rdbr 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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +155 -0
  5. data/app/public/application.js +609 -0
  6. data/app/public/database-diagram.js +602 -0
  7. data/app/public/styles.css +2141 -0
  8. data/app/views/data.haml +166 -0
  9. data/app/views/group.haml +50 -0
  10. data/app/views/index.haml +108 -0
  11. data/app/views/layout.haml +59 -0
  12. data/app/views/record.haml +81 -0
  13. data/demo/README.md +47 -0
  14. data/demo/compose.yml +34 -0
  15. data/demo/seed.rb +270 -0
  16. data/demo/smoke.rb +13 -0
  17. data/docs/RELEASING.md +30 -0
  18. data/docs/VALUE_PRESENTATION.md +54 -0
  19. data/exe/rdbr +5 -0
  20. data/lib/rdbr/access/read_only.rb +61 -0
  21. data/lib/rdbr/access.rb +6 -0
  22. data/lib/rdbr/browser_launcher.rb +71 -0
  23. data/lib/rdbr/catalog/connection.rb +68 -0
  24. data/lib/rdbr/catalog/errors.rb +12 -0
  25. data/lib/rdbr/catalog/mysql/queries.rb +131 -0
  26. data/lib/rdbr/catalog/mysql.rb +146 -0
  27. data/lib/rdbr/catalog/postgresql/queries.rb +171 -0
  28. data/lib/rdbr/catalog/postgresql.rb +138 -0
  29. data/lib/rdbr/catalog/reader.rb +30 -0
  30. data/lib/rdbr/catalog/serializer.rb +24 -0
  31. data/lib/rdbr/catalog/sqlite.rb +182 -0
  32. data/lib/rdbr/catalog.rb +20 -0
  33. data/lib/rdbr/cli.rb +74 -0
  34. data/lib/rdbr/cli_options.rb +63 -0
  35. data/lib/rdbr/metadata/column.rb +44 -0
  36. data/lib/rdbr/metadata/constraints.rb +25 -0
  37. data/lib/rdbr/metadata/database.rb +37 -0
  38. data/lib/rdbr/metadata/index.rb +30 -0
  39. data/lib/rdbr/metadata/keys.rb +56 -0
  40. data/lib/rdbr/metadata/namespace.rb +26 -0
  41. data/lib/rdbr/metadata/relation.rb +104 -0
  42. data/lib/rdbr/metadata/relationship_inference.rb +75 -0
  43. data/lib/rdbr/metadata/relationships.rb +126 -0
  44. data/lib/rdbr/metadata/values.rb +73 -0
  45. data/lib/rdbr/metadata.rb +10 -0
  46. data/lib/rdbr/presentation/record_identity.rb +125 -0
  47. data/lib/rdbr/query/connection.rb +138 -0
  48. data/lib/rdbr/query/errors.rb +33 -0
  49. data/lib/rdbr/query/lookup.rb +40 -0
  50. data/lib/rdbr/query/mysql.rb +15 -0
  51. data/lib/rdbr/query/page.rb +66 -0
  52. data/lib/rdbr/query/postgresql.rb +7 -0
  53. data/lib/rdbr/query/postgresql_dialect.rb +11 -0
  54. data/lib/rdbr/query/relational.rb +239 -0
  55. data/lib/rdbr/query/sqlite.rb +15 -0
  56. data/lib/rdbr/query/type_support.rb +68 -0
  57. data/lib/rdbr/query/value_limiter.rb +38 -0
  58. data/lib/rdbr/query.rb +32 -0
  59. data/lib/rdbr/version.rb +3 -0
  60. data/lib/rdbr/web/application.rb +234 -0
  61. data/lib/rdbr/web/array_value_helpers.rb +92 -0
  62. data/lib/rdbr/web/binary_value_helpers.rb +51 -0
  63. data/lib/rdbr/web/column_preferences.rb +56 -0
  64. data/lib/rdbr/web/database_graph.rb +91 -0
  65. data/lib/rdbr/web/deferred_column_value_helpers.rb +20 -0
  66. data/lib/rdbr/web/deferred_value_helpers.rb +76 -0
  67. data/lib/rdbr/web/document_value_helpers.rb +68 -0
  68. data/lib/rdbr/web/filter_presentation_helpers.rb +44 -0
  69. data/lib/rdbr/web/helpers.rb +42 -0
  70. data/lib/rdbr/web/identifier_value_helpers.rb +20 -0
  71. data/lib/rdbr/web/json_page_stream.rb +30 -0
  72. data/lib/rdbr/web/navigation_helpers.rb +115 -0
  73. data/lib/rdbr/web/presentation_helpers.rb +114 -0
  74. data/lib/rdbr/web/range_value_helpers.rb +86 -0
  75. data/lib/rdbr/web/record_value_helpers.rb +90 -0
  76. data/lib/rdbr/web/related_value_helpers.rb +99 -0
  77. data/lib/rdbr/web/relationship_loader.rb +65 -0
  78. data/lib/rdbr/web/search_value_helpers.rb +58 -0
  79. data/lib/rdbr/web/spatial_value_helpers.rb +83 -0
  80. data/lib/rdbr/web/through_relationship_loader.rb +90 -0
  81. data/lib/rdbr/web/vector_value_helpers.rb +59 -0
  82. data/lib/rdbr.rb +25 -0
  83. metadata +298 -0
