eno 0.4 → 0.5

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.
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