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