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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +155 -0
- data/app/public/application.js +609 -0
- data/app/public/database-diagram.js +602 -0
- data/app/public/styles.css +2141 -0
- data/app/views/data.haml +166 -0
- data/app/views/group.haml +50 -0
- data/app/views/index.haml +108 -0
- data/app/views/layout.haml +59 -0
- data/app/views/record.haml +81 -0
- data/demo/README.md +47 -0
- data/demo/compose.yml +34 -0
- data/demo/seed.rb +270 -0
- data/demo/smoke.rb +13 -0
- data/docs/RELEASING.md +30 -0
- data/docs/VALUE_PRESENTATION.md +54 -0
- data/exe/rdbr +5 -0
- data/lib/rdbr/access/read_only.rb +61 -0
- data/lib/rdbr/access.rb +6 -0
- data/lib/rdbr/browser_launcher.rb +71 -0
- data/lib/rdbr/catalog/connection.rb +68 -0
- data/lib/rdbr/catalog/errors.rb +12 -0
- data/lib/rdbr/catalog/mysql/queries.rb +131 -0
- data/lib/rdbr/catalog/mysql.rb +146 -0
- data/lib/rdbr/catalog/postgresql/queries.rb +171 -0
- data/lib/rdbr/catalog/postgresql.rb +138 -0
- data/lib/rdbr/catalog/reader.rb +30 -0
- data/lib/rdbr/catalog/serializer.rb +24 -0
- data/lib/rdbr/catalog/sqlite.rb +182 -0
- data/lib/rdbr/catalog.rb +20 -0
- data/lib/rdbr/cli.rb +74 -0
- data/lib/rdbr/cli_options.rb +63 -0
- data/lib/rdbr/metadata/column.rb +44 -0
- data/lib/rdbr/metadata/constraints.rb +25 -0
- data/lib/rdbr/metadata/database.rb +37 -0
- data/lib/rdbr/metadata/index.rb +30 -0
- data/lib/rdbr/metadata/keys.rb +56 -0
- data/lib/rdbr/metadata/namespace.rb +26 -0
- data/lib/rdbr/metadata/relation.rb +104 -0
- data/lib/rdbr/metadata/relationship_inference.rb +75 -0
- data/lib/rdbr/metadata/relationships.rb +126 -0
- data/lib/rdbr/metadata/values.rb +73 -0
- data/lib/rdbr/metadata.rb +10 -0
- data/lib/rdbr/presentation/record_identity.rb +125 -0
- data/lib/rdbr/query/connection.rb +138 -0
- data/lib/rdbr/query/errors.rb +33 -0
- data/lib/rdbr/query/lookup.rb +40 -0
- data/lib/rdbr/query/mysql.rb +15 -0
- data/lib/rdbr/query/page.rb +66 -0
- data/lib/rdbr/query/postgresql.rb +7 -0
- data/lib/rdbr/query/postgresql_dialect.rb +11 -0
- data/lib/rdbr/query/relational.rb +239 -0
- data/lib/rdbr/query/sqlite.rb +15 -0
- data/lib/rdbr/query/type_support.rb +68 -0
- data/lib/rdbr/query/value_limiter.rb +38 -0
- data/lib/rdbr/query.rb +32 -0
- data/lib/rdbr/version.rb +3 -0
- data/lib/rdbr/web/application.rb +234 -0
- data/lib/rdbr/web/array_value_helpers.rb +92 -0
- data/lib/rdbr/web/binary_value_helpers.rb +51 -0
- data/lib/rdbr/web/column_preferences.rb +56 -0
- data/lib/rdbr/web/database_graph.rb +91 -0
- data/lib/rdbr/web/deferred_column_value_helpers.rb +20 -0
- data/lib/rdbr/web/deferred_value_helpers.rb +76 -0
- data/lib/rdbr/web/document_value_helpers.rb +68 -0
- data/lib/rdbr/web/filter_presentation_helpers.rb +44 -0
- data/lib/rdbr/web/helpers.rb +42 -0
- data/lib/rdbr/web/identifier_value_helpers.rb +20 -0
- data/lib/rdbr/web/json_page_stream.rb +30 -0
- data/lib/rdbr/web/navigation_helpers.rb +115 -0
- data/lib/rdbr/web/presentation_helpers.rb +114 -0
- data/lib/rdbr/web/range_value_helpers.rb +86 -0
- data/lib/rdbr/web/record_value_helpers.rb +90 -0
- data/lib/rdbr/web/related_value_helpers.rb +99 -0
- data/lib/rdbr/web/relationship_loader.rb +65 -0
- data/lib/rdbr/web/search_value_helpers.rb +58 -0
- data/lib/rdbr/web/spatial_value_helpers.rb +83 -0
- data/lib/rdbr/web/through_relationship_loader.rb +90 -0
- data/lib/rdbr/web/vector_value_helpers.rb +59 -0
- data/lib/rdbr.rb +25 -0
- 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,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
|
data/lib/rdbr/access.rb
ADDED
|
@@ -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
|