Compare commits

..

58 commits

Author SHA1 Message Date
0e82d96755 Release 0.7.2 2018-09-14 09:53:46 +02:00
95ecac4946 Update gem homepage 2018-09-14 09:52:55 +02:00
071932e4be Release 0.7.1 2017-11-23 16:33:26 +01:00
86ce2b65ff Properly handle backslash escaping 2017-11-23 16:33:01 +01:00
a0f1153407 Adjust quote style 2017-11-23 10:40:12 +01:00
3bb41b81f0 Generate less whitespace 2017-11-23 10:39:59 +01:00
31045461ab Release 0.7.0 2017-11-22 17:22:46 +01:00
6ffa4e88f0 Normalize best-effort vs strict operator usage 2017-11-22 17:22:27 +01:00
354f4a6860 Require "date" since we use Date 2017-11-22 17:20:57 +01:00
a1084539af Fix Arel name 2017-11-22 17:20:26 +01:00
93592329de Improve README with design, examples, FAQ 2017-11-22 16:03:30 +01:00
02043ec233 Replace '*' by :* 2017-11-22 15:13:20 +01:00
cfe0851f04 Adjust prose 2017-11-22 15:12:35 +01:00
f315679713 Fix introspection when extending with Rebel::SQLB 2017-11-21 17:24:20 +01:00
c746cf18aa Release 0.6.0 2017-11-21 16:35:35 +01:00
351e7e9645 Make Rebel::SQL properly configurable to database peculiarities 2017-11-21 16:35:15 +01:00
ddef06756f Release 0.5.0 2017-11-21 14:08:36 +01:00
97f5214da7 Fix circular argument reference 2017-11-21 14:08:13 +01:00
422defeecd Add IS NOT, NOT IN, NOT LIKE 2017-11-21 13:59:01 +01:00
000e7f2ae2 Map (binary) logic operators to AND and OR 2017-11-21 13:58:29 +01:00
9c4c031db9 Rewrite tests for readability 2017-11-21 13:56:37 +01:00
96d2f9991a Release 0.4.0 2017-11-21 11:22:38 +01:00
56ad166c1e Add support for GROUP, ORDER, BY and HAVING 2017-11-21 11:20:45 +01:00
6c4b435ae6 Add support for LIMIT and OFFSET 2017-11-21 11:19:48 +01:00
adfa38baea Add support for DISTINCT 2017-11-21 11:17:45 +01:00
a3712ceefd Add support for SELECT without FROM 2017-11-21 11:14:45 +01:00
818da9047a Improve feedback for unsupported value type 2017-11-21 11:12:54 +01:00
978dca51bf Release 0.3.3 2017-09-27 10:32:04 +02:00
56ad34b79e Pass argument down on? correctly
Fixes an extra `= NULL` appearing at times.
2017-09-27 10:31:11 +02:00
5811131d23 release 0.3.2 2017-07-17 16:45:15 +02:00
a010f31403 add a few tests 2017-07-17 16:44:53 +02:00
18569b2b2f queries are raw 2017-07-17 16:42:55 +02:00
e37d9034b4 don't track Gemfile.lock 2017-07-17 16:41:47 +02:00
179cba86b6 release 0.3.1 2017-03-20 15:57:10 +01:00
06ff4227af add LIKE 2017-03-20 15:56:36 +01:00
3c2d41788c release 0.3.0 2017-03-07 10:08:48 +01:00
9b6e871b1b date, time, datetime values support 2017-03-03 16:13:46 +01:00
075957be04 wrap AND first member w/ parens 2017-03-03 16:13:06 +01:00
ee34b9c600 remove useless absolute qualifiers 2017-03-03 15:06:16 +01:00
448a342d5f arbitrary functions 2017-03-03 14:53:28 +01:00
892b21eaf8 Raw#is 2017-03-03 14:52:54 +01:00
3d18261e05 boolean value type 2017-03-01 17:07:19 +01:00
d3ba98ab1b fix join without clause 2017-03-01 17:06:26 +01:00
2c727a38cb fix clause passing 2017-03-01 17:06:26 +01:00
421ae6d32e extract dummy escape 2017-03-01 17:06:26 +01:00
8a9b5804fc fix hash clause handling 2017-03-01 17:06:23 +01:00
d538a9b584 stupid settings 2017-03-01 16:09:26 +01:00
e9ee35f68e parens handling for where 2017-03-01 15:55:38 +01:00
814aa46592 operators 2017-03-01 15:18:24 +01:00
65a751e375 name is a Raw 2017-03-01 15:18:24 +01:00
9e6b503b52 Release 0.2.0 2017-03-01 10:03:58 +01:00
619837da62 Lint 2017-03-01 09:34:43 +01:00
2155aa8d43 Tests 2017-03-01 09:31:23 +01:00
3415cf8ad4 IN support 2017-02-28 17:33:58 +01:00
22fe1981db additional helpers 2017-02-28 17:33:44 +01:00
6adbef3671 {Fix,Big}Num => Integer 2017-02-28 17:33:30 +01:00
b698263f37 fix namespaced calling 2017-02-28 17:32:46 +01:00
17db89cbc4 Update desc 2017-02-27 22:20:37 +01:00
10 changed files with 943 additions and 123 deletions

32
.rubocop.yml Normal file
View file

@ -0,0 +1,32 @@
inherit_from: .rubocop_todo.yml
Metrics/LineLength:
Enabled: false
Metrics/ParameterLists:
Enabled: false
Style/Documentation:
Enabled: false
Style/ClassAndModuleChildren:
Enabled: false
Metrics/ModuleLength:
Enabled: false
Style/TrailingCommaInLiteral:
EnforcedStyleForMultiline: comma
Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: comma
Style/NestedParenthesizedCalls:
Enabled: false
Style/BracesAroundHashParameters:
Exclude:
- 'test/test_*.rb'
Style/IndentArray:
EnforcedStyle: consistent

15
.rubocop_todo.yml Normal file
View file

@ -0,0 +1,15 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2017-02-28 19:06:39 +0100 using RuboCop version 0.47.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: prefer_alias, prefer_alias_method
Style/Alias:
Exclude:
- 'test/helper.rb'

