diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index 89086a0..0000000 --- a/.rubocop.yml +++ /dev/null @@ -1,32 +0,0 @@ -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 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index b54c8f9..0000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,15 +0,0 @@ -# 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' diff --git a/Gemfile b/Gemfile index 3c96edc..8ecc621 100644 --- a/Gemfile +++ b/Gemfile @@ -6,4 +6,3 @@ gem 'minitest' gem 'pry' gem 'rake' gem 'rubocop' -gem 'sqlite3' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..3b3452f --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,43 @@ +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 diff --git a/README.md b/README.md index e6fb495..2949548 100644 --- a/README.md +++ b/README.md @@ -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,11 +21,13 @@ 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 @@ -34,314 +36,6 @@ 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 diff --git a/lib/rebel/sql.rb b/lib/rebel/sql.rb index b5216e8..3d8830c 100644 --- a/lib/rebel/sql.rb +++ b/lib/rebel/sql.rb @@ -1,6 +1,4 @@ -require 'date' - -module Rebel::SQLQ +module Rebel::SQL attr_reader :conn def exec(query) @@ -8,247 +6,125 @@ module Rebel::SQLQ end def create_table(table_name, desc) - exec(Rebel::SQL.create_table(table_name, desc)) + exec(SQL.create_table(table_name, desc)) end - 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, - group: group, - order: order, - limit: limit, - offset: offset)) + def select(*fields, from: nil, where: nil, inner: nil, left: nil, right: nil) + exec(SQL.select(*fields, + from: from, + where: where, + inner: inner, + left: left, + right: right)) end def insert_into(table_name, *rows) - exec(Rebel::SQL.insert_into(table_name, *rows)) + exec(SQL.insert_into(table_name, *rows)) end def update(table_name, set: nil, where: nil, inner: nil, left: nil, right: nil) - exec(Rebel::SQL.update(table_name, set: set, where: where, inner: inner, left: left, right: right)) + exec(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(Rebel::SQL.delete_from(table_name, where: where, inner: inner, left: left, right: right)) + exec(SQL.delete_from(table_name, where: where, inner: inner, left: left, right: right)) end def truncate(table_name) - exec(Rebel::SQL.truncate(table_name)) + exec(SQL.truncate(table_name)) end def count(*n) - Rebel::SQL.count(*n) + SQL.count(*n) end def join(table, on: nil) - Rebel::SQL.join(table, on: on) + SQL.join(table, on: on) end def outer_join(table, on: nil) - Rebel::SQL.outer_join(table, on: on) + 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 - def as(n) - sql.raw(self + " AS #{sql.name(n)}") + Raw.new(self + " AS #{SQL.name(n)}") end def as?(n) n ? as(n) : self end - def on(*clause) - sql.raw(self + " ON #{sql.and_clause(*clause)}") + def on(clause) + Raw.new(self + " ON #{SQL.and_clause(clause)}") end - 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 + def on?(clause) + clause ? on(clause) : self end end - module SQLB + class << self def raw(str) - Raw.new(str).tap { |r| r.instance_variable_set(:@sql, self) } + Raw.new(str) end def create_table(table_name, desc) - raw %[CREATE TABLE #{name(table_name)} (#{list(desc.map { |k, v| "#{name(k)} #{v}" })})] + <<-SQL + CREATE TABLE #{SQL.name(table_name)} ( + #{SQL.list(desc.map { |k, v| "#{SQL.name(k)} #{v}" })} + ); + SQL end - 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(' ') + 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 end def insert_into(table_name, *rows) - raw [ - "INSERT INTO #{name(table_name)} (#{names(*rows.first.keys)})", - "VALUES #{list(rows.map { |r| "(#{values(*r.values)})" })}", - ].join(' ') + <<-SQL + INSERT INTO #{SQL.name(table_name)} (#{SQL.names(*rows.first.keys)}) + VALUES #{SQL.list(rows.map { |r| "(#{SQL.values(*r.values)})" })}; + SQL end def update(table_name, set: nil, where: nil, inner: nil, left: nil, right: nil) - raise ArgumentError if set.nil? + fail ArgumentError if set.nil? - raw [ - "UPDATE #{name(table_name)}", - "SET #{assign_clause(set)}", - inner?(inner), - left?(left), - right?(right), - where?(where), - ].compact.join(' ') + <<-SQL + UPDATE #{SQL.name(table_name)} + SET #{SQL.assign_clause(set)} + #{SQL.inner?(inner)} + #{SQL.left?(left)} + #{SQL.right?(right)} + #{SQL.where?(where)}; + SQL end def delete_from(table_name, where: nil, inner: nil, left: nil, right: nil) - raw [ - "DELETE FROM #{name(table_name)}", - inner?(inner), - left?(left), - right?(right), - where?(where), - ].join(' ') + <<-SQL + DELETE FROM #{SQL.name(table_name)} + #{SQL.inner?(inner)} + #{SQL.left?(left)} + #{SQL.right?(right)} + #{SQL.where?(where)}; + SQL end def truncate(table_name) - raw "TRUNCATE #{name(table_name)}" + <<-SQL + TRUNCATE #{SQL.name(table_name)}; + SQL 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 @@ -261,26 +137,13 @@ module Rebel 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 = nil) - super() if name.nil? # workaround for pry and introspection + def name(name) return name if name.is_a?(Raw) - return raw('*') if name == :* + return raw('*') if name == '*' - raw(name.to_s.split('.').map { |e| "#{@identifier_quote}#{e}#{@identifier_quote}" }.join('.')) + name.to_s.split('.').map { |e| "\"#{e}\"" }.join('.') end def names(*names) @@ -291,22 +154,13 @@ module Rebel 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 "#{@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 String then raw "'#{v.tr("'", "''")}'" + when Fixnum then raw v.to_s when nil then raw 'NULL' - else raise NotImplementedError, "#{v.class}: #{v.inspect}" + else fail NotImplementedError, v.inspect end end @@ -318,10 +172,6 @@ module Rebel 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 @@ -330,35 +180,22 @@ module Rebel list(clause.map { |k, v| equal(k, v) }) end - 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) + return clause if clause.is_a?(Raw) || clause.is_a?(String) - def and_clause(*clause) clause.map do |e| case e - 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 + when Array then "#{name(e[0])} = #{name_or_value(e[1])}" + when Raw, String then e + else fail NotImplementedError, e.class end end.join(' AND ') end - def from?(from) - from ? "FROM #{name(from)}" : nil - end + def where?(clause) + return "WHERE #{clause}" if clause.is_a?(Raw) || clause.is_a?(String) - def where?(*clause) - clause.any? ? "WHERE #{and_clause(*clause)}" : nil + (clause && clause.any?) ? "WHERE #{SQL.and_clause(clause)}" : nil end def inner?(join) @@ -372,48 +209,5 @@ module Rebel 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 - "#" - end - end - - return sql.instance_eval(&block) unless block.nil? - - sql - end - - SQL = SQL() -end diff --git a/rebel.gemspec b/rebel.gemspec index 75a3680..2bd28e0 100644 --- a/rebel.gemspec +++ b/rebel.gemspec @@ -1,11 +1,11 @@ Gem::Specification.new do |s| s.name = 'rebel' - s.version = '0.7.2' + s.version = '0.1.0' s.licenses = ['MIT'] s.summary = 'Fight against the Object tyranny' - s.description = 'SQL-flavoured Ruby, or is it the other way around?' + s.description = 'Write SQL queries in 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://gitlab.com/lloeki/rebel.git' + s.homepage = 'https://github.com/lloeki/rebel.git' end diff --git a/test/helper.rb b/test/helper.rb deleted file mode 100644 index 4e6708f..0000000 --- a/test/helper.rb +++ /dev/null @@ -1,8 +0,0 @@ -require 'pry' -require 'sqlite3' - -def memdb - SQLite3::Database.new(':memory:').tap do |db| - db.class.instance_eval { alias_method :exec, :execute } - end -end diff --git a/test/test_exec.rb b/test/test_exec.rb deleted file mode 100644 index f9cb146..0000000 --- a/test/test_exec.rb +++ /dev/null @@ -1,49 +0,0 @@ -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 diff --git a/test/test_raw.rb b/test/test_raw.rb deleted file mode 100644 index e3cee69..0000000 --- a/test/test_raw.rb +++ /dev/null @@ -1,246 +0,0 @@ -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