eno 0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +164 -0
  4. data/lib/eno.rb +486 -0
  5. data/lib/eno/version.rb +5 -0
  6. metadata +66 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eb697b829ee2858e69995a4485607463b4b2c0740cb78f2c984bd25ffa188108
4
+ data.tar.gz: 40850710021d688e6a916e0f8c21ee5511990012f015c8c9541e9e700f815b59
5
+ SHA512:
6
+ metadata.gz: cf6d28da3a66cc2db0121914f2ae29c578ec67f9d5ea72bc9acb9fc0d0c85215145f424092095b73abc6d68b094661ef5a73a318f84d3a70d2dba91c1f75a4b7
7
+ data.tar.gz: dec76ece5872f0f4688d1654ff2894809680e870f728165269516167101828be9e8379f77ef643f137604f4cf082a73d03c88ce0a05be34248350f32b40081b7
@@ -0,0 +1,13 @@
1
+ 0.4 2019-01-21
2
+ --------------
3
+
4
+ * Implement query mutation
5
+ * Can now use `!` as `not` operator
6
+ * Clauses as real expressions
7
+ * Implement context injection
8
+ * Refactor and simplify code
9
+
10
+ 0.1 2019-01-16
11
+ --------------
12
+
13
+ * First working version
@@ -0,0 +1,164 @@
1
+ # Eno is Not an ORM
2
+
3
+ [INSTALL](#installing-eno) |
4
+ [TUTORIAL](#getting-started) |
5
+ [EXAMPLES](examples)
6
+
7
+ ## What is Eno?
8
+
9
+ Eno is an experimental Ruby gem for working with SQL databases. Eno provides
10
+ tools for writing SQL queries using plain Ruby and specifically for querying
11
+ PostgreSQL and SQLite databases.
12
+
13
+ Eno provides the following features:
14
+
15
+ - Compose `SELECT` statements using plain Ruby syntax
16
+ - Create arbitrarily complex `WHERE` clauses
17
+ - Support for common table expressions (CTE) and joins
18
+ - Compose queries and sub-queries
19
+ - Create parametric queries using context variables
20
+ - Reusable queries can be further refined and mutated
21
+
22
+ ## What is it good for?
23
+
24
+ So why would anyone want to compose queries in Ruby instead of in plain SQL?
25
+ That's actually a very good question. Libraries like ActiveRecord and Sequel
26
+ already provide tools for querying relational databases. There's usage patterns
27
+ like ActiveRecord's `where`:
28
+
29
+ ```ruby
30
+ Client.where(order_count: [1, 3, 5])
31
+ ```
32
+
33
+ And Sequel is a bit more flexible:
34
+
35
+ ```ruby
36
+ Client.where { order_count > 10 }
37
+ ```
38
+
39
+ But both stumble when it comes to putting together more complex queries.
40
+ ActiveRecord queries in particular aren't really composable, making it actually
41
+ easier to filter and manipulate records inside your app code than in your
42
+ database.
43
+
44
+ With both ActiveRecord and Sequel you'll need to eventually provide snippets of
45
+ literal SQL. This is time-consuming, prevents your queries from being composable
46
+ and makes it easy to expose your app to SQL injection.
47
+
48
+ ## Installing eno
49
+
50
+ Using bundler:
51
+
52
+ ```ruby
53
+ gem 'eno'
54
+ ```
55
+
56
+ Or manually:
57
+
58
+ ```bash
59
+ $ gem install eno
60
+ ```
61
+
62
+ ## Getting started
63
+
64
+ To use eno in your code just require it:
65
+
66
+ ```ruby
67
+ require 'eno'
68
+ ```
69
+
70
+ Alternatively, you can import it using [Modulation](https://github.com/digital-fabric/modulation):
71
+
72
+ ```ruby
73
+ Eno = import('eno')
74
+ ```
75
+
76
+ ## Putting together queries
77
+
78
+ Eno makes it easy to compose SQL queries using plain Ruby syntax. It takes care
79
+ of formatting table and column identifiers and literals, and allows you to
80
+ compose multiple queries into a single `SELECT` statement.
81
+
82
+ To compose a query use the `Kernel#Q` method, providing a block in which the
83
+ query is built:
84
+
85
+ ```ruby
86
+ Q {
87
+ select a, b
88
+ from c
89
+ }
90
+ ```
91
+
92
+ To turn the query into SQL, use the `#to_sql` method:
93
+
94
+ ```ruby
95
+ Q {
96
+ select a, b
97
+ from c
98
+ }.to_sql #=> "select a, b from c"
99
+ ```
100
+
101
+ ## Using expressions
102
+
103
+ Once inside the query block, you can build arbitrarily complex expressions. You
104
+ can mix logical and arithmetic operators:
105
+
106
+ ```ruby
107
+ Q { select (a + b) & (c * d) }.to_sql #=> select (a + b) and (c * d)
108
+ ```
109
+
110
+ You can also use SQL functions:
111
+
112
+ ```ruby
113
+ Q {
114
+ select user_id, max(score)
115
+ from exams
116
+ group_by user_id
117
+ }
118
+ ```
119
+
120
+ ## Hooking up Eno to your database
121
+
122
+ In and of itself, Eno is just an engine for building SQL queries. To actually
123
+ run your queries, you'll need to hook Eno to your database. Here's an example
124
+ of how to open a connection to a PostgreSQL database and then easily issue
125
+ queries to it:
126
+
127
+ ```ruby
128
+ require 'pg'
129
+
130
+ DB = PG.connect(host: '/tmp', dbname: 'myapp', user: 'myuser')
131
+ def DB.q(**ctx, &block)
132
+ query(**ctx, &block).to_a
133
+ end
134
+
135
+ # issue a query
136
+ DB.q {
137
+ from users
138
+ select
139
+ }
140
+ ```
141
+
142
+ Another way to issue queries is by defining methods on Eno::Query:
143
+
144
+ ```ruby
145
+ def Eno::Query.each(**ctx, &block)
146
+ DB.query(to_sql(**ctx)).each(&block)
147
+ end
148
+ ```
149
+
150
+ ## Roadmap
151
+
152
+ Eno is intended as a complete solution for eventually expressing *any* SQL query
153
+ in Ruby (including `INSERT`, `UPDATE` and `DELETE` and `ALTER TABLE`
154
+ statements).
155
+
156
+ In the future, Eno could be used to manipulate queries in other ways:
157
+
158
+ - `EXPLAIN` your queries.
159
+ - Introspect different parts of a query (for example look at results of
160
+ subqueries or CTE's).
161
+ - Transform CTE's into subqueries (for example to overcome optimization
162
+ boundaries).
163
+ - Create views from queries.
164
+ - Compose data manipulation statements using `SELECT` subqueries.
@@ -0,0 +1,486 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'modulation/gem'
4
+
5
+ export :Query, :SQL, :Expression, :Identifier, :Alias
6
+
7
+ module ::Kernel
8
+ def Q(**ctx, &block)
9
+ Query.new(**ctx, &block)
10
+ end
11
+ end
12
+
13
+ class Expression
14
+ def self.quote(expr)
15
+ case expr
16
+ when Query
17
+ "(#{expr.to_sql.strip})"
18
+ when Expression
19
+ expr.to_sql
20
+ when Symbol
21
+ expr.to_s
22
+ when String
23
+ "'#{expr}'"
24
+ else
25
+ expr.inspect
26
+ end
27
+ end
28
+
29
+ attr_reader :members, :props
30
+
31
+ def initialize(*members, **props)
32
+ @members = members
33
+ @props = props
34
+ end
35
+
36
+ def as(sym = nil, &block)
37
+ if sym
38
+ Alias.new(self, sym)
39
+ else
40
+ Alias.new(self, Query.new(&block))
41
+ end
42
+ end
43
+
44
+ def desc
45
+ Desc.new(self)
46
+ end
47
+
48
+ def over(sym = nil, &block)
49
+ Over.new(self, sym || WindowExpression.new(&block))
50
+ end
51
+
52
+ def ==(expr2)
53
+ Operator.new('=', self, expr2)
54
+ end
55
+
56
+ def !=(expr2)
57
+ Operator.new('<>', self, expr2)
58
+ end
59
+
60
+ def <(expr2)
61
+ Operator.new('<', self, expr2)
62
+ end
63
+
64
+ def >(expr2)
65
+ Operator.new('>', self, expr2)
66
+ end
67
+
68
+ def <=(expr2)
69
+ Operator.new('<=', self, expr2)
70
+ end
71
+
72
+ def >=(expr2)
73
+ Operator.new('>=', self, expr2)
74
+ end
75
+
76
+ def &(expr2)
77
+ Operator.new('and', self, expr2)
78
+ end
79
+
80
+ def |(expr2)
81
+ Operator.new('or', self, expr2)
82
+ end
83
+
84
+ def +(expr2)
85
+ Operator.new('+', self, expr2)
86
+ end
87
+
88
+ def -(expr2)
89
+ Operator.new('-', self, expr2)
90
+ end
91
+
92
+ def *(expr2)
93
+ Operator.new('*', self, expr2)
94
+ end
95
+
96
+ def /(expr2)
97
+ Operator.new('/', self, expr2)
98
+ end
99
+
100
+ def %(expr2)
101
+ Operator.new('%', self, expr2)
102
+ end
103
+
104
+ # not
105
+ def !@
106
+ Not.new(self)
107
+ end
108
+
109
+ def null?
110
+ IsNull.new(self)
111
+ end
112
+
113
+ def not_null?
114
+ IsNotNull.new(self)
115
+ end
116
+
117
+ def join(sym, **props)
118
+ Join.new(self, sym, **props)
119
+ end
120
+
121
+ def inner_join(sym, **props)
122
+ join(sym, props.merge(type: :inner))
123
+ end
124
+ end
125
+
126
+ class Operator < Expression
127
+ def initialize(*members, **props)
128
+ op = members[0]
129
+ if Operator === members[1] && op == members[1].op
130
+ members = [op] + members[1].members[1..-1] + members[2..-1]
131
+ end
132
+ if Operator === members[2] && op == members[2].op
133
+ members = members[0..1] + members[2].members[1..-1]
134
+ end
135
+
136
+ super(*members, **props)
137
+ end
138
+
139
+ def op
140
+ @members[0]
141
+ end
142
+
143
+ def to_sql
144
+ op = " #{@members[0]} "
145
+ "(%s)" % @members[1..-1].map { |m| Expression.quote(m) }.join(op)
146
+ end
147
+ end
148
+
149
+ class Desc < Expression
150
+ def to_sql
151
+ "#{Expression.quote(@members[0])} desc"
152
+ end
153
+ end
154
+
155
+ class Over < Expression
156
+ def to_sql
157
+ "#{Expression.quote(@members[0])} over #{Expression.quote(@members[1])}"
158
+ end
159
+ end
160
+
161
+ class Not < Expression
162
+ def to_sql
163
+ "(not #{Expression.quote(@members[0])})"
164
+ end
165
+ end
166
+
167
+ class IsNull < Expression
168
+ def to_sql
169
+ "(#{Expression.quote(@members[0])} is null)"
170
+ end
171
+
172
+ def !@
173
+ IsNotNull.new(members[0])
174
+ end
175
+ end
176
+
177
+ class IsNotNull < Expression
178
+ def to_sql
179
+ "(#{Expression.quote(@members[0])} is not null)"
180
+ end
181
+ end
182
+
183
+ class WindowExpression < Expression
184
+ def initialize(&block)
185
+ instance_eval(&block)
186
+ end
187
+
188
+ def partition_by(*args)
189
+ @partition_by = args
190
+ end
191
+
192
+ def order_by(*args)
193
+ @order_by = args
194
+ end
195
+
196
+ def range_unbounded
197
+ @range = 'between unbounded preceding and unbounded following'
198
+ end
199
+
200
+ def to_sql
201
+ "(%s)" % [
202
+ _partition_by_clause,
203
+ _order_by_clause,
204
+ _range_clause
205
+ ].join.strip
206
+ end
207
+
208
+ def _partition_by_clause
209
+ return nil unless @partition_by
210
+ "partition by %s " % @partition_by.map { |e| Expression.quote(e) }.join(', ')
211
+ end
212
+
213
+ def _order_by_clause
214
+ return nil unless @order_by
215
+ "order by %s " % @order_by.map { |e| Expression.quote(e) }.join(', ')
216
+ end
217
+
218
+ def _range_clause
219
+ return nil unless @range
220
+ "range #{@range} "
221
+ end
222
+
223
+ def method_missing(sym)
224
+ super if sym == :to_hash
225
+ Identifier.new(sym)
226
+ end
227
+ end
228
+
229
+ class QuotedExpression < Expression
230
+ def to_sql
231
+ Expression.quote(@members[0])
232
+ end
233
+ end
234
+
235
+ class Identifier < Expression
236
+ def to_sql
237
+ @members[0].to_s
238
+ end
239
+
240
+ def method_missing(sym)
241
+ super if sym == :to_hash
242
+ Identifier.new("#{@members[0]}.#{sym}")
243
+ end
244
+
245
+ def _empty_placeholder?
246
+ m = @members[0]
247
+ Symbol === m && m == :_
248
+ end
249
+ end
250
+
251
+ class Alias < Expression
252
+ def to_sql
253
+ "#{Expression.quote(@members[0])} as #{Expression.quote(@members[1])}"
254
+ end
255
+ end
256
+
257
+ class FunctionCall < Expression
258
+ def to_sql
259
+ fun = @members[0]
260
+ if @members.size == 2 && Identifier === @members.last && @members.last._empty_placeholder?
261
+ "#{fun}()"
262
+ else
263
+ "#{fun}(#{@members[1..-1].map { |a| Expression.quote(a) }.join(', ')})"
264
+ end
265
+ end
266
+ end
267
+
268
+ class Join < Expression
269
+ H_JOIN_TYPES = {
270
+ nil: 'join',
271
+ inner: 'inner join',
272
+ outer: 'outer join'
273
+ }
274
+
275
+ def to_sql
276
+ ("%s %s %s %s" % [
277
+ Expression.quote(@members[0]),
278
+ H_JOIN_TYPES[@props[:type]],
279
+ Expression.quote(@members[1]),
280
+ condition_sql
281
+ ]).strip
282
+ end
283
+
284
+ def condition_sql
285
+ if @props[:on]
286
+ 'on %s' % Expression.quote(@props[:on])
287
+ elsif using_fields = @props[:using]
288
+ fields = using_fields.is_a?(Array) ? using_fields : [using_fields]
289
+ 'using (%s)' % fields.map { |f| Expression.quote(f) }.join(', ')
290
+ else
291
+ nil
292
+ end
293
+ end
294
+ end
295
+
296
+ class From < Expression
297
+ def to_sql
298
+ "from %s" % @members.map { |m| member_sql(m) }.join(', ')
299
+ end
300
+
301
+ def member_sql(member)
302
+ if Query === member
303
+ "%s t1" % Expression.quote(member)
304
+ elsif Alias === member && Query === member.members[0]
305
+ "%s %s" % [Expression.quote(member.members[0]), Expression.quote(member.members[1])]
306
+ else
307
+ Expression.quote(member)
308
+ end
309
+ end
310
+ end
311
+
312
+ class With < Expression
313
+ def to_sql
314
+ "with %s" % @members.map { |e| Expression.quote(e) }.join(', ')
315
+ end
316
+ end
317
+
318
+ class Select < Expression
319
+ def to_sql
320
+ "select %s%s" % [distinct_clause, @members.map { |e| Expression.quote(e) }.join(', ')]
321
+ end
322
+
323
+ def distinct_clause
324
+ case (on = @props[:distinct])
325
+ when nil
326
+ nil
327
+ when true
328
+ "distinct "
329
+ when Array
330
+ "distinct on (%s) " % on.map { |e| Expression.quote(e) }.join(', ')
331
+ else
332
+ "distinct on %s " % Expression.quote(on)
333
+ end
334
+ end
335
+ end
336
+
337
+ class Where < Expression
338
+ def to_sql
339
+ "where %s" % @members.map { |e| Expression.quote(e) }.join(' and ')
340
+ end
341
+ end
342
+
343
+ class Window < Expression
344
+ def initialize(sym, &block)
345
+ super(sym)
346
+ @block = block
347
+ end
348
+
349
+ def to_sql
350
+ "window %s as %s" % [
351
+ Expression.quote(@members.first),
352
+ WindowExpression.new(&@block).to_sql
353
+ ]
354
+ end
355
+ end
356
+
357
+ class OrderBy < Expression
358
+ def to_sql
359
+ "order by %s" % @members.map { |e| Expression.quote(e) }.join(', ')
360
+ end
361
+ end
362
+
363
+ class Limit < Expression
364
+ def to_sql
365
+ "limit %d" % @members[0]
366
+ end
367
+ end
368
+
369
+ class Query
370
+ def initialize(**ctx, &block)
371
+ @ctx = ctx
372
+ @block = block
373
+ end
374
+
375
+ def to_sql(**ctx)
376
+ r = SQL.new(@ctx.merge(ctx))
377
+ r.to_sql(&@block)
378
+ end
379
+
380
+ def as(sym)
381
+ Alias.new(self, sym)
382
+ end
383
+
384
+ def where(&block)
385
+ old_block = @block
386
+ Query.new(@ctx) {
387
+ instance_eval(&old_block)
388
+ where instance_eval(&block)
389
+ }
390
+ end
391
+
392
+ def mutate(&block)
393
+ old_block = @block
394
+ Query.new(@ctx) {
395
+ instance_eval(&old_block)
396
+ instance_eval(&block)
397
+ }
398
+ end
399
+ end
400
+
401
+ class SQL
402
+ def initialize(ctx)
403
+ @ctx = ctx
404
+ end
405
+
406
+ def to_sql(&block)
407
+ instance_eval(&block)
408
+ [
409
+ @with,
410
+ @select || default_select,
411
+ @from,
412
+ @where,
413
+ @window,
414
+ @order_by,
415
+ @limit
416
+ ].compact.map { |c| c.to_sql }.join(' ')
417
+ end
418
+
419
+ def _q(expr)
420
+ QuotedExpression.new(expr)
421
+ end
422
+
423
+ def default_select
424
+ Select.new(:*)
425
+ end
426
+
427
+ def method_missing(sym, *args)
428
+ if @ctx.has_key?(sym)
429
+ value = @ctx[sym]
430
+ return Symbol === value ? Identifier.new(value) : value
431
+ end
432
+
433
+ super if sym == :to_hash
434
+ if args.empty?
435
+ Identifier.new(sym)
436
+ else
437
+ FunctionCall.new(sym, *args)
438
+ end
439
+ end
440
+
441
+ def with(*members, **props)
442
+ @with = With.new(*members, **props)
443
+ end
444
+
445
+ H_EMPTY = {}.freeze
446
+
447
+ def select(*members, **props)
448
+ if members.empty? && !props.empty?
449
+ members = props.map { |k, v| Alias.new(v, k) }
450
+ props = {}
451
+ end
452
+ @select = Select.new(*members, **props)
453
+ end
454
+
455
+ def from(*members, **props)
456
+ @from = From.new(*members, **props)
457
+ end
458
+
459
+ def where(expr)
460
+ if @where
461
+ @where.members << expr
462
+ else
463
+ @where = Where.new(expr)
464
+ end
465
+ end
466
+
467
+ def window(sym, &block)
468
+ @window = Window.new(sym, &block)
469
+ end
470
+
471
+ def order_by(*members, **props)
472
+ @order_by = OrderBy.new(*members, **props)
473
+ end
474
+
475
+ def limit(*members)
476
+ @limit = Limit.new(*members)
477
+ end
478
+
479
+ def all(sym = nil)
480
+ if sym
481
+ Identifier.new("#{sym}.*")
482
+ else
483
+ Identifier.new('*')
484
+ end
485
+ end
486
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Eno
4
+ VERSION = '0.4'
5
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: eno
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.4'
5
+ platform: ruby
6
+ authors:
7
+ - Sharon Rosner
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-01-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: modulation
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: '0.18'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: '0.18'
27
+ description:
28
+ email: ciconia@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files:
32
+ - README.md
33
+ files:
34
+ - CHANGELOG.md
35
+ - README.md
36
+ - lib/eno.rb
37
+ - lib/eno/version.rb
38
+ homepage: http://github.com/digital-fabric/eno
39
+ licenses:
40
+ - MIT
41
+ metadata:
42
+ source_code_uri: https://github.com/digital-fabric/eno
43
+ post_install_message:
44
+ rdoc_options:
45
+ - "--title"
46
+ - eno
47
+ - "--main"
48
+ - README.md
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.0.1
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: 'Eno: Eno is Not an ORM'
66
+ test_files: []