View file

@ -6,3 +6,4 @@ gem 'minitest'
gem 'pry'
gem 'rake'
gem 'rubocop'
gem 'sqlite3'

View file

@ -1,43 +0,0 @@
PATH
remote: .
specs:
rebel (0.1.0)
GEM
remote: https://rubygems.org/
specs:
ast (2.3.0)
coderay (1.1.1)
method_source (0.8.2)
minitest (5.10.1)
parser (2.4.0.0)
ast (~> 2.2)
powerpack (0.1.1)
pry (0.10.4)
coderay (~> 1.1.0)
method_source (~> 0.8.1)
slop (~> 3.4)
rainbow (2.2.1)
rake (12.0.0)
rubocop (0.47.1)
parser (>= 2.3.3.1, < 3.0)
powerpack (~> 0.1)
rainbow (>= 1.99.1, < 3.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-progressbar (1.8.1)
slop (3.6.0)
unicode-display_width (1.1.3)
PLATFORMS
ruby
DEPENDENCIES
minitest
pry
rake
rebel!
rubocop
BUNDLED WITH
1.14.5

314
README.md
View file

@ -7,7 +7,7 @@ You've been fighting yet another abstraction...
Aren't you fed up with object-relation magic?
But wait, here comes a humongous migration.
Is ActiveRecord making you sick?
To hell with that monstrous ARel expression!
To hell with that monstrous Arel expression!
Tell the truth, you were just wishing
That it was as simple as a here-string.
But could it keep some Ruby notation
@ -21,13 +21,11 @@ No bullshit
No layers
No wrappers
No smarty-pants
No weird stance
No sexy
No nonsense
No AST
No lazy loading
No crazy mapping
No pretense
No nonsense
What you write is what you get: readable and obvious
What you write is what you meant: tasty and delicious
@ -36,6 +34,314 @@ Wait, it doesn't execute!?
Just use your fave client gem, isn't that cute?
```
## Motivation
There are many a time where you end up knowing exactly what SQL query you want,
yet have to wrap your head around for the ORM to produce it, which is when the
point of such a layer is entirely defeated. Concatenating and interpolating
only goes so far.
As ActiveRecord grows, a significant decision has been taken in the Rails team
to turn Arel into a library purely internal to ActiveRecord: the whole of it is
basically considered internal and private, and only ActiveRecord's public
interface should be used. Unfortunately, some highly dynamic, complex queries
simply cannot be built using ActiveRecord, and concatenating strings to build
SQL fragments and clauses simply does not cut it.
## Philosophy
- It must be readable as being SQL
- Yet it must be as much Ruby syntax and types as possible
- It must be able to produce fragments for others to use
- And somehow be composable enough
- It must not rely on metaprogramming magic
- Nor need monkeypatching core types
## Design
There are two goals to this library:
- query building
- query execution
Query building is about assembling a string containing a partial or complete
query that you will later pass on to be executed by an executor.
Query execution is about writing a query that will be executed on the spot.
There are also non-goals to this library:
- be any sort of ORM
- or any sort of abstraction layer
- or any sort of query optimiser
## Usage
`Rebel::SQL` is a module that contains building and execution features, and
output ANSI-style SQL.
`Rebel::SQL()` is a function that produces a customised module enabling support
for alternative dialects, and when passed a block, allows you to write things
more literally.
Ruby types are best-effort mapped to SQL entities in a simple, regular way:
- Symbols map to quoted SQL names such as tables, columns, aliases.
- Strings map to strings. Always. (Quote style can be configured).
- Integers and floats map to, well, integers and floats.
- Date, Time and DateTime map to their ISO 8601 string representation
- Booleans map to their respective ANSI literals (unless overriden by
configuration).
- `nil` maps to `NULL` and is expected to have the same "unknown" semantic
Variable arguments are generally used. Hashes, depending on context, map to:
- `=` equality or `IN` operators joined by `AND`
- `=` assignment operator joined by commas
## Examples
### Query building
```ruby
require 'rebel'
# Here's a typical query
Rebel::SQL.select :id, from: :customers, where: { :first_name => 'John', :last_name => 'Doe' }
=> SELECT "id" FROM "customers" WHERE "first_name" = 'John' AND "last_name" = 'Doe'
# More args give more columns
Rebel::SQL.select :first_name, :last_name, from: :customers, where: { :id => [1, 2, 3] }
=> SELECT "first_name", "last_name" FROM "customers" WHERE "id" IN (1, 2, 3)
# * is special-cased for names
Rebel::SQL.select :*, from: :customers, where: { :id => [1, 2, 3] }
=> SELECT * FROM "customers" WHERE "id" IN (1, 2, 3)
# You can emit fragments to produce clauses
puts Rebel::SQL.and_clause :id => [1, 2, 3], :country => 'GB'
=> "id" IN (1, 2, 3) AND "country" = 'GB'
Rebel::SQL.where? :id => [1, 2, 3], :country => 'GB'
=> WHERE "id" IN (1, 2, 3) AND "country" = 'GB'
# Here the question mark means where? swallows nil arguments: maybe it's a Maybe monad
Rebel::SQL.where?(nil)
=> nil
# Let's emit join clauses
Rebel::SQL.join(:contracts, on: :customer_id => :id)
#=> JOIN "contracts" ON "customer_id" = "id"
Rebel::SQL.join(:contracts).on(:customer_id => :id)
#=> JOIN "contracts" ON "customer_id" = "id"
# :contracts might have an :id too, so we can disambiguate those columns
Rebel::SQL.join(:contracts).on(:'contracts.customer_id' => :'customers.id')
#=> JOIN "contracts" ON "contracts"."customer_id" = "customers"."id"
# Other types of join are obviously available
Rebel::SQL.inner_join(:contracts).on(:'contracts.customer_id' => :'customers.id')
#=> INNER JOIN "contracts" ON "contracts"."customer_id" = "customers"."id"
Rebel::SQL.outer_join(:contracts).on(:'contracts.customer_id' => :'customers.id')
#=> OUTER JOIN "contracts" ON "contracts"."customer_id" = "customers"."id"
Rebel::SQL.left_outer_join(:contracts).on(:'contracts.customer_id' => :'customers.id')
#=> LEFT OUTER JOIN "contracts" ON "contracts"."customer_id" = "customers"."id"
Rebel::SQL.right_outer_join(:contracts).on(:'contracts.customer_id' => :'customers.id')
#=> RIGHT OUTER JOIN "contracts" ON "contracts"."customer_id" = "customers"."id"
# The type of join can be split off. Again, note the question mark.
Rebel::SQL.inner? Rebel::SQL.join(:contracts).on(:'contracts.customer_id' => :'customers.id')
#=> INNER JOIN "contracts" ON "contracts"."customer_id" = "customers"."id"
Rebel::SQL.left? Rebel::SQL.outer_join(:contracts).on(:'contracts.customer_id' => :'customers.id')
#=> LEFT OUTER JOIN "contracts" ON "contracts"."customer_id" = "customers"."id"
# And in a full query
Rebel::SQL.select :'customers.id', :'contracts.id',
from: :customers,
where: { :first_name => 'John', :last_name => 'Doe' },
inner: Rebel::SQL.join(:contracts).on(:'contracts.customer_id' => :'customers.id'),
order: Rebel::SQL.by(:'customer.age').asc
#=> SELECT "customers"."id", "contracts"."id"
# FROM "customers"
# INNER JOIN "contracts" ON "contracts"."customer_id" = "customers"."id"
# WHERE "first_name" = 'John' AND "last_name" = 'Doe'
# ORDER BY "customer"."age" ASC
# All those Rebel::SQL can get unwieldy, so let's reduce the noise
Rebel::SQL() do
select :'customers.id', :'contracts.id',
from: :customers,
where: { :first_name => 'John', :last_name => 'Doe' },
inner: join(:contracts).on(:'contracts.customer_id' => :'customers.id'),
order: by(:'customer.age').asc
end
#=> SELECT "customers"."id", "contracts"."id"
# FROM "customers"
# INNER JOIN "contracts" ON "contracts"."customer_id" = "customers"."id"
# WHERE "first_name" = 'John' AND "last_name" = 'Doe'
# ORDER BY "customer"."age" ASC
# Now, that function can be used to make things different
Rebel::SQL.name(:foo)
#=> "foo"
Rebel::SQL(identifier_quote: '`').name(:foo)
#=> `foo`
Rebel::SQL.value(true)
#=> TRUE
Rebel::SQL(true_literal: '1').value(true)
#=> 1
Rebel::SQL(true_literal: '1') { select value(true) }
#=> SELECT 1
# While we're at it, let's call arbitrary functions
Rebel::SQL() { select function('NOW') }
#=> SELECT NOW()
Rebel::SQL() { select function('LENGTH', "a string") }
#=> SELECT LENGTH('a string')
Rebel::SQL() { select function('COUNT', :id), from: :customers, where: { :age => 42 } }
#=> SELECT COUNT("id") FROM "customers" WHERE "age" = 42
Rebel::SQL() { select count(:id), from: :customers, where: { :age => 42 } }
#=> SELECT COUNT("id") FROM "customers" WHERE "age" = 42
# And throw in some aliases
Rebel::SQL() { select function('LENGTH', "a string").as(:length) }
#=> SELECT LENGTH('a string') AS "length"
Rebel::SQL() { select name(:id).as(:customer_id), from: :customers }
#=> SELECT "id" AS "customer_id" FROM "customers"
Rebel::SQL() { select count(:id).as(:count), from: :customers, where: { :age => 42 } }
#=> SELECT COUNT("id") FROM "customers" WHERE "age" = 42
# While we're counting things, let's group results
Rebel::SQL() { select count(:id).as(:count), :country, from: :customers, group: by(:country).having(count(:customer_id) => 5) }
#=> SELECT COUNT("id") AS "count", "country" FROM "customers" GROUP BY "country" HAVING COUNT("customer_id") = 5
# Passing a hash does a best effort to map Ruby to SQL
Rebel::SQL() { select :id, from: :customers, where: { :age => 42 } }
#=> SELECT "id" FROM "customers" WHERE "age" = 42
Rebel::SQL() { select :id, from: :customers, where: { :age => [20, 21, 22] } }
#=> SELECT "id" FROM "customers" WHERE "age" IN (20, 21, 22)
Rebel::SQL() { select :id, from: :customers, where: { :age => nil } }
#=> SELECT "id" FROM "customers" WHERE "age" IS NULL
# Using operators ensures the expected SQL operator is used
Rebel::SQL() { select :id, from: :customers, where: name(:age).eq(42) }
#=> SELECT "id" FROM "customers" WHERE "age" = 42
Rebel::SQL() { select :id, from: :customers, where: name(:age).eq(nil) }
#=> SELECT "id" FROM "customers" WHERE "age" = NULL
Rebel::SQL() { select :id, from: :customers, where: name(:age).ne(42) }
#=> SELECT "id" FROM "customers" WHERE "age" != NULL
Rebel::SQL() { select :id, from: :customers, where: name(:age).is(42) }
#=> SELECT "id" FROM "customers" WHERE "age" IS 42
Rebel::SQL() { select :id, from: :customers, where: name(:age).is(nil) }
#=> SELECT "id" FROM "customers" WHERE "age" IS NULL
Rebel::SQL() { select :id, from: :customers, where: name(:age).is_not(nil) }
#=> SELECT "id" FROM "customers" WHERE "age" IS NOT NULL
# Other operators are available
Rebel::SQL() { select :id, from: :customers, where: name(:age).ge(42) }
#=> SELECT "id" FROM "customers" WHERE "age" >= 42
Rebel::SQL() { select :id, from: :customers, where: name(:age).in(21, 22, 23) }
#=> SELECT "id" FROM "customers" WHERE "age" IN (21, 22, 23)
Rebel::SQL() { select :id, from: :customers, where: name(:first_name).like("J%") }
#=> SELECT "id" FROM "customers" WHERE "first_name" LIKE "J%"
# Aliases to overload operators are available
Rebel::SQL() { select :id, from: :customers, where: name(:age) == 42 }
#=> SELECT "id" FROM "customers" WHERE "age" = 42
Rebel::SQL() { select :id, from: :customers, where: name(:age) < 42 }
#=> SELECT "id" FROM "customers" WHERE "age" < 42
Rebel::SQL() { select :id, from: :customers, where: name(:age) >= 42 }
#=> SELECT "id" FROM "customers" WHERE "age" >= 42
Rebel::SQL() { select :id, from: :customers, where: name(:age) != 42 }
#=> SELECT "id" FROM "customers" WHERE "age" != 42
# Conditions can be combined
Rebel::SQL() { select :id, from: :customers, where: name(:age).gt(42).or(name(:age).lt(21)) }
#=> SELECT "id" FROM "customers" WHERE ("age" > 42 OR "age" < 21)
Rebel::SQL() { select :id, from: :customers, where: (name(:age) < 42).and(name(:age) > 21) }
#=> SELECT "id" FROM "customers" WHERE ("age" < 42 AND "age" > 21)
# Binary-wise operators can be used to tie conditions
# WARNING: Usefulness if this hack is still debated. It might be removed in the future.
Rebel::SQL() { select :id, from: :customers, where: ((name(:age) > 42) | (name(:age) < 21)) }
#=> SELECT "id" FROM "customers" WHERE ("age" > 42 OR "age" < 21)
Rebel::SQL() { select :id, from: :customers, where: name(:age).lt(42) & (name(:age).gt(21)) }
#=> SELECT "id" FROM "customers" WHERE ("age" < 42 AND "age" > 21)
```
### Query execution
If you provide Rebel::SQL an environment within which a query executor is
available, queries can be executed directly.
```ruby
class CreateTableCustomers
include Rebel::SQL
# provide a connection that responds to exec(query)
def conn
@conn ||= PG.connect( dbname: 'sales' )
end
# remember that SQL() returns a module!
include Rebel::SQL(true_literal: '1', false_literal: '0')
# alternatively, redefine the provided exec (which calls conn.exec)
def exec(query)
@db ||= SQLite3::Database.new "test.db"
@db.execute(query)
end
def up
create_table :customers, {
id: 'SERIAL',
name: 'VARCHAR(255)',
address: 'VARCHAR(255)',
city: 'VARCHAR(255)',
zip: 'VARCHAR(255)',
country: 'VARCHAR(255)',
}
insert_into :customers,
{ name: 'Lewis Caroll', address: '1, Alice St.', city: 'Oxford', zip: '1865', country: 'Wonderland' },
{ name: 'Neal Stephenson', address: '2, Hiro Blvd.', city: 'Los Angeles', zip: '1992', country: 'Metaverse' }
results = select :name, :country, from: :customers
update :customers, set: { city: 'FooTown' }, where: { zip: 1234 }
delete_from :customers, where: { zip: 1234 }
truncate :customers
end
def down
drop_table :customers
end
end
```
## FAQ
### X is missing/database specific, how do I write it?
You can use `Rebel::SQL.raw("whatever")` and drop it in.
### Why the weird syntax like `inner: join` instead of `inner_join`?
This allows for a more uniform interface as well as not monkeypatching core types.
### Can I write nonsensical SQL with this?
Yes. Just as you can write nonsensical SQL in SQL.
### Your query builder is not using an AST.
That's not a question. You're welcome to implement one that does though, and if
it leverages the visitor pattern, allocates a trajillion objects along the way
and manages to produce invalid SQL in some corner cases, well congratulations
for reimplementing Arel.
## License
MIT

View file

@ -1,4 +1,6 @@
module Rebel::SQL
require 'date'
module Rebel::SQLQ
attr_reader :conn
def exec(query)
@ -6,125 +8,247 @@ module Rebel::SQL
end
def create_table(table_name, desc)
exec(SQL.create_table(table_name, desc))
exec(Rebel::SQL.create_table(table_name, desc))
end
def select(*fields, from: nil, where: nil, inner: nil, left: nil, right: nil)
exec(SQL.select(*fields,
def drop_table(table_name)
exec(Rebel::SQL.drop_table(table_name))
end
def select(*fields, distinct: nil, from: nil, where: nil, inner: nil, left: nil, right: nil, group: nil, order: nil, limit: nil, offset: nil)
exec(Rebel::SQL.select(*fields,
distinct: distinct,
from: from,
where: where,
inner: inner,
left: left,
right: right))
right: right,
group: group,
order: order,
limit: limit,
offset: offset))
end
def insert_into(table_name, *rows)
exec(SQL.insert_into(table_name, *rows))
exec(Rebel::SQL.insert_into(table_name, *rows))
end
def update(table_name, set: nil, where: nil, inner: nil, left: nil, right: nil)
exec(SQL.update(table_name, set: set, where: where, inner: inner, left: left, right: right))
exec(Rebel::SQL.update(table_name, set: set, where: where, inner: inner, left: left, right: right))
end
def delete_from(table_name, where: nil, inner: nil, left: nil, right: nil)
exec(SQL.delete_from(table_name, where: where, inner: inner, left: left, right: right))
exec(Rebel::SQL.delete_from(table_name, where: where, inner: inner, left: left, right: right))
end
def truncate(table_name)
exec(SQL.truncate(table_name))
exec(Rebel::SQL.truncate(table_name))
end
def count(*n)
SQL.count(*n)
Rebel::SQL.count(*n)
end
def join(table, on: nil)
SQL.join(table, on: on)
Rebel::SQL.join(table, on: on)
end
def outer_join(table, on: nil)
SQL.outer_join(table, on: on)
Rebel::SQL.outer_join(table, on: on)
end
end
module Rebel
class Raw < String
def wants_parens!
@wants_parens = true
self
end
def wants_parens?
@wants_parens = false unless instance_variable_defined?(:@wants_parens)
@wants_parens
end
def parens
sql.raw("(#{self})")
end
def parens?
wants_parens? ? parens : self
end
class Raw < String
def as(n)
Raw.new(self + " AS #{SQL.name(n)}")
sql.raw(self + " AS #{sql.name(n)}")
end
def as?(n)
n ? as(n) : self
end
def on(clause)
Raw.new(self + " ON #{SQL.and_clause(clause)}")
def on(*clause)
sql.raw(self + " ON #{sql.and_clause(*clause)}")
end
def on?(clause)
clause ? on(clause) : self
def on?(*clause)
clause.any? ? on(*clause) : self
end
def having(*clause)
sql.raw(self + " HAVING #{sql.and_clause(*clause)}")
end
def asc
sql.raw(self + " ASC")
end
def desc
sql.raw(self + " DESC")
end
def and(*clause)
sql.raw("#{self.parens?} AND #{sql.and_clause(*clause)}")
end
alias & and
def or(*clause)
sql.raw("#{self} OR #{sql.and_clause(*clause)}").wants_parens!
end
alias | or
def eq(n)
sql.raw("#{self} = #{sql.name_or_value(n)}")
end
alias == eq
def is(n)
sql.raw("#{self} IS #{sql.name_or_value(n)}")
end
def ne(n)
sql.raw("#{self} != #{sql.name_or_value(n)}")
end
alias != ne
def is_not(n)
sql.raw("#{self} IS NOT #{sql.name_or_value(n)}")
end
def lt(n)
sql.raw("#{self} < #{sql.name_or_value(n)}")
end
alias < lt
def gt(n)
sql.raw("#{self} > #{sql.name_or_value(n)}")
end
alias > gt
def le(n)
sql.raw("#{self} <= #{sql.name_or_value(n)}")
end
alias <= le
def ge(n)
sql.raw("#{self} >= #{sql.name_or_value(n)}")
end
alias >= ge
def in(*v)
sql.raw("#{self} IN (#{sql.values(*v)})")
end
def not_in(*v)
sql.raw("#{self} NOT IN (#{sql.values(*v)})")
end
def like(n)
sql.raw("#{self} LIKE #{sql.value(n)}")
end
def not_like(n)
sql.raw("#{self} NOT LIKE #{sql.value(n)}")
end
private
def sql
@sql ||= Rebel::SQLQ
end
end
class << self
module SQLB
def raw(str)
Raw.new(str)
Raw.new(str).tap { |r| r.instance_variable_set(:@sql, self) }
end
def create_table(table_name, desc)
<<-SQL
CREATE TABLE #{SQL.name(table_name)} (
#{SQL.list(desc.map { |k, v| "#{SQL.name(k)} #{v}" })}
);
SQL
raw %[CREATE TABLE #{name(table_name)} (#{list(desc.map { |k, v| "#{name(k)} #{v}" })})]
end
def select(*fields, from: nil, where: nil, inner: nil, left: nil, right: nil)
<<-SQL
SELECT #{names(*fields)} FROM #{name(from)}
#{SQL.inner?(inner)}
#{SQL.left?(left)}
#{SQL.right?(right)}
#{SQL.where?(where)};
SQL
def drop_table(table_name)
raw "DROP TABLE #{name(table_name)}"
end
def select(*fields, distinct: nil, from: nil, where: nil, inner: nil, left: nil, right: nil, group: nil, order: nil, limit: nil, offset: nil)
raw [
"SELECT #{distinct ? "DISTINCT #{names(*distinct)}" : names(*fields)}",
from?(from),
inner?(inner),
left?(left),
right?(right),
where?(where),
group?(group),
order?(order),
limit?(limit, offset),
].compact.join(' ')
end
def insert_into(table_name, *rows)
<<-SQL
INSERT INTO #{SQL.name(table_name)} (#{SQL.names(*rows.first.keys)})
VALUES #{SQL.list(rows.map { |r| "(#{SQL.values(*r.values)})" })};
SQL
raw [
"INSERT INTO #{name(table_name)} (#{names(*rows.first.keys)})",
"VALUES #{list(rows.map { |r| "(#{values(*r.values)})" })}",
].join(' ')
end
def update(table_name, set: nil, where: nil, inner: nil, left: nil, right: nil)
fail ArgumentError if set.nil?
raise ArgumentError if set.nil?
<<-SQL
UPDATE #{SQL.name(table_name)}
SET #{SQL.assign_clause(set)}
#{SQL.inner?(inner)}
#{SQL.left?(left)}
#{SQL.right?(right)}
#{SQL.where?(where)};
SQL
raw [
"UPDATE #{name(table_name)}",
"SET #{assign_clause(set)}",
inner?(inner),
left?(left),
right?(right),
where?(where),
].compact.join(' ')
end
def delete_from(table_name, where: nil, inner: nil, left: nil, right: nil)
<<-SQL
DELETE FROM #{SQL.name(table_name)}
#{SQL.inner?(inner)}
#{SQL.left?(left)}
#{SQL.right?(right)}
#{SQL.where?(where)};
SQL
raw [
"DELETE FROM #{name(table_name)}",
inner?(inner),
left?(left),
right?(right),
where?(where),
].join(' ')
end
def truncate(table_name)
<<-SQL
TRUNCATE #{SQL.name(table_name)};
SQL
raw "TRUNCATE #{name(table_name)}"
end
## Functions
def function(name, *args)
raw("#{name}(#{names_or_values(*args)})")
end
alias fn function
def by(*n)
raw("BY #{names(*n)}")
end
def count(*n)
raw("COUNT(#{names(*n)})")
end
@ -137,13 +261,26 @@ module Rebel::SQL
raw("OUTER JOIN #{name(table)}").on?(on)
end
def inner_join(table, on: nil)
raw(inner? join(table, on: on))
end
def left_outer_join(table, on: nil)
raw(left? outer_join(table, on: on))
end
def right_outer_join(table, on: nil)
raw(right? outer_join(table, on: on))
end
## Support
def name(name)
def name(name = nil)
super() if name.nil? # workaround for pry and introspection
return name if name.is_a?(Raw)
return raw('*') if name == '*'
return raw('*') if name == :*
name.to_s.split('.').map { |e| "\"#{e}\"" }.join('.')
raw(name.to_s.split('.').map { |e| "#{@identifier_quote}#{e}#{@identifier_quote}" }.join('.'))
end
def names(*names)
@ -154,13 +291,22 @@ module Rebel::SQL
items.join(', ')
end
def escape_str(str)
str.dup.tap do |s|
s.gsub!('\\') { @escaped_string_backslash } if @escaped_string_backslash
s.gsub!(@string_quote) { @escaped_string_quote }
end
end
def value(v)
case v
when Raw then v
when String then raw "'#{v.tr("'", "''")}'"
when Fixnum then raw v.to_s
when String then raw "#{@string_quote}#{escape_str(v)}#{@string_quote}"
when Integer then raw v.to_s
when TrueClass, FalseClass then raw(v ? @true_literal : @false_literal)
when Date, Time, DateTime then value(v.iso8601)
when nil then raw 'NULL'
else fail NotImplementedError, v.inspect
else raise NotImplementedError, "#{v.class}: #{v.inspect}"
end
end
@ -172,6 +318,10 @@ module Rebel::SQL
item.is_a?(Symbol) ? name(item) : value(item)
end
def names_or_values(*items)
list(items.map { |v| name_or_value(v) })
end
def equal(l, r)
"#{name_or_value(l)} = #{name_or_value(r)}"
end
@ -180,22 +330,35 @@ module Rebel::SQL
list(clause.map { |k, v| equal(k, v) })
end
def and_clause(clause)
return clause if clause.is_a?(Raw) || clause.is_a?(String)
def clause_term(left, right)
case right
when Array
name(left).in(*right)
when nil
name(left).is(name_or_value(right))
else
name(left).eq(name_or_value(right))
end
end
def and_clause(*clause)
clause.map do |e|
case e
when Array then "#{name(e[0])} = #{name_or_value(e[1])}"
when Raw, String then e
else fail NotImplementedError, e.class
when Hash then and_clause(*e.to_a)
when Array then clause_term(e[0], e[1])
when Raw then e.parens?
when String then e
else raise NotImplementedError, e.class
end
end.join(' AND ')
end
def where?(clause)
return "WHERE #{clause}" if clause.is_a?(Raw) || clause.is_a?(String)
def from?(from)
from ? "FROM #{name(from)}" : nil
end
(clause && clause.any?) ? "WHERE #{SQL.and_clause(clause)}" : nil
def where?(*clause)
clause.any? ? "WHERE #{and_clause(*clause)}" : nil
end
def inner?(join)
@ -209,5 +372,48 @@ module Rebel::SQL
def right?(join)
join ? "RIGHT #{join}" : nil
end
def group?(group)
group ? "GROUP #{name(group)}" : nil
end
def order?(order)
order ? "ORDER #{name(order)}" : nil
end
def limit?(limit, offset)
limit ? "LIMIT #{value(limit)}" << (offset ? " OFFSET #{offset}" : "") : nil
end
end
end
module Rebel
def self.SQL(options = {}, &block)
sql = const_defined?(:SQL) && options.empty? ? SQL : Module.new do
@identifier_quote = options[:identifier_quote] || '"'
@string_quote = options[:string_quote] || "'"
@escaped_string_quote = options[:escaped_string_quote] || "''"
@escaped_string_backslash = options[:escaped_string_backslash]
@true_literal = options[:true_literal] || 'TRUE'
@false_literal = options[:false_literal] || 'FALSE'
extend Rebel::SQLB
include Rebel::SQLQ
def self.name(name = nil)
return "Rebel::SQL" if name.nil?
super
end
def self.inspect
"#<Rebel::SQL(#{instance_variables.map { |k| "#{k.to_s.sub(/^@/, '')}: #{instance_variable_get(k).inspect}" }.join(', ')})>"
end
end
return sql.instance_eval(&block) unless block.nil?
sql
end
SQL = SQL()
end

View file

@ -1,11 +1,11 @@
Gem::Specification.new do |s|
s.name = 'rebel'
s.version = '0.1.0'
s.version = '0.7.2'
s.licenses = ['MIT']
s.summary = 'Fight against the Object tyranny'
s.description = 'Write SQL queries in Ruby, or is it the other way around?'
s.description = 'SQL-flavoured Ruby, or is it the other way around?'
s.authors = ['Loic Nageleisen']
s.email = 'loic.nageleisen@gmail.com'
s.files = Dir['lib/**/*.rb']
s.homepage = 'https://github.com/lloeki/rebel.git'
s.homepage = 'https://gitlab.com/lloeki/rebel.git'
end

8
test/helper.rb Normal file
View file

@ -0,0 +1,8 @@
require 'pry'
require 'sqlite3'
def memdb
SQLite3::Database.new(':memory:').tap do |db|
db.class.instance_eval { alias_method :exec, :execute }
end
end

49
test/test_exec.rb Normal file
View file

@ -0,0 +1,49 @@
require 'minitest/autorun'
require 'helper'
require 'rebel'
class TestExec < Minitest::Test
include Rebel::SQL
def setup
@conn = memdb
end
def test_create_table
assert_raises(SQLite3::SQLException) { conn.execute('SELECT * FROM foo') }
create_table :foo, id: 'INT', col: 'VARCHAR(255)'
assert_equal(conn.execute('SELECT * FROM foo'), [])
end
def test_drop_table
create_table :foo, id: 'INT', col: 'VARCHAR(255)'
assert_equal(conn.execute('SELECT * FROM foo'), [])
drop_table :foo
assert_raises(SQLite3::SQLException) { conn.execute('SELECT * FROM foo') }
end
def test_insert_into
create_table :foo, id: 'INT', col: 'VARCHAR(255)'
insert_into :foo, id: 1, col: 'whatevs'
assert_equal(conn.execute('SELECT * FROM foo'), [[1, 'whatevs']])
end
def test_insert_into_with_many_values
create_table :foo, id: 'INT', col: 'VARCHAR(255)'
insert_into :foo,
{ id: 1, col: 'more' },
{ id: 2, col: 'rows' },
{ id: 3, col: 'for the win' }
assert_equal(conn.execute('SELECT * FROM foo'), [
[1, 'more'],
[2, 'rows'],
[3, 'for the win'],
])
end
def test_select
create_table :foo, id: 'INT', col: 'VARCHAR(255)'
insert_into :foo, id: 1, col: 'whatevs'
assert_equal(select(:*, from: :foo), [[1, 'whatevs']])
end
end

246
test/test_raw.rb Normal file
View file

@ -0,0 +1,246 @@
require 'minitest/autorun'
require 'helper'
require 'rebel'
class TestRaw < Minitest::Test
def assert_sql(expected, &actual)
assert_equal(expected.to_s, Rebel::SQL(&actual).to_s)
end
def assert_mysql(expected, &actual)
assert_equal(expected.to_s, Rebel::SQL(identifier_quote: '`', escaped_string_quote: "\\'", escaped_string_backslash: '\\', &actual).to_s)
end
def assert_sqlite(expected, &actual)
assert_equal(expected.to_s, Rebel::SQL(true_literal: '1', false_literal: '0', &actual).to_s)
end
def assert_postgresql(expected, &actual)
assert_equal(expected.to_s, Rebel::SQL(&actual).to_s)
end
def test_and
assert_sql('"foo" = 1 AND "bar" = 2') { name(:foo).eq(1).and(name(:bar).eq(2)) }
assert_sql('"foo" = 1 AND "bar" = 2') { name(:foo).eq(1) & name(:bar).eq(2) }
assert_sql('"foo" = 1 AND "bar" = 2') { (name(:foo) == 1) & (name(:bar) == 2) }
end
def test_or
assert_sql('"foo" = 1 OR "bar" = 2') { name(:foo).eq(1).or(name(:bar).eq(2)) }
assert_sql('"foo" = 1 OR "bar" = 2') { name(:foo).eq(1) | name(:bar).eq(2) }
assert_sql('"foo" = 1 OR "bar" = 2') { (name(:foo) == 1) | (name(:bar) == 2) }
end
def test_and_or
assert_sql('"foo" = 0 AND ("foo" = 1 OR "bar" = 2)') { name(:foo).eq(0).and(name(:foo).eq(1).or(name(:bar).eq(2))) }
assert_sql('"foo" = 0 AND ("foo" = 1 OR "bar" = 2)') { name(:foo).eq(0) & (name(:foo).eq(1) | name(:bar).eq(2)) }
end
def test_or_and_or
assert_sql('("foo" = 1 OR "bar" = 2) AND ("foo" = 3 OR "bar" = 4)') { name(:foo).eq(1).or(name(:bar).eq(2)).and(name(:foo).eq(3).or(name(:bar).eq(4))) }
assert_sql('("foo" = 1 OR "bar" = 2) AND ("foo" = 3 OR "bar" = 4)') { (name(:foo).eq(1) | name(:bar).eq(2)) & (name(:foo).eq(3) | name(:bar).eq(4)) }
end
def test_and_or_and
assert_sql('"foo" = 1 AND "bar" = 2 OR "foo" = 3 AND "bar" = 4') { name(:foo).eq(1).and(name(:bar).eq(2)).or(name(:foo).eq(3).and(name(:bar).eq(4))) }
assert_sql('"foo" = 1 AND "bar" = 2 OR "foo" = 3 AND "bar" = 4') { name(:foo).eq(1) & name(:bar).eq(2) | name(:foo).eq(3) & name(:bar).eq(4) }
end
def test_is
assert_sql('"foo" IS NULL') { name(:foo).is(nil) }
assert_sql('"foo" IS 42') { name(:foo).is(42) }
assert_sql('"foo" IS "bar"') { name(:foo).is(name(:bar)) }
end
def test_is_not
assert_sql('"foo" IS NOT NULL') { name(:foo).is_not(nil) }
assert_sql('"foo" IS NOT 42') { name(:foo).is_not(42) }
assert_sql('"foo" IS NOT "bar"') { name(:foo).is_not(name(:bar)) }
end
def test_eq
assert_sql('"foo" = NULL') { name(:foo).eq(nil) }
assert_sql('"foo" = NULL') { name(:foo) == nil }
assert_sql('"foo" = "bar"') { name(:foo).eq(name(:bar)) }
assert_sql('"foo" = "bar"') { name(:foo) == name(:bar) }
end
def test_ne
assert_sql('"foo" != "bar"') { name(:foo).ne(name(:bar)) }
assert_sql('"foo" != "bar"') { name(:foo) != name(:bar) }
assert_sql('"foo" != NULL') { name(:foo).ne(nil) }
assert_sql('"foo" != NULL') { name(:foo) != nil }
end
def test_lt
assert_sql('"foo" < "bar"') { name(:foo).lt(name(:bar)) }
assert_sql('"foo" < "bar"') { name(:foo) < name(:bar) }
end
def test_gt
assert_sql('"foo" > "bar"') { name(:foo).gt(name(:bar)) }
assert_sql('"foo" > "bar"') { name(:foo) > name(:bar) }
end
def test_le
assert_sql('"foo" <= "bar"') { name(:foo).le(name(:bar)) }
assert_sql('"foo" <= "bar"') { name(:foo) <= name(:bar) }
end
def test_ge
assert_sql('"foo" >= "bar"') { name(:foo).ge(name(:bar)) }
assert_sql('"foo" >= "bar"') { name(:foo) >= name(:bar) }
end
def test_in
assert_sql('"foo" IN (1, 2, 3)') { name(:foo).in(1, 2, 3) }
end
def test_not_in
assert_sql('"foo" NOT IN (1, 2, 3)') { name(:foo).not_in(1, 2, 3) }
end
def test_like
assert_sql(%("foo" LIKE '%bar%')) { name(:foo).like('%bar%') }
end
def test_not_like
assert_sql(%("foo" NOT LIKE '%bar%')) { name(:foo).not_like('%bar%') }
end
def test_where
assert_sql('WHERE "foo" = 1 AND "bar" = 2 AND "baz" = 3') { where?(foo: 1, bar: 2, baz: 3) }
assert_sql('WHERE ("foo" = 1 OR "bar" = 2) AND "baz" = 3') { where?(name(:foo).eq(1).or(name(:bar).eq(2)), name(:baz).eq(3)) }
assert_sql('WHERE ("foo" = 1 OR "bar" = 2)') { where?(name(:foo).eq(1).or(name(:bar).eq(2))) }
assert_sql('WHERE "foo" IS NULL') { where?(foo: nil) }
assert_sql('WHERE "foo" IN (1, 2, 3)') { where?(foo: [1, 2, 3]) }
end
def test_join
assert_sql('JOIN "foo"') { join(:foo) }
end
def test_function
assert_sql('COALESCE("foo", 0)') { function('COALESCE', :foo, 0) }
end
def test_where_function
assert_sql('WHERE COALESCE("foo", 0) = 42') { where?(function('COALESCE', :foo, 0).eq 42) }
end
def test_name
assert_sql('"foo"') { name(:foo) }
assert_mysql('`foo`') { name(:foo) }
assert_postgresql('"foo"') { name(:foo) }
assert_sqlite('"foo"') { name(:foo) }
end
def test_string
assert_sql("'FOO'") { value('FOO') }
assert_mysql("'FOO'") { value('FOO') }
assert_postgresql("'FOO'") { value('FOO') }
assert_sqlite("'FOO'") { value('FOO') }
end
def test_escaped_string
assert_sql (%q('FOO''BAR')) { value(%q(FOO'BAR)) }
assert_postgresql (%q('FOO''BAR')) { value(%q(FOO'BAR)) }
assert_sqlite (%q('FOO''BAR')) { value(%q(FOO'BAR)) }
assert_mysql (%q('FOO\'BAR')) { value(%q(FOO'BAR)) }
assert_sql (%q('FOO"BAR')) { value(%q(FOO"BAR)) }
assert_postgresql (%q('FOO"BAR')) { value(%q(FOO"BAR)) }
assert_sqlite (%q('FOO"BAR')) { value(%q(FOO"BAR)) }
assert_mysql (%q('FOO"BAR')) { value(%q(FOO"BAR)) }
assert_sql (%q('FOO\BAR')) { value(%q(FOO\BAR)) }
assert_postgresql (%q('FOO\BAR')) { value(%q(FOO\BAR)) }
assert_sqlite (%q('FOO\BAR')) { value(%q(FOO\BAR)) }
assert_mysql (%q('FOO\\BAR')) { value(%q(FOO\BAR)) }
assert_sql (%q('FOO\\''BAR')) { value(%q(FOO\'BAR)) }
assert_postgresql (%q('FOO\\''BAR')) { value(%q(FOO\'BAR)) }
assert_sqlite (%q('FOO\\''BAR')) { value(%q(FOO\'BAR)) }
assert_mysql (%q('FOO\\\'BAR')) { value(%q(FOO\'BAR)) }
end
def test_boolean_literal
assert_sql('TRUE') { value(true) }
assert_mysql('TRUE') { value(true) }
assert_postgresql('TRUE') { value(true) }
assert_sqlite('1') { value(true) }
assert_sql('FALSE') { value(false) }
assert_mysql('FALSE') { value(false) }
assert_postgresql('FALSE') { value(false) }
assert_sqlite('0') { value(false) }
end
def test_value
assert_sql("'FOO'") { value(raw("'FOO'")) }
assert_sql("'FOO'") { value('FOO') }
assert_sql('1') { value(1) }
assert_sql('TRUE') { value(true) }
assert_sql('FALSE') { value(false) }
assert_sql("'2016-12-31'") { value(Date.new(2016, 12, 31)) }
assert_sql("'2016-12-31T23:59:59Z'") { value(Time.utc(2016, 12, 31, 23, 59, 59)) }
assert_sql("'2016-12-31T23:59:59+00:00'") { value(DateTime.new(2016, 12, 31, 23, 59, 59)) }
assert_sql('NULL') { value(nil) }
end
def test_select
assert_sql('SELECT * FROM "foo"') { select(raw('*'), from: name(:foo)) }
end
def test_select_without_from
assert_sql('SELECT 1') { select(raw('1')).strip }
end
def test_select_distinct
assert_sql('SELECT DISTINCT "bar" FROM "foo"') { select(distinct: :bar, from: :foo) }
end
def test_select_distinct_multiple
assert_sql('SELECT DISTINCT "bar", "baz" FROM "foo"') { select(distinct: [:bar, :baz], from: :foo) }
end
def test_select_group_by
assert_sql('SELECT "bar" FROM "foo" GROUP BY "baz"') { select(:bar, from: :foo, group: by(:baz)) }
end
def test_select_group_by_having
assert_sql('SELECT "bar" FROM "foo" GROUP BY "baz" HAVING COUNT("qux") > 5') { select(:bar, from: :foo, group: by(:baz).having(count(:qux).gt(5))) }
end
def test_select_order_by
assert_sql('SELECT "bar" FROM "foo" ORDER BY "baz"') { select(:bar, from: :foo, order: by(:baz)) }
end
def test_select_order_by_asc
assert_sql('SELECT "bar" FROM "foo" ORDER BY "baz" ASC') { select(:bar, from: :foo, order: by(:baz).asc) }
end
def test_select_order_by_desc
assert_sql('SELECT "bar" FROM "foo" ORDER BY "baz" DESC') { select(:bar, from: :foo, order: by(:baz).desc) }
end
def test_select_multiple_order_by
assert_sql('SELECT "bar" FROM "foo" ORDER BY "baz", "qux"') { select(:bar, from: :foo, order: by(:baz, :qux)) }
end
def test_select_multiple_order_by_opposing
assert_sql('SELECT "bar" FROM "foo" ORDER BY "baz" ASC, "qux" DESC') { select(:bar, from: :foo, order: by(name(:baz).asc, name(:qux).desc)) }
end
def test_select_limit
assert_sql('SELECT "bar" FROM "foo" LIMIT 10') { select(:bar, from: :foo, limit: 10) }
end
def test_select_offset
assert_sql('SELECT "bar" FROM "foo" LIMIT 10 OFFSET 20') { select(:bar, from: :foo, limit: 10, offset: 20) }
end
def test_nested_select
assert_sql('SELECT * FROM "foo" WHERE "bar" IN (SELECT "bar" FROM "foo")') { select(raw('*'), from: name(:foo), where: name(:bar).in(select(name(:bar), from: name(:foo)))) }
end
end