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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require 'base64'
|
|
2
|
+
require 'date'
|
|
3
|
+
|
|
4
|
+
module RDBr
|
|
5
|
+
module Query
|
|
6
|
+
Page = Data.define(:namespace, :relation, :columns, :rows, :page, :per_page, :has_next, :total_count) do
|
|
7
|
+
def self.build(**attributes)
|
|
8
|
+
new(
|
|
9
|
+
namespace: attributes.fetch(:namespace).to_s.freeze,
|
|
10
|
+
relation: attributes.fetch(:relation).to_s.freeze,
|
|
11
|
+
columns: attributes.fetch(:columns).map{|column| column.to_s.freeze }.freeze,
|
|
12
|
+
rows: normalize_rows(attributes.fetch(:rows)),
|
|
13
|
+
page: Integer(attributes.fetch(:page)),
|
|
14
|
+
per_page: Integer(attributes.fetch(:per_page)),
|
|
15
|
+
has_next: !!attributes.fetch(:has_next),
|
|
16
|
+
total_count: attributes[:total_count]&.then{|count| Integer(count) }
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def total_pages
|
|
21
|
+
return unless total_count
|
|
22
|
+
|
|
23
|
+
[ (total_count.to_f / per_page).ceil, 1 ].max
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{
|
|
28
|
+
namespace: namespace,
|
|
29
|
+
relation: relation,
|
|
30
|
+
columns: columns,
|
|
31
|
+
rows: rows,
|
|
32
|
+
page: page,
|
|
33
|
+
per_page: per_page,
|
|
34
|
+
has_next: has_next,
|
|
35
|
+
total_count: total_count,
|
|
36
|
+
total_pages: total_pages
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.normalize_rows(rows)
|
|
41
|
+
rows.map do |row|
|
|
42
|
+
row.to_h.transform_values{|value| normalize_value(value) }.freeze
|
|
43
|
+
end.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.normalize_value(value)
|
|
47
|
+
case value
|
|
48
|
+
when Time, DateTime then value.iso8601(6)
|
|
49
|
+
when Date then value.iso8601
|
|
50
|
+
when String then normalize_string(value)
|
|
51
|
+
when Array then value.map{|item| normalize_value(item) }.freeze
|
|
52
|
+
when Hash then value.transform_values{|item| normalize_value(item) }.freeze
|
|
53
|
+
else value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.normalize_string(value)
|
|
58
|
+
return value.freeze if value.valid_encoding?
|
|
59
|
+
|
|
60
|
+
{ 'encoding' => 'base64', 'data' => Base64.strict_encode64(value.b) }.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private_class_method :normalize_rows, :normalize_value, :normalize_string
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Query
|
|
3
|
+
class Relational
|
|
4
|
+
include TypeSupport
|
|
5
|
+
include Lookup
|
|
6
|
+
|
|
7
|
+
Request = Data.define(:namespace, :relation, :page_number, :page_size, :sort, :filters, :search)
|
|
8
|
+
GroupRequest = Data.define(:namespace, :relation, :column, :page_number, :page_size, :filters, :search)
|
|
9
|
+
DEFAULT_PAGE_SIZE = 25
|
|
10
|
+
|
|
11
|
+
def initialize(connection:, catalog:, max_results: 100)
|
|
12
|
+
@connection = connection
|
|
13
|
+
@catalog = catalog
|
|
14
|
+
@max_results = positive_integer(max_results, 'maximum result limit')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def page(namespace:, relation:, **options)
|
|
18
|
+
namespace_metadata, relation_metadata = find_relation(namespace, relation)
|
|
19
|
+
request = Request.new(
|
|
20
|
+
namespace: namespace_metadata,
|
|
21
|
+
relation: relation_metadata,
|
|
22
|
+
page_number: positive_integer(options.fetch(:page, 1), 'page'),
|
|
23
|
+
page_size: [ positive_integer(options.fetch(:per_page, DEFAULT_PAGE_SIZE), 'page size'), max_results ].min,
|
|
24
|
+
sort: options[:sort],
|
|
25
|
+
filters: options.fetch(:filters, {}),
|
|
26
|
+
search: options[:search]
|
|
27
|
+
)
|
|
28
|
+
total_count = count_rows(request) if options[:include_total]
|
|
29
|
+
rows = connection.select_all(select_sql(request))
|
|
30
|
+
build_page(request, rows, total_count)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build_page(request, rows, total_count)
|
|
34
|
+
Page.build(
|
|
35
|
+
namespace: request.namespace.name,
|
|
36
|
+
relation: request.relation.name,
|
|
37
|
+
columns: request.relation.columns.map(&:name),
|
|
38
|
+
rows: rows.first(request.page_size),
|
|
39
|
+
page: request.page_number,
|
|
40
|
+
per_page: request.page_size,
|
|
41
|
+
has_next: page_has_next?(request, rows, total_count),
|
|
42
|
+
total_count: total_count
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def page_has_next?(request, rows, total_count)
|
|
47
|
+
return rows.length > request.page_size unless total_count
|
|
48
|
+
|
|
49
|
+
request.page_number * request.page_size < total_count
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
delegate :disconnect, to: :connection
|
|
53
|
+
|
|
54
|
+
def record(namespace:, relation:, key:)
|
|
55
|
+
_namespace, relation_metadata = find_relation(namespace, relation)
|
|
56
|
+
primary_key = relation_metadata.primary_key
|
|
57
|
+
raise InvalidQueryError, 'This relation has no primary key' unless primary_key
|
|
58
|
+
|
|
59
|
+
normalized_key = key.to_h.transform_keys(&:to_s)
|
|
60
|
+
unless normalized_key.keys.sort == primary_key.columns.sort
|
|
61
|
+
raise InvalidQueryError, 'Record key must contain every primary-key column'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
page(namespace: namespace, relation: relation, per_page: 1, filters: normalized_key).rows.first
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def group(namespace:, relation:, column:, **options)
|
|
68
|
+
namespace_metadata, relation_metadata = find_relation(namespace, relation)
|
|
69
|
+
column_metadata = relation_metadata.column(column)
|
|
70
|
+
raise InvalidQueryError, "Unknown group column: #{column}" unless column_metadata
|
|
71
|
+
raise InvalidQueryError, "Unsupported group column: #{column}" unless groupable_column?(column_metadata)
|
|
72
|
+
|
|
73
|
+
page_number = positive_integer(options.fetch(:page, 1), 'page')
|
|
74
|
+
requested_size = positive_integer(options.fetch(:per_page, DEFAULT_PAGE_SIZE), 'page size')
|
|
75
|
+
page_size = [ requested_size, max_results ].min
|
|
76
|
+
request = GroupRequest.new(
|
|
77
|
+
namespace: namespace_metadata, relation: relation_metadata, column: column_metadata,
|
|
78
|
+
page_number: page_number, page_size: page_size,
|
|
79
|
+
filters: options.fetch(:filters, {}), search: options[:search]
|
|
80
|
+
)
|
|
81
|
+
rows = connection.select_all(group_sql(request))
|
|
82
|
+
Page.build(
|
|
83
|
+
namespace: namespace_metadata.name, relation: relation_metadata.name,
|
|
84
|
+
columns: [ 'value', 'count' ], rows: rows.first(page_size), page: page_number,
|
|
85
|
+
per_page: page_size, has_next: rows.length > page_size
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
attr_reader :connection, :catalog, :max_results
|
|
92
|
+
|
|
93
|
+
def select_sql(request)
|
|
94
|
+
table = quoted_table(request)
|
|
95
|
+
clauses = [ "SELECT * FROM #{table}" ]
|
|
96
|
+
where = where_clause(request.relation, request.filters, request.search)
|
|
97
|
+
clauses << "WHERE #{where}" unless where.empty?
|
|
98
|
+
clauses << "ORDER BY #{order_clause(request.relation, request.sort)}"
|
|
99
|
+
clauses << pagination_clause(request)
|
|
100
|
+
clauses.join("\n")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def count_rows(request)
|
|
104
|
+
clauses = [ "SELECT COUNT(*) FROM #{quoted_table(request)}" ]
|
|
105
|
+
where = where_clause(request.relation, request.filters, request.search)
|
|
106
|
+
clauses << "WHERE #{where}" unless where.empty?
|
|
107
|
+
connection.select_value(clauses.join("\n")).to_i
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def quoted_table(request)
|
|
111
|
+
[ request.namespace.name, request.relation.name ]
|
|
112
|
+
.map{|name| connection.quote_identifier(name) }.join('.')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def pagination_clause(request)
|
|
116
|
+
"LIMIT #{request.page_size + 1} OFFSET #{(request.page_number - 1) * request.page_size}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def group_sql(request)
|
|
120
|
+
table = quoted_table(request)
|
|
121
|
+
identifier = connection.quote_identifier(request.column.name)
|
|
122
|
+
offset = (request.page_number - 1) * request.page_size
|
|
123
|
+
clauses = [ "SELECT #{identifier} AS value, COUNT(*) AS count", "FROM #{table}" ]
|
|
124
|
+
where = where_clause(request.relation, request.filters, request.search)
|
|
125
|
+
clauses << "WHERE #{where}" unless where.empty?
|
|
126
|
+
clauses << "GROUP BY #{identifier}"
|
|
127
|
+
clauses << "ORDER BY count DESC, #{text_expression(identifier)} ASC"
|
|
128
|
+
clauses << "LIMIT #{request.page_size + 1} OFFSET #{offset}"
|
|
129
|
+
clauses.join("\n")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def find_relation(namespace_name, relation_name)
|
|
133
|
+
namespace = catalog.namespace(namespace_name)
|
|
134
|
+
relation = namespace&.relation(relation_name)
|
|
135
|
+
raise RelationNotFoundError, 'Unknown namespace or relation' unless relation
|
|
136
|
+
|
|
137
|
+
[ namespace, relation ]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def where_clause(relation, filters, search = nil)
|
|
141
|
+
clauses = normalize_filters(filters).map do |column_name, operator, value|
|
|
142
|
+
column = relation.column(column_name)
|
|
143
|
+
raise InvalidQueryError, "Unknown filter column: #{column_name}" unless column
|
|
144
|
+
|
|
145
|
+
validate_operator!(column, operator)
|
|
146
|
+
filter_expression(column, operator, value)
|
|
147
|
+
end
|
|
148
|
+
clauses << search_expression(relation, search) unless search.to_s.empty?
|
|
149
|
+
clauses.join(' AND ')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def search_expression(relation, search)
|
|
153
|
+
columns = relation.columns.select{|column| type_group(column) == :text }
|
|
154
|
+
raise InvalidQueryError, 'This relation has no searchable text columns' if columns.empty?
|
|
155
|
+
|
|
156
|
+
value = connection.quote("%#{escape_pattern(search)}%")
|
|
157
|
+
expressions = columns.map{|column| pattern_expression(connection.quote_identifier(column.name), value) }
|
|
158
|
+
"(#{expressions.join(' OR ')})"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def normalize_filters(filters)
|
|
162
|
+
filters.to_h.flat_map do |column, condition|
|
|
163
|
+
if condition.is_a?(Hash)
|
|
164
|
+
condition.map{|operator, value| [ column.to_s, operator.to_sym, value ] }
|
|
165
|
+
else
|
|
166
|
+
[[ column.to_s, :eq, condition ]]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_operator!(column, operator)
|
|
172
|
+
return if FILTER_OPERATORS.fetch(type_group(column)).include?(operator)
|
|
173
|
+
|
|
174
|
+
raise InvalidQueryError, "Unsupported filter operator #{operator} for #{column.name}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def filter_expression(column, operator, value)
|
|
178
|
+
identifier = connection.quote_identifier(column.name)
|
|
179
|
+
return null_expression(identifier, value) if operator == :is_null
|
|
180
|
+
return "#{identifier} >= #{connection.quote(value)}" if operator == :from
|
|
181
|
+
return "#{identifier} < #{connection.quote(day_after(value))}" if operator == :to
|
|
182
|
+
|
|
183
|
+
quoted = connection.quote(filter_value(operator, value))
|
|
184
|
+
return pattern_expression(identifier, quoted) if [ :contains, :starts_with ].include?(operator)
|
|
185
|
+
|
|
186
|
+
sql_operator = { eq: '=', gt: '>', gte: '>=', lt: '<', lte: '<=' }
|
|
187
|
+
"#{identifier} #{sql_operator.fetch(operator)} #{quoted}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def null_expression(identifier, value)
|
|
191
|
+
null = [ true, 'true', '1', 1 ].include?(value)
|
|
192
|
+
"#{identifier} IS #{null ? '' : 'NOT '}NULL"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def order_clause(relation, sort)
|
|
196
|
+
requested = parse_sort(relation, sort)
|
|
197
|
+
tie_breakers = deterministic_columns(relation).reject do |column|
|
|
198
|
+
requested.any?{|item| item.first == column.name }
|
|
199
|
+
end
|
|
200
|
+
parts = requested.map{|column, direction| "#{connection.quote_identifier(column.name)} #{direction}" }
|
|
201
|
+
parts.concat(tie_breakers.map{|column| deterministic_order(column) })
|
|
202
|
+
parts.empty? ? '(SELECT 0)' : parts.join(', ')
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def deterministic_order(column)
|
|
206
|
+
identifier = connection.quote_identifier(column.name)
|
|
207
|
+
integer_column?(column) ? "#{identifier} ASC" : "#{text_expression(identifier)} ASC"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def parse_sort(relation, sort)
|
|
211
|
+
return [] if sort.nil? || sort.to_s.empty?
|
|
212
|
+
|
|
213
|
+
sort.to_s.split(',').map do |item|
|
|
214
|
+
descending = item.start_with?('-')
|
|
215
|
+
name = item.delete_prefix('-')
|
|
216
|
+
column = relation.column(name)
|
|
217
|
+
raise InvalidQueryError, "Unknown sort column: #{name}" unless column
|
|
218
|
+
raise InvalidQueryError, "Unsupported sort column: #{name}" unless sortable_column?(column)
|
|
219
|
+
|
|
220
|
+
[ column, descending ? 'DESC' : 'ASC' ]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def deterministic_columns(relation)
|
|
225
|
+
names = relation.primary_key&.columns || relation.columns.map(&:name)
|
|
226
|
+
names.filter_map{|name| relation.column(name) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def positive_integer(value, name)
|
|
230
|
+
integer = Integer(value)
|
|
231
|
+
raise InvalidQueryError, "#{name} must be positive" unless integer.positive?
|
|
232
|
+
|
|
233
|
+
integer
|
|
234
|
+
rescue ArgumentError, TypeError
|
|
235
|
+
raise InvalidQueryError, "#{name} must be a positive integer"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Query
|
|
3
|
+
class Sqlite < Relational
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def pattern_expression(identifier, value)
|
|
7
|
+
"#{identifier} LIKE #{value} ESCAPE '\\'"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def text_expression(identifier)
|
|
11
|
+
"CAST(#{identifier} AS TEXT)"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Query
|
|
3
|
+
module TypeSupport
|
|
4
|
+
FILTER_OPERATORS = {
|
|
5
|
+
text: [ :eq, :contains, :starts_with, :is_null ],
|
|
6
|
+
numeric: [ :eq, :gt, :gte, :lt, :lte, :is_null ],
|
|
7
|
+
temporal: [ :eq, :gt, :gte, :lt, :lte, :from, :to, :is_null ],
|
|
8
|
+
identifier: [ :eq, :is_null ],
|
|
9
|
+
boolean: [ :eq, :is_null ],
|
|
10
|
+
other: [ :eq, :is_null ]
|
|
11
|
+
}.freeze
|
|
12
|
+
TYPE_GROUPS = {
|
|
13
|
+
text: %i[bpchar char citext name text varchar],
|
|
14
|
+
numeric: %i[decimal float4 float8 int2 int4 int8 money numeric oid],
|
|
15
|
+
temporal: %i[date interval time timestamp timestamptz timetz],
|
|
16
|
+
identifier: %i[uuid],
|
|
17
|
+
boolean: %i[bool boolean]
|
|
18
|
+
}.freeze
|
|
19
|
+
INTEGER_TYPES = %i[int2 int4 int8 oid].freeze
|
|
20
|
+
|
|
21
|
+
def filter_operators(column)
|
|
22
|
+
FILTER_OPERATORS.fetch(type_group(column))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sortable_column?(column)
|
|
26
|
+
type_group(column) != :other
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def groupable_column?(column)
|
|
30
|
+
!%i[json xml].include?(column.logical_type.to_s.to_sym)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def type_group(column)
|
|
36
|
+
logical_type = column.logical_type.to_s.to_sym
|
|
37
|
+
TYPE_GROUPS.find{|_group, types| types.include?(logical_type) }&.first || :other
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def integer_column?(column)
|
|
41
|
+
INTEGER_TYPES.include?(column.logical_type.to_s.to_sym)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def day_after(value)
|
|
45
|
+
text = value.to_s
|
|
46
|
+
unless /\A\d{4}-\d{2}-\d{2}\z/.match?(text)
|
|
47
|
+
raise InvalidQueryError, 'The to filter requires a date in YYYY-MM-DD format'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
(Date.iso8601(text) + 1).iso8601
|
|
51
|
+
rescue Date::Error
|
|
52
|
+
raise InvalidQueryError, 'The to filter requires a valid date in YYYY-MM-DD format'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def filter_value(operator, value)
|
|
56
|
+
escaped = escape_pattern(value)
|
|
57
|
+
return "%#{escaped}%" if operator == :contains
|
|
58
|
+
return "#{escaped}%" if operator == :starts_with
|
|
59
|
+
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def escape_pattern(value)
|
|
64
|
+
value.to_s.gsub(/[\\%_]/){|character| "\\#{character}" }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Query
|
|
3
|
+
class ValueLimiter
|
|
4
|
+
SUFFIX = '... [value truncated]'.freeze
|
|
5
|
+
|
|
6
|
+
def initialize(max_bytes:)
|
|
7
|
+
@max_bytes = positive_integer(max_bytes)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(value)
|
|
11
|
+
case value
|
|
12
|
+
when String then truncate(value)
|
|
13
|
+
when Array then value.map{|item| call(item) }
|
|
14
|
+
when Hash then value.to_h{|key, item| [ key, call(item) ] }
|
|
15
|
+
else value
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def truncate(value)
|
|
22
|
+
return value if value.bytesize <= @max_bytes
|
|
23
|
+
|
|
24
|
+
return SUFFIX.byteslice(0, @max_bytes) if @max_bytes <= SUFFIX.bytesize
|
|
25
|
+
|
|
26
|
+
available = @max_bytes - SUFFIX.bytesize
|
|
27
|
+
"#{value.byteslice(0, available).to_s.scrub}#{SUFFIX}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def positive_integer(value)
|
|
31
|
+
number = Integer(value)
|
|
32
|
+
raise ArgumentError, 'maximum value size must be positive' unless number.positive?
|
|
33
|
+
|
|
34
|
+
number
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/rdbr/query.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require_relative 'query/errors'
|
|
2
|
+
require_relative 'query/page'
|
|
3
|
+
require_relative 'query/value_limiter'
|
|
4
|
+
require_relative 'query/connection'
|
|
5
|
+
require_relative 'query/type_support'
|
|
6
|
+
require_relative 'query/lookup'
|
|
7
|
+
require_relative 'query/postgresql_dialect'
|
|
8
|
+
require_relative 'query/relational'
|
|
9
|
+
require_relative 'query/postgresql'
|
|
10
|
+
require_relative 'query/sqlite'
|
|
11
|
+
require_relative 'query/mysql'
|
|
12
|
+
|
|
13
|
+
module RDBr
|
|
14
|
+
module Query
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def build(database_url:, catalog:, max_results: 100, connection_options: {}, access_policy: nil)
|
|
18
|
+
adapter = catalog.adapter&.to_sym
|
|
19
|
+
query_class = { postgresql: Postgresql, sqlite: Sqlite, mysql: Mysql, mariadb: Mysql }[adapter]
|
|
20
|
+
raise UnsupportedAdapterError, "Data queries are not supported for #{catalog.adapter}" unless query_class
|
|
21
|
+
|
|
22
|
+
connection = Connection.new(
|
|
23
|
+
database_url,
|
|
24
|
+
options: connection_options,
|
|
25
|
+
access_policy: access_policy
|
|
26
|
+
)
|
|
27
|
+
query_class.new(connection: connection, catalog: catalog, max_results: max_results)
|
|
28
|
+
rescue ArgumentError, TypeError => e
|
|
29
|
+
raise ConfigurationError, e.message
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/rdbr/version.rb
ADDED