├── .gitignore ├── README.textile ├── pom.xml └── src ├── main └── scala │ └── com │ └── log4p │ └── sqldsl │ ├── AnsiSqlRenderer.scala │ ├── QueryBuilder.scala │ └── SQLParser.scala └── test └── scala └── com └── log4p └── sqldsl ├── QueryBuilderSpec.scala └── SQLParserSpec.scala /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iws 3 | *.ipr 4 | *.iml 5 | 6 | out 7 | target 8 | 9 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h1. Scala SQL DSL 2 | 3 | h2. Scala implementation of a really small subset of the SQL language 4 | 5 | To understand how to write DSLs (internal/external) in Scala I started implementing a small subset of the SQL language. 6 | 7 | Implemented: 8 | 9 | * select operation with fields 10 | * where clause with (typed) equals, in, and, or 11 | * order 12 | * A 'renderer' to create a SQL String from the given Query object 13 | 14 | Scala SQL DSL lets you write stuff like: 15 | 16 |
17 | scala> val q = select ("*") from ("user") where (("name","peter") and (("active", true) or ("role", "admin")))
18 | scala> q.sql
19 | res0: java.lang.String = select * from user where (name = 'peter' and (active = true or role = 'admin'))
20 | 
21 | 22 | Or using the parser: 23 | 24 |
25 | scala> val sql = """select name,age from users where name = "peter" and (active = true or age = 30)"""
26 | scala> val query = p.parse(sql).get
27 | scala> AnsiSqlRenderer.sql(query)
28 | res0: java.lang.String = select name,age from users where (name = 'peter' and (active = true or age = 30))
29 | 
30 | 31 | h2. Prerequisites for building / running tests 32 | 33 | * maven2 34 | * maven uses the maven-scala-test plugin of which I have a for here: [http://github.com/p3t0r/maven-scalatest-plugin/] for running the specs 35 | 36 | h2. Usage 37 | 38 | The spec contains various examples on how to write queries. But basically you start by: 39 | 40 | * Import all functions/implicits in the QueryBuilder object: import QueryBuilder._ 41 | * Import the AnsiSqlRenderer object: AnsiSqlRenderer._ 42 | 43 | h2. Author 44 | 45 | Written by Peter Maas 46 | 47 | * http://www.twitter.com/p3t0r 48 | * http://log4p.com 49 | 50 | h1. License 51 | 52 |
53 | This software is licensed under the Apache 2 license, quoted below.
54 | 
55 | Licensed under the Apache License, Version 2.0 (the "License"); you may not
56 | use this file except in compliance with the License. You may obtain a copy of
57 | the License at
58 | 
59 |     http://www.apache.org/licenses/LICENSE-2.0
60 | 
61 | Unless required by applicable law or agreed to in writing, software
62 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
63 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
64 | License for the specific language governing permissions and limitations under
65 | the License.
66 | 


--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |     4.0.0
 4 |     com.log4p
 5 |     scala-sql-dsl
 6 |     jar
 7 |     1.0-SNAPSHOT
 8 |     scala-sql-dsl
 9 |     
10 |         
11 |             scala-tools.org
12 |             Scala-tools Maven2 Repository
13 |             http://scala-tools.org/repo-releases
14 |         
15 |     
16 |     
17 |         
18 |             scala-tools.org
19 |             Scala-tools Maven2 Repository
20 |             http://scala-tools.org/repo-releases
21 |         
22 |     
23 |     
24 |         
25 |             org.scala-lang
26 |             scala-library
27 |             2.8.0
28 |         
29 |         
30 |             org.scala-lang
31 |             scala-compiler
32 |             2.8.0
33 |         
34 |         
35 |             org.scalatest
36 |             scalatest
37 |             1.2
38 |         
39 |     
40 |     
41 |         
42 |             
43 |                 org.scala-tools
44 |                 maven-scala-plugin
45 |                 
46 |                     
47 |                         compile
48 |                         
49 |                             compile
50 |                         
51 |                         compile
52 |                     
53 |                     
54 |                         test-compile
55 |                         
56 |                             testCompile
57 |                         
58 |                         test-compile
59 |                     
60 |                     
61 |                         process-resources
62 |                         
63 |                             compile
64 |                         
65 |                     
66 |                 
67 |             
68 |             
69 |                 com.jteigen
70 |                 maven-scalatest-plugin
71 |                 1.1-SNAPSHOT
72 |             
73 |         
74 |     
75 | 
76 | 


--------------------------------------------------------------------------------
/src/main/scala/com/log4p/sqldsl/AnsiSqlRenderer.scala:
--------------------------------------------------------------------------------
 1 | package com.log4p.sqldsl
 2 | 
 3 | case class SQL(val sql:String)
 4 | 
 5 | object AnsiSqlRenderer {
 6 |   implicit def query2sql(q:Query):SQL = SQL(sql(q))
 7 |   implicit def from2sql(f: From): SQL = SQL(sql(Query(f.operation.get, f, None)))
 8 | 
 9 |   def sql(query: Query): String = {
10 |     List(
11 |       expandOperation(query),
12 |       expandFrom(query),
13 |       expandWhere(query),
14 |       expandOrder(query)
15 |     )
16 |     .filter(_ != None)
17 |     .map(_ match {
18 |       case Some(s) => s
19 |       case s:String => s 
20 |     })
21 |     .mkString(" ")
22 |   }
23 | 
24 |   def expandOperation(query:Query):String = query.operation match {
25 |     case Select(field:String) => "select %s".format(field)
26 |     case s:Select => "select %s".format(s.fields.mkString(","))    
27 |     case _ => throw new IllegalArgumentException("Operation %s not implemented".format(query.operation))
28 |   }
29 | 
30 |   def expandFrom(query: Query) = "from %s".format(query.from.table)
31 |   def expandWhere(query: Query):Option[String] = {
32 |     if (query.where.isEmpty || query.where.get.clauses.isEmpty)
33 |       None
34 |     else
35 |       Option("where %s".format(query.where.get.clauses.map(expandClause(_)).mkString(" ")))
36 |   }
37 | 
38 |   def expandClause(clause: Clause): String = clause match {
39 |     case StringEquals(field, value) => "%s = %s".format(field, quote(value))
40 |     case BooleanEquals(field, value) => "%s = %s".format(field, value)
41 |     case NumberEquals(field, value) => "%s = %s".format(field, value)
42 |     case in:In => "%s in (%s)".format(in.field, in.values.map(quote(_)).mkString(","))
43 |     case and:And => "(%s and %s)".format(expandClause(and.lClause), expandClause(and.rClause))
44 |     case or:Or => "(%s or %s)".format(expandClause(or.lClause), expandClause(or.rClause))
45 |     case _ => throw new IllegalArgumentException("Clause %s not implemented".format(clause))
46 |   }
47 | 
48 |   def expandOrder(query: Query):Option[String] = query.order match {
49 |     case Some(direction) => direction match {
50 |       case Asc(field) => Option("order by %s asc".format(field))
51 |       case Desc(field) => Option("order by %s desc".format(field))
52 |     }
53 |     case None => None
54 |   }
55 | 
56 |   def quote(value: String) = "'%s'".format(escape(value))
57 |   def escape(value: String) = value.replaceAll("'", "''")
58 | }


--------------------------------------------------------------------------------
/src/main/scala/com/log4p/sqldsl/QueryBuilder.scala:
--------------------------------------------------------------------------------
 1 | package com.log4p.sqldsl
 2 | 
 3 | case class Query(val operation:Operation, val from: From, val where: Option[Where], val order: Option[Direction] = None) {
 4 |   def order(dir: Direction): Query = this.copy(order = Option(dir))
 5 | }
 6 | 
 7 | abstract class Operation {
 8 |   def from(table: String) = From(table, Option(this))
 9 | }
10 | case class Select(val fields:String*) extends Operation
11 | case class From(val table: String, val operation:Option[Operation] = None) {
12 |   def where(clauses: Clause*): Query = Query(operation.get, this, Option(Where(clauses: _*)))
13 | }
14 | 
15 | case class Where(val clauses: Clause*)
16 | 
17 | abstract class Clause {
18 |   def and(otherField: Clause): Clause = And(this, otherField)
19 |   def or(otherField: Clause): Clause = Or(this, otherField)
20 | }
21 | 
22 | case class StringEquals(val f: String, val value: String) extends Clause
23 | case class NumberEquals(val f: String, val value: Number) extends Clause
24 | case class BooleanEquals(val f: String, val value: Boolean) extends Clause
25 | case class In(val field: String, val values: String*) extends Clause
26 | case class And(val lClause:Clause, val rClause:Clause) extends Clause
27 | case class Or(val lClause:Clause, val rClause:Clause) extends Clause
28 | 
29 | abstract class Direction
30 | case class Asc(field: String) extends Direction
31 | case class Desc(field: String) extends Direction
32 | 
33 | object QueryBuilder {
34 |   implicit def tuple2field(t: (String, String)): StringEquals = StringEquals(t._1, t._2)
35 |   implicit def tuple2field(t: (String, Int)): NumberEquals = NumberEquals(t._1, t._2)
36 |   implicit def tuple2field(t: (String, Boolean)): BooleanEquals = BooleanEquals(t._1, t._2)
37 |   implicit def from2query(f: From): Query = Query(f.operation.get, f, Option(Where()))
38 | 
39 |   /** entrypoint for starting a select query */
40 |   def select(fields:String*) = Select(fields:_*)
41 |   def select(symbol: Symbol): Select = symbol match {
42 |     case 'all => select("*")
43 |     case _ => throw new RuntimeException("Only 'all allowed as symbol")
44 |   }
45 | 
46 |   def in(field: String, values: String*) = In(field, values: _*)
47 | }


--------------------------------------------------------------------------------
/src/main/scala/com/log4p/sqldsl/SQLParser.scala:
--------------------------------------------------------------------------------
 1 | package com.log4p.sqldsl
 2 | 
 3 | import scala.util.parsing.combinator._
 4 | import scala.util.parsing.combinator.syntactical._
 5 | 
 6 | class SQLParser extends JavaTokenParsers {
 7 | 
 8 |   def query:Parser[Query] = operation ~ from ~ opt(where) ~ opt(order) ^^ {
 9 |     case operation ~ from ~ where ~ order => Query(operation, from, where, order)
10 |   }
11 | 
12 |   def operation:Parser[Operation] = {
13 |     ("select" | "update" | "delete") ~ repsep(ident, ",") ^^ {
14 |       case "select" ~ f => Select(f:_*)
15 |       case _ => throw new IllegalArgumentException("Operation not implemented")
16 |     }
17 |   }
18 | 
19 |   def from:Parser[From] = "from" ~> ident ^^ (From(_))
20 | 
21 |   def where:Parser[Where] = "where" ~> rep(clause) ^^ (Where(_:_*))
22 | 
23 |   def clause:Parser[Clause] = (predicate|parens) * (
24 |             "and" ^^^ { (a:Clause, b:Clause) => And(a,b) } |
25 |             "or" ^^^ { (a:Clause, b:Clause) => Or(a,b) }
26 |           )
27 | 
28 |   def parens:Parser[Clause] = "(" ~> clause  <~ ")"
29 |   
30 |   def predicate = (
31 |       ident ~ "=" ~ boolean ^^ { case f ~ "=" ~ b => BooleanEquals(f,b)} 
32 |     | ident ~ "=" ~ stringLiteral ^^ { case f ~ "=" ~ v => StringEquals(f,stripQuotes(v))}
33 |     | ident ~ "=" ~ wholeNumber ^^ { case f ~ "=" ~ i => NumberEquals(f,i.toInt)}
34 | 
35 |   )
36 | 
37 |   def boolean = ("true" ^^^ (true) | "false" ^^^ (false))
38 | 
39 |   def order:Parser[Direction] = {
40 |     "order" ~> "by" ~> ident  ~ ("asc" | "desc") ^^ {
41 |       case f ~ "asc" => Asc(f)
42 |       case f ~ "desc" => Desc(f)
43 |     }
44 |   }
45 | 
46 |   def stripQuotes(s:String) = s.substring(1, s.length-1)
47 | 
48 |   def parse(sql:String):Option[Query] = {
49 |     parseAll(query, sql) match {
50 |       case Success(r, q) => Option(r)
51 |       case x => println(x); None
52 |     }
53 |   }
54 | }


--------------------------------------------------------------------------------
/src/test/scala/com/log4p/sqldsl/QueryBuilderSpec.scala:
--------------------------------------------------------------------------------
 1 | package com.log4p.sqldsl
 2 | 
 3 | import org.scalatest.matchers.ShouldMatchers
 4 | import org.scalatest.Spec
 5 | 
 6 | import QueryBuilder._
 7 | import AnsiSqlRenderer._
 8 | 
 9 | class QueryBuilderSpec extends Spec with ShouldMatchers {
10 | 
11 |   val SQL = QueryBuilder
12 | 
13 |   describe("A Query") {
14 |     describe("(when containing nested 'and' or 'or' clauses)") {
15 |       val q = SQL select 'all from ("user") where (("name","peter") and (("active", true) or ("role", "admin")))
16 |       
17 |       it("should contain parentheses at the correct locations in the resulting SQL") {
18 |         q.sql should be ("select * from user where (name = 'peter' and (active = true or role = 'admin'))")
19 |       }
20 |     }
21 |     describe("(when containing quotes in values)") {
22 |       val q = SQL select "*" from ("user") where (("name","p'eter"))
23 | 
24 |       it("should escape those quotes in the resulting SQL") {
25 |         q.sql should be ("select * from user where name = 'p''eter'")
26 |       }
27 |     }
28 |     describe("(when testing against numeric value)") {
29 |       val q = SQL select "*" from("user") where (("id", 100))
30 | 
31 |       it("shouldn't quote numbers in resulting SQL") {
32 |         q.sql should be ("select * from user where id = 100")
33 |       }
34 |     }
35 |     describe("(when containing 'in' clause)") {
36 |       val q = SQL select "*" from ("user") where (in("name","pe'ter","petrus"))
37 | 
38 |       it("should generate in-clause with escaped values in SQL") {
39 |         q.sql should be ("select * from user where name in ('pe''ter','petrus')")
40 |       }
41 |     }
42 |     describe("(when ordered)") {
43 |       val q = SQL select "*" from ("user") where (("name","peter")) order Desc("name")
44 | 
45 |       it("should generate order SQL") {
46 |         q.sql should be ("select * from user where name = 'peter' order by name desc")
47 |       }
48 |     }
49 |     describe("(when asked to select 'all)") {
50 |       val q = SQL select 'all from ("user") where (("id", 100))
51 |       
52 |       it("should select *") {
53 |         q.sql should be ("select * from user where id = 100")
54 |       }
55 |     }
56 |     describe("(when asked to select using another symbol)") {
57 |       it("should throw an exception, for now") {
58 |         evaluating { val q = select ('bla) from ("user") where (("id", 100)) } should produce[Throwable]
59 |       }
60 |     }
61 |     describe("(when no where clause is specified)") {
62 |       val q = SQL select 'all from ("user")
63 | 
64 |       it("should select all rows without filter") {
65 |         q.sql should be ("select * from user")
66 |       }
67 |     }
68 |     describe("(when a where clause is not, but an order clause is specified)") {
69 |       val q = SQL select 'all from ("user") order Asc("name")
70 | 
71 |       it("should select and order all rows without filter") {
72 |         q.sql should be ("select * from user order by name asc")
73 |       }
74 |     }
75 |     describe("(when specified with lots less parentheses)") {
76 |       val q = SQL select 'all from "user" where (("id", 100))
77 | 
78 |       it("should still work") {
79 |         q.sql should be ("select * from user where id = 100")
80 |       }
81 |     }
82 |   }
83 | }


--------------------------------------------------------------------------------
/src/test/scala/com/log4p/sqldsl/SQLParserSpec.scala:
--------------------------------------------------------------------------------
 1 | package com.log4p.sqldsl
 2 | 
 3 | import org.scalatest.matchers.ShouldMatchers
 4 | import org.scalatest.Spec
 5 | 
 6 | class SQLParserSpec extends Spec with ShouldMatchers {
 7 |   val p = new SQLParser
 8 |   describe("given a sql string with an order clause") {
 9 |     describe("(when direction is asc)") {
10 |       val sql = "select name from users order by name asc"
11 |       it("should be parsed into an Asc object containing the given field") {
12 |         val query = p.parse(sql).get
13 | 
14 |         query.operation should be (Select("name"))
15 |         query.from should be (From("users"))
16 |         query.order should be (Option(Asc("name")))
17 |       }
18 |     }
19 |   }
20 |   describe("given a sql string with a single where clause") {
21 |     describe("(when equals predicate with string literal)") {
22 |       val sql = "select name from users where name = \"peter\""
23 |       it("should be parsed into an StringEquals object containing the given field and value") {
24 |         println(sql)
25 |         val query = p.parse(sql).get
26 |         query.operation should be (Select("name"))
27 |         query.from should be (From("users"))
28 |         query.where.get.clauses.head should be (StringEquals("name","peter"))
29 |       }
30 |     }
31 |     describe("(when equals predicate with numeric literal)") {
32 |       val sql = """select age from users where age = 30"""
33 |       it("should be parsed into an NumberEquals object containing the given field and value") {
34 |         println(sql)
35 |         val query = p.parse(sql).get
36 |         query.operation should be (Select("age"))
37 |         query.from should be (From("users"))
38 |         query.where.get.clauses.head should be (NumberEquals("age",30))
39 |       }
40 |     }
41 |   }
42 |   describe("given a sql string with a combined where clause") {
43 |     describe("(when equals predicate contains and)") {
44 |       val sql = """select name from users where name = "peter" and age = 30"""
45 |       it("should be parsed into an And object containing to correct subclauses") {
46 |         println(sql)
47 |         val query = p.parse(sql).get
48 |         query.operation should be (Select("name"))
49 |         query.from should be (From("users"))
50 |         query.where.get.clauses.head should be (And(StringEquals("name","peter"), NumberEquals("age",30)))
51 |       }
52 |     }
53 |     describe("(when equals predicate contains or)") {
54 |       val sql = """select name from users where age = 20 or age = 30"""
55 |       it("should be parsed into an Or object containing to correct subclauses") {
56 |         println(sql)
57 |         val query = p.parse(sql).get
58 |         query.operation should be (Select("name"))
59 |         query.from should be (From("users"))
60 |         query.where.get.clauses.head should be (Or(NumberEquals("age",20), NumberEquals("age",30)))
61 |       }
62 |     }
63 |     describe("(when equals predicate contains multiple combined clauses)") {
64 |       val sql = """select name from users where name = "peter" and age = 20 or age = 30"""
65 |       it("should be parsed into an Or object containing and And object and and Equals predicate") {
66 |         println(sql)
67 |         val query = p.parse(sql).get
68 |         query.operation should be (Select("name"))
69 |         query.from should be (From("users"))
70 |         query.where.get.clauses.head should be (Or(And(StringEquals("name","peter"), NumberEquals("age",20)), NumberEquals("age",30)))
71 |         println(AnsiSqlRenderer.sql(query))
72 |       }
73 |     }
74 |     describe("(when equals predicate contains multiple combined clauses where the presedence is dictated by parens)") {
75 |       val sql = """select name,age from users where name = "peter" and (active = true or age = 30)"""
76 |       it("should be parsed into an Or object containing and And object and and Equals predicate") {
77 |         println(sql)
78 |         val query = p.parse(sql).get
79 |         query.operation should be (Select(List("name","age"):_*))
80 |         query.from should be (From("users"))
81 |         query.where.get.clauses.head should be (And(StringEquals("name","peter"), Or(BooleanEquals("active",true), NumberEquals("age",30))))
82 |         println(AnsiSqlRenderer.sql(query))
83 |       }
84 |     }
85 | 
86 |   }
87 | }


--------------------------------------------------------------------------------