eno 0.4 → 0.5

Sign up to get free protection for your applications and to get access to all the features.
data/lib/eno/pg.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pg'
4
+
5
+ class PG::Connection
6
+ ESCAPER = ->(expr) {
7
+ case expr
8
+ when Symbol
9
+ quote_ident(expr.to_s)
10
+ when String
11
+ "'#{escape(expr)}'"
12
+ else
13
+ nil # use default quoting
14
+ end
15
+ }
16
+
17
+ def q(query = nil, **ctx, &block)
18
+ query ||= Eno::Query.new(&block)
19
+ exec(query_to_sql(query, **ctx))
20
+ end
21
+
22
+ def query_to_sql(query, **ctx)
23
+ query.to_sql(escape_proc: ESCAPER, **ctx)
24
+ end
25
+ end
data/lib/eno/query.rb ADDED
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ export :Query
4
+
5
+ Expressions = import('./expressions')
6
+ SQL = import('./sql')
7
+
8
+ class Query
9
+ def initialize(**ctx, &block)
10
+ @ctx = ctx
11
+ @block = block
12
+ end
13
+
14
+ def to_sql(escape_proc: nil, **ctx)
15
+ r = SQL::SQL.new(escape_proc: escape_proc, **@ctx.merge(ctx))
16
+ r.to_sql(&@block)
17
+ end
18
+
19
+ def as(sym)
20
+ Expressions::Alias.new(self, sym)
21
+ end
22
+
23
+ def where(&block)
24
+ old_block = @block
25
+ Query.new(@ctx) {
26
+ instance_eval(&old_block)
27
+ where instance_eval(&block)
28
+ }
29
+ end
30
+
31
+ def mutate(&block)
32
+ old_block = @block
33
+ Query.new(@ctx) {
34
+ instance_eval(&old_block)
35
+ instance_eval(&block)
36
+ }
37
+ end
38
+
39
+ def union(*queries, **props, &block)
40
+ q1 = self
41
+ queries << Query.new(&block) if queries.empty?
42
+ Query.new(@ctx) { union q1, *queries, **props }
43
+ end
44
+ alias_method :|, :union
45
+
46
+ def union_all(*queries, &block)
47
+ union(*queries, all: true, &block)
48
+ end
49
+
50
+ def intersect(*queries, **props, &block)
51
+ q1 = self
52
+ queries << Query.new(&block) if queries.empty?
53
+ Query.new(@ctx) { intersect q1, *queries, **props }
54
+ end
55
+ alias_method :&, :intersect
56
+
57
+ def intersect_all(*queries, &block)
58
+ intersect(*queries, all: true, &block)
59
+ end
60
+
61
+ def except(*queries, **props, &block)
62
+ q1 = self
63
+ queries << Query.new(&block) if queries.empty?
64
+ Query.new(@ctx) { except q1, *queries, **props }
65
+ end
66
+ alias_method :"^", :except
67
+
68
+ def except_all(*queries, &block)
69
+ except(*queries, all: true, &block)
70
+ end
71
+ end
72
+
data/lib/eno/sql.rb ADDED
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ Expressions = import('./expressions')
4
+ Query = import('./query')
5
+
6
+ export :SQL
7
+
8
+ S_SPACE = ' '
9
+ S_PARENS = '(%s)'
10
+ S_QUOTES = "'%s'"
11
+ S_ALL = '*'
12
+ S_QUALIFIED_ALL = '%s.*'
13
+
14
+ class SQL
15
+ def initialize(escape_proc: nil, **ctx)
16
+ @escape_proc = escape_proc
17
+ @ctx = ctx
18
+ end
19
+
20
+ def to_sql(&block)
21
+ instance_eval(&block)
22
+
23
+ return @combination.to_sql(self) if @combination
24
+
25
+ [
26
+ @with,
27
+ @select || default_select,
28
+ @from,
29
+ @where,
30
+ @window,
31
+ @order_by,
32
+ @limit
33
+ ].compact.map { |c| c.to_sql(self) }.join(S_SPACE)
34
+ end
35
+
36
+ def quote(expr)
37
+ if @escape_proc
38
+ value = @escape_proc.(expr)
39
+ return value if value
40
+ end
41
+
42
+ case expr
43
+ when Query::Query
44
+ S_PARENS % expr.to_sql(@ctx).strip
45
+ when Expressions::Expression
46
+ expr.to_sql(self)
47
+ when Symbol
48
+ expr.to_s
49
+ when String
50
+ S_QUOTES % expr
51
+ else
52
+ expr.inspect
53
+ end
54
+ end
55
+
56
+ def context
57
+ @ctx
58
+ end
59
+
60
+ def _l(value)
61
+ Expressions::Literal.new(value)
62
+ end
63
+
64
+ def _i(value)
65
+ Expressions::Identifier.new(value)
66
+ end
67
+
68
+ def default_select
69
+ Expressions::Select.new(:*)
70
+ end
71
+
72
+ def method_missing(sym, *args)
73
+ if @ctx.has_key?(sym)
74
+ value = @ctx[sym]
75
+ return Symbol === value ? Expressions::Identifier.new(value) : value
76
+ end
77
+
78
+ super if sym == :to_hash
79
+ if args.empty?
80
+ Expressions::Identifier.new(sym)
81
+ else
82
+ Expressions::FunctionCall.new(sym, *args)
83
+ end
84
+ end
85
+
86
+ def with(*members, **props)
87
+ @with = Expressions::With.new(*members, **props)
88
+ end
89
+
90
+ H_EMPTY = {}.freeze
91
+
92
+ def select(*members, **props)
93
+ if members.empty? && !props.empty?
94
+ members = props.map { |k, v| Expressions::Alias.new(v, k) }
95
+ props = {}
96
+ end
97
+ @select = Expressions::Select.new(*members, **props)
98
+ end
99
+
100
+ def from(*members, **props)
101
+ @from = Expressions::From.new(*members, **props)
102
+ end
103
+
104
+ def where(expr)
105
+ if @where
106
+ @where.members << expr
107
+ else
108
+ @where = Expressions::Where.new(expr)
109
+ end
110
+ end
111
+
112
+ def window(sym, &block)
113
+ @window = Expressions::Window.new(sym, &block)
114
+ end
115
+
116
+ def order_by(*members, **props)
117
+ @order_by = Expressions::OrderBy.new(*members, **props)
118
+ end
119
+
120
+ def limit(*members)
121
+ @limit = Expressions::Limit.new(*members)
122
+ end
123
+
124
+ def all(sym = nil)
125
+ if sym
126
+ Expressions::Identifier.new(S_QUALIFIED_ALL % sym)
127
+ else
128
+ Expressions::Identifier.new(S_ALL)
129
+ end
130
+ end
131
+
132
+ def cond(props)
133
+ Expressions::Case.new(props)
134
+ end
135
+
136
+ def default
137
+ :default
138
+ end
139
+
140
+ def union(*queries, **props)
141
+ @combination = Expressions::Combination.new(*queries, kind: :union, **props)
142
+ end
143
+
144
+ def intersect(*queries, **props)
145
+ @combination = Expressions::Combination.new(*queries, kind: :intersect, **props)
146
+ end
147
+
148
+ def except(*queries, **props)
149
+ @combination = Expressions::Combination.new(*queries, kind: :except, **props)
150
+ end
151
+ end
data/lib/eno/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Eno
4
- VERSION = '0.4'
4
+ VERSION = '0.5'
5
5
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../ext'
4
+ import('../../lib/eno/pg')
5
+
6
+ require 'pp'
7
+
8
+ class PostgresTest < T
9
+ DB = PG.connect(
10
+ host: '/tmp',
11
+ user: 'reality',
12
+ password: nil,
13
+ dbname: 'reality',
14
+ sslmode: 'require'
15
+ )
16
+
17
+ def test_correct_escaping
18
+ results = DB.q {
19
+ select _l("abc def 'ghi'\n").as(:"jkl mno")
20
+ }.to_a
21
+ assert_equal(1, results.size)
22
+ assert_equal(['jkl mno'], results.first.keys)
23
+ assert_equal(["abc def 'ghi'\n"], results.first.values)
24
+ end
25
+
26
+ def test_identifier_escaping
27
+ query = Q {
28
+ select _i("abc'def\"ghi")
29
+ }
30
+ assert_equal("select \"abc'def\"\"ghi\"", DB.query_to_sql(query))
31
+ end
32
+ end
data/test/ext.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'modulation'
4
+ require 'minitest/autorun'
5
+
6
+ Eno = import '../lib/eno'
7
+
8
+ T = MiniTest::Test
9
+ class T
10
+ def assert_sql(sql, &block); assert_equal(sql, Q(&block).to_sql); end
11
+ end
data/test/test.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir["#{__dir__}/**/test_*.rb"].each { |fn| require fn }
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './ext'
4
+
5
+ class SelectTest < T
6
+ def test_that_no_from_select_is_supported
7
+ assert_sql('select 1') { select 1 }
8
+ assert_sql('select pg_sleep(1)') { select pg_sleep(1) }
9
+ end
10
+
11
+ def test_default_select
12
+ assert_sql('select *') { }
13
+ assert_sql('select * from t') { from t}
14
+ end
15
+
16
+ def test_that_select_with_hash_is_supported
17
+ assert_sql('select 1 as a, 2 as b') { select a: 1, b: 2 }
18
+ assert_sql('select pg_sleep(1) as s, pg_sleep(2) as s2') {
19
+ select s: pg_sleep(1), s2: pg_sleep(2)
20
+ }
21
+ end
22
+
23
+ def test_select_distinct
24
+ assert_sql('select distinct a, b') {
25
+ select a, b, distinct: true
26
+ }
27
+
28
+ assert_sql('select distinct on (a + b) a, b') {
29
+ select a, b, distinct: a + b
30
+ }
31
+ end
32
+ end
33
+
34
+ class FromTest < T
35
+ def test_that_from_accepts_table_name
36
+ assert_sql('select * from abc') {
37
+ from abc
38
+ }
39
+ end
40
+
41
+ def test_that_expression_can_be_aliased
42
+ assert_sql('select * from abc as a') {
43
+ from abc.as(a)
44
+ }
45
+ end
46
+
47
+ def test_that_from_accepts_sub_query
48
+ query = Q { select _l(1).as a }
49
+ assert_sql('select a from (select 1 as a) t1') {
50
+ select a
51
+ from query
52
+ }
53
+
54
+ assert_sql('select a from (select 1 as a) t3') {
55
+ select a
56
+ from query.as t3
57
+ }
58
+ end
59
+ end
60
+
61
+ class WithTest < T
62
+ def test_that_with_accepts_sub_queries
63
+ assert_sql('with t1 as (select 1 as a), t2 as (select 2 as b) select * from b') {
64
+ with t1.as { select _l(1).as a }, t2.as { select _l(2).as b }
65
+ select all
66
+ from b
67
+ }
68
+ end
69
+ end
70
+
71
+ class WhereTest < T
72
+ def test_that_where_accepts_boolean_const
73
+ assert_sql('select * from a where true') {
74
+ from a
75
+ where true
76
+ }
77
+ end
78
+
79
+ def test_that_where_accepts_comparison_expression
80
+ assert_sql('select * from a where (b = 1)') {
81
+ from a
82
+ where b == 1
83
+ }
84
+
85
+ assert_sql('select * from a where ((b = 1) and (c = 2))') {
86
+ from a
87
+ where (b == 1) & (c == 2)
88
+ }
89
+
90
+ assert_sql('select * from a where ((b > 1) and (c < 2))') {
91
+ from a
92
+ where (b > 1) & (c < 2)
93
+ }
94
+
95
+ assert_sql('select * from a where ((b <= 1) and (c <> 2))') {
96
+ from a
97
+ where (b <= 1) & (c != 2)
98
+ }
99
+ end
100
+
101
+ def test_that_where_accepts_logical_expression
102
+ assert_sql('select * from a where (b and c)') {
103
+ from a
104
+ where b & c
105
+ }
106
+
107
+ assert_sql('select * from a where (b or c)') {
108
+ from a
109
+ where b | c
110
+ }
111
+
112
+ assert_sql('select * from a where (b and (not c))') {
113
+ from a
114
+ where b & !c
115
+ }
116
+
117
+ assert_sql('select * from a where (not (b or c))') {
118
+ from a
119
+ where !(b | c)
120
+ }
121
+ end
122
+
123
+ def test_that_where_accepts_nil
124
+ assert_sql('select * from a where (b is null)') {
125
+ from a
126
+ where b.null?
127
+ }
128
+
129
+ assert_sql('select * from a where (b is not null)') {
130
+ from a
131
+ where !b.null?
132
+ }
133
+
134
+ assert_sql('select * from a where (b is not null)') {
135
+ from a
136
+ where !b.null?
137
+ }
138
+ end
139
+
140
+ def test_that_where_accepts_arithmetic_operators
141
+ assert_sql('select * from a where ((b + c) = 42)') {
142
+ from a
143
+ where b + c == 42
144
+ }
145
+ end
146
+ end
147
+
148
+ class DSLTest < T
149
+ class Eno::Identifier
150
+ def [](sym)
151
+ case sym
152
+ when Symbol
153
+ Eno::Alias.new(JSONBExpression.new(self, sym), sym)
154
+ else
155
+ JSONBExpression.new(self, sym)
156
+ end
157
+ end
158
+ end
159
+
160
+ class JSONBExpression < Eno::Expression
161
+ def to_sql(sql)
162
+ "#{sql.quote(@members[0])}->>'#{sql.quote(@members[1])}'"
163
+ end
164
+ end
165
+
166
+ def test_that_dsl_can_be_extended
167
+ assert_sql("select attributes->>'path'") {
168
+ select attributes[path]
169
+ }
170
+
171
+ assert_sql("select attributes->>'path' as path") {
172
+ select attributes[:path]
173
+ }
174
+ end
175
+ end
176
+
177
+ class WindowTest < T
178
+ def test_that_over_is_supported
179
+ assert_sql('select last_value(q) over w as q_last, last_value(v) over w as v_last') {
180
+ select last_value(q).over(w).as(q_last),
181
+ last_value(v).over(w).as(v_last)
182
+ }
183
+ end
184
+
185
+ def test_that_over_supports_inline_window
186
+ assert_sql('select group_name, avg(price) over (partition by group_name) from products') {
187
+ select group_name, (avg(price).over { partition_by group_name })
188
+ from products
189
+ }
190
+ end
191
+
192
+ def test_named_windows
193
+ assert_sql('select last_value(q) over w as q_last, last_value(v) over w as v_last from t1 window w as (partition by stamp_aligned order by stamp range between unbounded preceding and unbounded following)') {
194
+ select last_value(q).over(w).as(q_last),
195
+ last_value(v).over(w).as(v_last)
196
+ from t1
197
+ window(w) {
198
+ partition_by stamp_aligned
199
+ order_by stamp
200
+ range_unbounded
201
+ }
202
+ }
203
+ end
204
+ end
205
+
206
+ class ContextTest < T
207
+ def test_that_context_passed_can_be_used_in_query
208
+ query = Q(tbl: :nodes, field: :sample_rate, value: 42) {
209
+ select a, b
210
+ from tbl
211
+ where field < value
212
+ }
213
+ assert_equal(
214
+ 'select a, b from nodes where (sample_rate < 42)',
215
+ query.to_sql
216
+ )
217
+ end
218
+
219
+ def test_that_context_can_be_passed_in_to_sql_method
220
+ query = Q {
221
+ select a, b
222
+ from tbl
223
+ where field < value
224
+ }
225
+ assert_equal(
226
+ 'select a, b from nodes where (sample_rate < 43)',
227
+ query.to_sql(tbl: :nodes, field: :sample_rate, value: 43)
228
+ )
229
+ end
230
+
231
+ def test_that_to_sql_overrides_initial_context
232
+ query = Q(tbl: :nodes, field: :deadband) {
233
+ select a, b
234
+ from tbl
235
+ where field < value
236
+ }
237
+ assert_equal(
238
+ 'select a, b from nodes where (sample_rate < 42)',
239
+ query.to_sql(field: :sample_rate, value: 42)
240
+ )
241
+
242
+ assert_equal(
243
+ 'select a, b from nodes where (deadband < 42)',
244
+ query.to_sql(value: 42)
245
+ )
246
+ end
247
+ end
248
+
249
+ class MutationTest < T
250
+ def test_that_query_can_further_refined_with_where_clause
251
+ q = Q {
252
+ select a, b
253
+ }
254
+ assert_equal('select a, b', q.to_sql)
255
+
256
+ q2 = q.where { c < d}
257
+ assert(q != q2)
258
+ assert_equal('select a, b', q.to_sql)
259
+ assert_equal('select a, b where (c < d)', q2.to_sql)
260
+
261
+ q = Q {
262
+ where _l(2) + _l(2) == _l(5)
263
+ }
264
+ assert_equal('select * where ((2 + 2) = 5)', q.to_sql)
265
+
266
+ q2 = q.where { _l('up') == _l('down') }
267
+ assert_equal("select * where ((2 + 2) = 5) and ('up' = 'down')", q2.to_sql)
268
+ end
269
+
270
+ def test_that_mutated_query_can_change_arbitrary_clauses
271
+ q = Q { select a; from b }
272
+ assert_equal('select a from b', q.to_sql)
273
+
274
+ q2 = q.mutate { from c }
275
+ assert_equal('select a from b', q.to_sql)
276
+ assert_equal('select a from c', q2.to_sql)
277
+ end
278
+ end
279
+
280
+ class CastTest < T
281
+ def test_that_cast_is_correctly_formatted
282
+ assert_sql('select cast (a as b)') { select a.cast(b) }
283
+ assert_sql('select cast (123 as float)') { select _l(123).cast(float) }
284
+ assert_sql("select cast ('123' as integer)") { select _l('123').cast(integer) }
285
+ end
286
+
287
+ def test_that_cast_shorthand_is_correctly_formatted
288
+ assert_sql('select a::b') { select a^b }
289
+ assert_sql('select 123::float') { select _l(123)^float }
290
+ assert_sql("select '2019-01-01 00:00+00'::timestamptz") {
291
+ select _l('2019-01-01 00:00+00')^timestamptz
292
+ }
293
+ end
294
+
295
+ def test_that_cast_works_wih_symbols
296
+ assert_sql('select cast (a as b)') { select a.cast(:b) }
297
+ end
298
+ end
299
+
300
+ class InTest < T
301
+ def test_that_in_is_correctly_formatted
302
+ assert_sql('select * where a in (1, 2, 3)') { where a.in 1, 2, 3 }
303
+ assert_sql('select * where a not in (1, 2, 3)') { where !a.in(1, 2, 3) }
304
+ end
305
+
306
+ def test_that_not_in_is_correcly_formatted
307
+ assert_sql('select * where a not in (1, 2, 3)') { where a.not_in 1, 2, 3 }
308
+ end
309
+ end
310
+
311
+ class LiteralTest < T
312
+ def test_that_numbers_are_correctly_quoted
313
+ assert_sql('select 123') { select 123 }
314
+ assert_sql('select 123') { select _l(123) }
315
+ assert_sql('select (2 + 2)') { select _l(2) + _l(2) }
316
+ end
317
+
318
+ def test_that_strings_are_correctly_quoted
319
+ assert_sql("select 'abc'") { select 'abc' }
320
+ end
321
+
322
+ def test_that_null_literal_is_correctly_quoted
323
+ assert_sql('select null') { select null }
324
+ end
325
+ end
326
+
327
+ class ConvenienceVariablesTest < T
328
+ def test_that_convenience_variables_do_not_change_query
329
+ assert_sql('select unformatted_value::boolean, unformatted_value::float') {
330
+ uv = unformatted_value
331
+ select uv^boolean, uv^float
332
+ }
333
+ end
334
+ end
335
+
336
+ class ExtractEpoch < Eno::Expression
337
+ class Eno::SQL
338
+ def extract_epoch_from(sym)
339
+ ExtractEpoch.new(sym)
340
+ end
341
+ end
342
+
343
+ def to_sql(sql)
344
+ "extract (epoch from #{sql.quote(@members[0])})::integer"
345
+ end
346
+ end