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
@@ -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,7 @@
1
+ module RDBr
2
+ module Query
3
+ class Postgresql < Relational
4
+ include PostgresqlDialect
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ module RDBr
2
+ module Query
3
+ module PostgresqlDialect
4
+ private
5
+
6
+ def pattern_expression(identifier, value) = "#{identifier} ILIKE #{value}"
7
+
8
+ def text_expression(identifier) = "#{identifier}::text"
9
+ end
10
+ end
11
+ 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
@@ -0,0 +1,3 @@
1
+ module RDBr
2
+ VERSION = '0.1.0'.freeze
3
+ end