data/demo/seed.rb ADDED
@@ -0,0 +1,270 @@
1
+ require 'active_record'
2
+ require 'date'
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module RDBrDemo
8
+ module_function
9
+
10
+ FIRST_NAMES = %w[Aisha Alice Amelia Daniel Eleanor Farah George Grace Hannah Isaac James Maya Noah Priya Thomas
11
+ Yusuf].freeze
12
+ LAST_NAMES = %w[Ahmed Blake Chen Davies Evans Foster Garcia Hughes Ibrahim Jones Khan Morgan Patel Reed Singh
13
+ Wilson].freeze
14
+ CITIES = [ 'Bath', 'Belfast', 'Birmingham', 'Bristol', 'Cardiff', 'Edinburgh', 'Leeds', 'London',
15
+ 'Manchester' ].freeze
16
+ CATEGORIES = [ 'Books', 'Garden', 'Home', 'Kitchen', 'Office', 'Outdoors', 'Toys', 'Travel' ].freeze
17
+ PRODUCT_ADJECTIVES = %w[Compact Everyday Foldable Lightweight Modular Recycled Reusable Softwood].freeze
18
+ PRODUCT_NOUNS = [
19
+ 'Backpack', 'Basket', 'Blanket', 'Bottle', 'Desk lamp', 'Journal', 'Plant pot', 'Puzzle',
20
+ 'Shelf', 'Storage box', 'Travel mug', 'Utensil set'
21
+ ].freeze
22
+ TAGS = [ 'bestseller', 'compact', 'eco-friendly', 'gift', 'handmade', 'new', 'outdoor', 'recycled',
23
+ 'travel', 'warranty' ].freeze
24
+
25
+ class Category < ActiveRecord::Base; end
26
+ class Product < ActiveRecord::Base; end
27
+ class Tag < ActiveRecord::Base; end
28
+ class ProductTag < ActiveRecord::Base; end
29
+ class Customer < ActiveRecord::Base; end
30
+ class Address < ActiveRecord::Base; end
31
+ class Order < ActiveRecord::Base; end
32
+ class OrderItem < ActiveRecord::Base; end
33
+ class Shipment < ActiveRecord::Base; end
34
+
35
+ def run
36
+ database_url = ENV.fetch('DEMO_DATABASE_URL', default_sqlite_url)
37
+ validate_target!(database_url)
38
+ prepare_sqlite_directory(database_url)
39
+ ActiveRecord::Base.establish_connection(database_url)
40
+ rebuild_schema
41
+ seed_records
42
+ puts "Seeded RDBr demonstration database at #{sanitized_target(database_url)}"
43
+ ensure
44
+ ActiveRecord::Base.connection_pool.disconnect! if ActiveRecord::Base.connected?
45
+ end
46
+
47
+ def rebuild_schema
48
+ ActiveRecord::Schema.define do
49
+ %i[shipments order_items orders addresses customers product_tags tags products categories].each do |table|
50
+ drop_table table, if_exists: true
51
+ end
52
+
53
+ create_table(:categories){|table| table.string :name, null: false, index: { unique: true } }
54
+ create_table :products do |table|
55
+ table.references :category, null: false, foreign_key: true
56
+ table.string :sku, null: false, index: { unique: true }
57
+ table.string :name, null: false
58
+ table.text :description, null: false
59
+ table.decimal :price, precision: 10, scale: 2, null: false
60
+ table.integer :stock, null: false
61
+ table.boolean :active, null: false, default: true
62
+ table.json :details
63
+ table.timestamps null: false
64
+ end
65
+ create_table(:tags){|table| table.string :name, null: false, index: { unique: true } }
66
+ create_table :product_tags do |table|
67
+ table.references :product, null: false, foreign_key: true
68
+ table.references :tag, null: false, foreign_key: true
69
+ table.index [ :product_id, :tag_id ], unique: true
70
+ end
71
+ create_table :customers do |table|
72
+ table.string :first_name, null: false
73
+ table.string :last_name, null: false
74
+ table.string :email, null: false, index: { unique: true }
75
+ table.string :phone
76
+ table.date :joined_on, null: false
77
+ table.timestamps null: false
78
+ end
79
+ create_table :addresses do |table|
80
+ table.references :customer, null: false, foreign_key: true
81
+ table.string :label, null: false
82
+ table.string :line_1, null: false
83
+ table.string :line_2
84
+ table.string :city, null: false
85
+ table.string :postcode, null: false
86
+ table.string :country_code, null: false, limit: 2
87
+ table.timestamps null: false
88
+ end
89
+ create_table :orders do |table|
90
+ table.references :customer, null: false, foreign_key: true
91
+ table.references :shipping_address, null: false, foreign_key: { to_table: :addresses }
92
+ table.string :number, null: false, index: { unique: true }
93
+ table.string :status, null: false
94
+ table.date :ordered_on, null: false
95
+ table.decimal :total, precision: 12, scale: 2, null: false
96
+ table.json :preferences
97
+ table.timestamps null: false
98
+ end
99
+ create_table :order_items do |table|
100
+ table.references :order, null: false, foreign_key: true
101
+ table.references :product, null: false, foreign_key: true
102
+ table.integer :quantity, null: false
103
+ table.decimal :unit_price, precision: 10, scale: 2, null: false
104
+ table.decimal :discount, precision: 5, scale: 2, null: false, default: 0
105
+ table.index [ :order_id, :product_id ], unique: true
106
+ end
107
+ create_table :shipments do |table|
108
+ table.references :order, null: false, foreign_key: true, index: { unique: true }
109
+ table.string :carrier, null: false
110
+ table.string :tracking_number, index: { unique: true }
111
+ table.date :dispatched_on
112
+ table.date :delivered_on
113
+ table.timestamps null: false
114
+ end
115
+ end
116
+ end
117
+
118
+ def seed_records
119
+ now = Time.utc(2026, 7, 21, 10, 30)
120
+ Category.insert_all!(CATEGORIES.map{|name| { name: name } })
121
+ Tag.insert_all!(TAGS.map{|name| { name: name } })
122
+ seed_products(now)
123
+ seed_product_tags
124
+ seed_customers(now)
125
+ seed_addresses(now)
126
+ seed_orders(now)
127
+ seed_shipments(now)
128
+ end
129
+
130
+ def seed_products(now)
131
+ category_ids = Category.order(:id).pluck(:id)
132
+ rows = 80.times.map do |index|
133
+ name = "#{PRODUCT_ADJECTIVES[index % PRODUCT_ADJECTIVES.length]} " \
134
+ "#{PRODUCT_NOUNS[(index * 5) % PRODUCT_NOUNS.length]}"
135
+ {
136
+ category_id: category_ids[index % category_ids.length],
137
+ sku: format('RDBR-%04d', 1001 + index),
138
+ name: "#{name} #{index + 1}",
139
+ description: "#{name} made for an ordinary demonstration shop. " * (2 + (index % 5)),
140
+ price: 4.95 + (index * 2.35), stock: (index * 13) % 121, active: (index % 11) != 0,
141
+ details: JSON.generate(
142
+ colour: %w[blue green natural red yellow][index % 5],
143
+ material: %w[canvas ceramic cotton paper wood][index % 5],
144
+ weight_grams: 150 + (index * 17)
145
+ ),
146
+ created_at: now - (index * 86_400), updated_at: now - (index * 1_800)
147
+ }
148
+ end
149
+ Product.insert_all!(rows)
150
+ end
151
+
152
+ def seed_product_tags
153
+ tag_ids = Tag.order(:id).pluck(:id)
154
+ rows = Product.order(:id).flat_map.with_index do |product, index|
155
+ [ index % tag_ids.length, (index * 3 + 1) % tag_ids.length ].uniq.map do |tag_index|
156
+ { product_id: product.id, tag_id: tag_ids[tag_index] }
157
+ end
158
+ end
159
+ ProductTag.insert_all!(rows)
160
+ end
161
+
162
+ def seed_customers(now)
163
+ rows = 45.times.map do |index|
164
+ first_name = FIRST_NAMES[index % FIRST_NAMES.length]
165
+ last_name = LAST_NAMES[(index * 3) % LAST_NAMES.length]
166
+ {
167
+ first_name: first_name, last_name: last_name,
168
+ email: "#{first_name}.#{last_name}.#{index}@example.test".downcase,
169
+ phone: index.even? ? format('+44 20 7%<area>03d %<number>04d', area: 100 + index, number: 1000 + index) : nil,
170
+ joined_on: Date.new(2023 + (index % 4), 1 + ((index * 5) % 12), 1 + ((index * 7) % 24)),
171
+ created_at: now - (index * 172_800), updated_at: now - (index * 3_600)
172
+ }
173
+ end
174
+ Customer.insert_all!(rows)
175
+ end
176
+
177
+ def seed_addresses(now)
178
+ rows = Customer.order(:id).flat_map.with_index do |customer, index|
179
+ count = (index % 3).zero? ? 2 : 1
180
+ count.times.map do |offset|
181
+ {
182
+ customer_id: customer.id, label: offset.zero? ? 'Home' : 'Work',
183
+ line_1: "#{12 + index + offset} #{%w[Oak Station Market Garden][index % 4]} Street",
184
+ line_2: (index % 4).zero? ? "Flat #{1 + (index % 9)}" : nil,
185
+ city: CITIES[(index + offset) % CITIES.length],
186
+ postcode: format('AB%<area>d %<number>dCD', area: 1 + (index % 9), number: 1 + (index % 8)),
187
+ country_code: 'GB', created_at: now - (index * 86_400), updated_at: now - (index * 2_400)
188
+ }
189
+ end
190
+ end
191
+ Address.insert_all!(rows)
192
+ end
193
+
194
+ def seed_orders(now)
195
+ products = Product.order(:id).to_a
196
+ 135.times do |index|
197
+ customer = Customer.order(:id).offset(index % Customer.count).first
198
+ customer_addresses = Address.where(customer_id: customer.id)
199
+ address = customer_addresses.order(:id).offset(index % customer_addresses.count).first
200
+ ordered_on = Date.new(2025 + (index % 2), 1 + ((index * 5) % 12), 1 + ((index * 7) % 24))
201
+ status = %w[awaiting_payment processing dispatched delivered cancelled][index % 5]
202
+ items = (2 + (index % 4)).times.map do |offset|
203
+ product = products[(index * 7 + offset * 11) % products.length]
204
+ quantity = 1 + ((index + offset) % 4)
205
+ discount = ((index + offset) % 9).zero? ? 10 : 0
206
+ { product: product, quantity: quantity, unit_price: product.price, discount: discount }
207
+ end
208
+ total = items.sum{|item| item[:unit_price] * item[:quantity] * (1 - item[:discount] / 100.to_d) }
209
+ order = Order.create!(
210
+ customer_id: customer.id, shipping_address_id: address.id, number: format('ORD-%06d', 10_001 + index),
211
+ status: status, ordered_on: ordered_on, total: total,
212
+ preferences: JSON.generate(
213
+ gift_wrap: (index % 8).zero?,
214
+ delivery_note: (index % 6).zero? ? 'Leave in a safe place' : nil
215
+ ),
216
+ created_at: now - (index * 21_600), updated_at: now - (index * 1_200)
217
+ )
218
+ OrderItem.insert_all!(
219
+ items.map do |item|
220
+ {
221
+ order_id: order.id, product_id: item[:product].id, quantity: item[:quantity],
222
+ unit_price: item[:unit_price], discount: item[:discount]
223
+ }
224
+ end
225
+ )
226
+ end
227
+ end
228
+
229
+ def seed_shipments(now)
230
+ carriers = [ 'Parcel Post', 'Swift Delivery', 'Town Courier' ]
231
+ rows = Order.where(status: %w[dispatched delivered]).order(:id).map.with_index do |order, index|
232
+ dispatched_on = order.ordered_on + 2 + (index % 3)
233
+ delivered = order.status == 'delivered'
234
+ {
235
+ order_id: order.id, carrier: carriers[index % carriers.length],
236
+ tracking_number: format('TRACK%08d', 50_000 + index),
237
+ dispatched_on: dispatched_on, delivered_on: delivered ? dispatched_on + 1 + (index % 3) : nil,
238
+ created_at: now - (index * 14_400), updated_at: now - (index * 900)
239
+ }
240
+ end
241
+ Shipment.insert_all!(rows)
242
+ end
243
+
244
+ def validate_target!(database_url)
245
+ return if database_url == default_sqlite_url
246
+
247
+ uri = URI.parse(database_url)
248
+ safe_host = [ 'localhost', '127.0.0.1' ].include?(uri.host)
249
+ safe_database = uri.path == '/rdbr_demo'
250
+ return if safe_host && safe_database
251
+
252
+ abort 'Refusing to rebuild a database unless it is the local rdbr_demo database'
253
+ rescue URI::InvalidURIError
254
+ abort 'DEMO_DATABASE_URL must be a valid local rdbr_demo URL'
255
+ end
256
+
257
+ def default_sqlite_url
258
+ "sqlite3:#{File.expand_path('../tmp/rdbr-demo.sqlite3', __dir__)}"
259
+ end
260
+
261
+ def prepare_sqlite_directory(database_url)
262
+ FileUtils.mkdir_p(File.dirname(database_url.delete_prefix('sqlite3:'))) if database_url.start_with?('sqlite3:')
263
+ end
264
+
265
+ def sanitized_target(database_url)
266
+ database_url.sub(%r{://[^@]+@}, '://***@')
267
+ end
268
+ end
269
+
270
+ RDBrDemo.run if $PROGRAM_NAME == __FILE__
data/demo/smoke.rb ADDED
@@ -0,0 +1,13 @@
1
+ require 'rdbr'
2
+
3
+ database_url = ENV.fetch('DEMO_DATABASE_URL') do
4
+ "sqlite3:#{File.expand_path('../tmp/rdbr-demo.sqlite3', __dir__)}"
5
+ end
6
+ catalog = RDBr::Catalog.read(database_url: database_url)
7
+ query = RDBr::Query.build(database_url: database_url, catalog: catalog)
8
+ namespace = catalog.namespaces.find{|candidate| candidate.relation('products') } or abort 'products table not found'
9
+ page = query.page(namespace: namespace.name, relation: 'products', per_page: 10, include_total: true)
10
+ abort "expected 80 products, found #{page.total_count}" unless page.total_count == 80
11
+
12
+ puts "Verified #{catalog.adapter} catalog and row queries against #{catalog.name}"
13
+ query.disconnect
data/docs/RELEASING.md ADDED
@@ -0,0 +1,30 @@
1
+ # Releasing RDBr
2
+
3
+ RDBr uses semantic versions and RubyGems trusted publishing. No long-lived
4
+ RubyGems API key belongs in GitHub secrets.
5
+
6
+ ## One-time setup
7
+
8
+ Create a pending trusted publisher in your RubyGems.org account with:
9
+
10
+ - gem name: `rdbr`;
11
+ - repository owner: `andrewtblake`;
12
+ - repository name: `rdbr`;
13
+ - workflow filename: `release.yml`; and
14
+ - GitHub environment: `release`.
15
+
16
+ Protect the `release` environment in GitHub if a manual approval before
17
+ publication is desirable.
18
+
19
+ ## Release checklist
20
+
21
+ 1. Move the relevant `CHANGELOG.md` entries from Unreleased into a dated version.
22
+ 2. Set `RDBr::VERSION` to that version.
23
+ 3. Run `bundle exec rake` and `bundle exec rake build`.
24
+ 4. Commit and push the release preparation.
25
+ 5. Create and push an annotated `vVERSION` tag.
26
+
27
+ The tag starts `release.yml`, which verifies that the tag and gem version agree,
28
+ runs the full default checks, builds the gem, and publishes it using a short-lived
29
+ OpenID Connect credential. Publishing is deliberately impossible from an
30
+ untagged branch build.
@@ -0,0 +1,54 @@
1
+ # Value presentation
2
+
3
+ RDBr chooses an HTML presentation from discovered column metadata and the
4
+ returned value. This remains a zero-configuration policy: it does not require
5
+ application models, type registrations, or per-database settings.
6
+
7
+ Compact scalar values stay inline. Values that would dominate a row, are hard
8
+ to scan, or are unsafe to emit as text use a **View** control, a short summary,
9
+ and a clipboard button. The shared popover is keyboard accessible, remains open
10
+ while its content is scrolled, and constrains both its height and width.
11
+
12
+ ## Recognized families
13
+
14
+ | Family | Recognition | Presentation |
15
+ | --- | --- | --- |
16
+ | Identifiers | UUID types and UUID-like names/values | View and copy controls |
17
+ | JSON/JSONB | Native metadata, including nested serialized JSON | Pretty-printed document view with copy |
18
+ | Vectors | PostgreSQL `vector`, `halfvec`, `sparsevec`; MariaDB vectors; inferred bit embeddings | Dimension summary, view, and copy |
19
+ | Arrays | PostgreSQL array metadata | Small one-dimensional arrays inline; large or multidimensional arrays deferred |
20
+ | Binary | `bytea`, binary, and blob families | Byte count; Base64 view and copy so arbitrary bytes never enter the page |
21
+ | Key/value and documents | PostgreSQL `hstore` and XML | Entry count or XML root summary with exact-source view and copy |
22
+ | Bit strings | `bit` and `varbit` not inferred as embeddings | Bit count, view, and copy |
23
+ | Full-text search | PostgreSQL `tsvector` and `tsquery` | Lexeme or term count, view, and copy |
24
+ | Spatial | PostgreSQL geometric types, PostGIS, and MySQL/MariaDB geometry | Geometry/SRID summary for text; byte count and Base64 for binary WKB |
25
+ | Ranges | PostgreSQL range and multirange types | Range type, empty state, or contained-range count |
26
+ | Unfamiliar types | Extension and custom types without another match | Short values inline; values over 160 characters deferred with their SQL type |
27
+
28
+ Long ordinary text uses its existing preview and full-text dialog. Timestamps
29
+ are normalized to `YYYY-MM-DD HH:MM:SS`. Nulls remain visibly distinct from
30
+ empty strings.
31
+
32
+ ## Precedence
33
+
34
+ Some physical representations are ambiguous, so classification is deliberately
35
+ ordered:
36
+
37
+ 1. inferred vectors before ordinary arrays or bit strings;
38
+ 2. spatial metadata before generic binary detection, preserving WKB context;
39
+ 3. binary normalization before generic structured-value rendering;
40
+ 4. known document, search, and range families before the unfamiliar-type
41
+ fallback; and
42
+ 5. identifier inference after type-specific presentation.
43
+
44
+ Related-record labels take precedence over all of these in table cells because
45
+ the relationship supplies the most useful navigation. Filtering still uses the
46
+ underlying stored key.
47
+
48
+ ## Safety and limits
49
+
50
+ All values and summaries are HTML-escaped. Binary content is never decoded into
51
+ the document as arbitrary text. RDBr does not execute, deserialize, or render
52
+ XML, spatial data, or custom extension values; it presents their textual form or
53
+ a Base64 representation. Query-layer value limits apply before presentation,
54
+ so the popover does not bypass `--max-value-bytes` truncation safeguards.
data/exe/rdbr ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rdbr'
4
+
5
+ exit RDBr::CLI.run(ARGV)
@@ -0,0 +1,61 @@
1
+ module RDBr
2
+ module Access
3
+ class ReadOnly
4
+ MODE = 'read-only'.freeze
5
+ READ_STATEMENT = /\A\s*SELECT\b/i
6
+
7
+ attr_reader :statement_timeout
8
+
9
+ def initialize(statement_timeout: 5000)
10
+ @statement_timeout = positive_integer(statement_timeout, 'statement timeout')
11
+ end
12
+
13
+ def mode
14
+ MODE
15
+ end
16
+
17
+ def allow_http_method?(method)
18
+ [ 'GET', 'HEAD' ].include?(method)
19
+ end
20
+
21
+ def validate_statement!(sql)
22
+ return if READ_STATEMENT.match?(sql.to_s)
23
+
24
+ raise Query::AccessViolationError, 'Only read-only SELECT statements are permitted'
25
+ end
26
+
27
+ def configure(connection, adapter_name:, mariadb: false)
28
+ case adapter_name
29
+ when 'postgresql', 'postgis'
30
+ connection.execute('SET default_transaction_read_only = on')
31
+ connection.execute("SET statement_timeout = #{statement_timeout}")
32
+ when 'sqlite', 'sqlite3'
33
+ connection.execute('PRAGMA query_only = ON')
34
+ connection.execute("PRAGMA busy_timeout = #{statement_timeout}")
35
+ when 'mysql', 'mysql2', 'trilogy'
36
+ connection.execute('SET SESSION TRANSACTION READ ONLY')
37
+ configure_mysql_timeout(connection, mariadb)
38
+ else
39
+ raise ArgumentError, "unsupported query adapter: #{adapter_name}"
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def configure_mysql_timeout(connection, mariadb)
46
+ if mariadb
47
+ connection.execute("SET SESSION max_statement_time = #{statement_timeout.fdiv(1000)}")
48
+ else
49
+ connection.execute("SET SESSION max_execution_time = #{statement_timeout}")
50
+ end
51
+ end
52
+
53
+ def positive_integer(value, label)
54
+ number = Integer(value)
55
+ raise ArgumentError, "#{label} must be positive" unless number.positive?
56
+
57
+ number
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,6 @@
1
+ require_relative 'access/read_only'
2
+
3
+ module RDBr
4
+ module Access
5
+ end
6
+ end
@@ -0,0 +1,71 @@
1
+ require 'net/http'
2
+ require 'rbconfig'
3
+ require 'uri'
4
+
5
+ module RDBr
6
+ class BrowserLauncher
7
+ READY_TIMEOUT = 10
8
+ RETRY_INTERVAL = 0.1
9
+ WILDCARD_HOSTS = [ '0.0.0.0', '::', '[::]' ].freeze
10
+
11
+ class << self
12
+ def open_when_ready(host:, port:, output: $stdout)
13
+ url = browser_url(host: host, port: port)
14
+ Thread.new do
15
+ Thread.current.report_on_exception = false
16
+ next unless wait_until_ready(url)
17
+
18
+ output.puts "Open RDBr at #{url}" unless launch(url)
19
+ end
20
+ end
21
+
22
+ def browser_url(host:, port:)
23
+ browser_host = WILDCARD_HOSTS.include?(host) ? '127.0.0.1' : host
24
+ URI::HTTP.build(host: browser_host, port: port, path: '/').to_s
25
+ end
26
+
27
+ private
28
+
29
+ def wait_until_ready(url)
30
+ health_uri = URI.join(url, '/health')
31
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + READY_TIMEOUT
32
+ loop do
33
+ return true if healthy?(health_uri)
34
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
35
+
36
+ sleep RETRY_INTERVAL
37
+ end
38
+ end
39
+
40
+ def healthy?(uri)
41
+ client = Net::HTTP.new(uri.hostname, uri.port)
42
+ client.open_timeout = RETRY_INTERVAL
43
+ client.read_timeout = RETRY_INTERVAL
44
+ client.get(uri.request_uri).is_a?(Net::HTTPSuccess)
45
+ rescue IOError, SystemCallError, Timeout::Error
46
+ false
47
+ end
48
+
49
+ def launch(url)
50
+ launch_commands(url).any? do |command|
51
+ pid = Process.spawn(*command, out: File::NULL, err: File::NULL)
52
+ Process.detach(pid)
53
+ true
54
+ rescue Errno::ENOENT
55
+ false
56
+ end
57
+ end
58
+
59
+ def launch_commands(url)
60
+ case RbConfig::CONFIG.fetch('host_os')
61
+ when /darwin/
62
+ [[ 'open', url ]]
63
+ when /mingw|mswin|cygwin/
64
+ [[ 'cmd', '/c', 'start', '', url ]]
65
+ else
66
+ [[ 'xdg-open', url ], [ 'gio', 'open', url ]]
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,68 @@
1
+ module RDBr
2
+ module Catalog
3
+ class Connection
4
+ class Record < ActiveRecord::Base
5
+ self.abstract_class = true
6
+
7
+ def readonly?
8
+ true
9
+ end
10
+ end
11
+
12
+ def initialize(database_url)
13
+ @record_class = Record
14
+ @record_class.establish_connection(database_url)
15
+ rescue ActiveRecord::AdapterNotSpecified, ActiveRecord::AdapterNotFound, ArgumentError => e
16
+ raise ConnectionError, "Unable to configure database connection (#{e.class})"
17
+ end
18
+
19
+ def adapter_name
20
+ connection.adapter_name.downcase
21
+ rescue ActiveRecord::ActiveRecordError => e
22
+ raise ConnectionError, "Unable to connect to database (#{e.class})"
23
+ end
24
+
25
+ def database_version
26
+ connection.database_version
27
+ rescue ActiveRecord::ActiveRecordError => e
28
+ raise ConnectionError, "Unable to read database version (#{e.class})"
29
+ end
30
+
31
+ def database_product
32
+ connection.select_value('SELECT VERSION()')
33
+ rescue ActiveRecord::ActiveRecordError => e
34
+ raise ConnectionError, "Unable to read database product (#{e.class})"
35
+ end
36
+
37
+ def select_all(sql)
38
+ connection.select_all(sql).to_a
39
+ rescue ActiveRecord::ActiveRecordError => e
40
+ raise ConnectionError, "Database catalog query failed (#{e.class})"
41
+ end
42
+
43
+ def select_all_optional(sql)
44
+ connection.select_all(sql).to_a
45
+ rescue ActiveRecord::StatementInvalid
46
+ []
47
+ rescue ActiveRecord::ActiveRecordError => e
48
+ raise ConnectionError, "Database catalog query failed (#{e.class})"
49
+ end
50
+
51
+ delegate :quote, to: :connection
52
+
53
+ def quote_identifier(identifier)
54
+ connection.quote_column_name(identifier.to_s)
55
+ end
56
+
57
+ def disconnect
58
+ @record_class.connection_pool.disconnect!
59
+ end
60
+
61
+ private
62
+
63
+ def connection
64
+ @record_class.connection
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,12 @@
1
+ module RDBr
2
+ module Catalog
3
+ class Error < StandardError
4
+ end
5
+
6
+ class ConnectionError < Error
7
+ end
8
+
9
+ class UnsupportedAdapterError < Error
10
+ end
11
+ end
12
+ end