From 89f7aa405a0d66a709c5a88f1a289f75ce804c16 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 30 Nov 2012 07:38:51 -0800 Subject: [PATCH] Start work on SQL compilation - add SQL builder module and AST node classes - add .toBeLikeQuery matcher --- lib/server/index.coffee | 5 +- lib/server/relations/relation.coffee | 5 ++ lib/server/sql/binary_operator.coffee | 11 +++ lib/server/sql/builder.coffee | 75 ++++++++++++++++++ lib/server/sql/column.coffee | 7 ++ lib/server/sql/difference.coffee | 7 ++ lib/server/sql/join_table_ref.coffee | 13 ++++ lib/server/sql/literal.coffee | 7 ++ lib/server/sql/order_by_expression.coffee | 9 +++ lib/server/sql/query.coffee | 37 +++++++++ lib/server/sql/string_literal.coffee | 5 ++ lib/server/sql/table_ref.coffee | 8 ++ lib/server/sql/union.coffee | 7 ++ spec/server/spec_helper.coffee | 10 +++ spec/server/sql/builder_spec.coffee | 95 +++++++++++++++++++++++ 15 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 lib/server/relations/relation.coffee create mode 100644 lib/server/sql/binary_operator.coffee create mode 100644 lib/server/sql/builder.coffee create mode 100644 lib/server/sql/column.coffee create mode 100644 lib/server/sql/difference.coffee create mode 100644 lib/server/sql/join_table_ref.coffee create mode 100644 lib/server/sql/literal.coffee create mode 100644 lib/server/sql/order_by_expression.coffee create mode 100644 lib/server/sql/query.coffee create mode 100644 lib/server/sql/string_literal.coffee create mode 100644 lib/server/sql/table_ref.coffee create mode 100644 lib/server/sql/union.coffee create mode 100644 spec/server/sql/builder_spec.coffee diff --git a/lib/server/index.coffee b/lib/server/index.coffee index 9f53133..30d99dd 100644 --- a/lib/server/index.coffee +++ b/lib/server/index.coffee @@ -7,8 +7,11 @@ loader.configure( globals: { Monarch, _ } ) -Monarch.Sql = {} +_.extend Monarch, + Sql: {} + resourceUrlSeparator: '_' +loader.require('./sql/literal') loader.requireTree('.') module.exports = Monarch diff --git a/lib/server/relations/relation.coffee b/lib/server/relations/relation.coffee new file mode 100644 index 0000000..cdb2fe9 --- /dev/null +++ b/lib/server/relations/relation.coffee @@ -0,0 +1,5 @@ +module.exports = ({ Monarch, _ }) -> + + _.extend Monarch.Relations.Relation.prototype, + toSql: -> + (new Monarch.Sql.Builder).visit(this).toSql() diff --git a/lib/server/sql/binary_operator.coffee b/lib/server/sql/binary_operator.coffee new file mode 100644 index 0000000..4259e30 --- /dev/null +++ b/lib/server/sql/binary_operator.coffee @@ -0,0 +1,11 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.BinaryOperator + constructor: (@left, @right, @operator) -> + + toSql: -> + [ + @left.toSql(), + @operator, + @right.toSql() + ].join(' ') diff --git a/lib/server/sql/builder.coffee b/lib/server/sql/builder.coffee new file mode 100644 index 0000000..3108fa6 --- /dev/null +++ b/lib/server/sql/builder.coffee @@ -0,0 +1,75 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.Builder + visit: Monarch.Util.Visitor.visit + + visit_Relations_Table: (r) -> + new Monarch.Sql.Query( + new Monarch.Sql.TableRef(r.resourceName())) + + visit_Relations_Selection: (r) -> + _.tap(@visit(r.operand), (query) => + query.condition = @visit(r.predicate)) + + visit_Relations_OrderBy: (r) -> + _.tap(@visit(r.operand), (query) => + query.orderByExpressions = r.orderByExpressions.map (e) => @visit(e)) + + visit_Relations_Limit: (r) -> + _.tap(@visit(r.operand), (query) => + query.limitCount = r.count) + + visit_Relations_Offset: (r) -> + _.tap(@visit(r.operand), (query) => + query.offsetCount = r.count) + + visit_Relations_Union: (r) -> + new Monarch.Sql.Union(@visit(r.left), @visit(r.right)) + + visit_Relations_Difference: (r) -> + new Monarch.Sql.Difference(@visit(r.left), @visit(r.right)) + + visit_Relations_InnerJoin: (r) -> + leftQuery = @visit(r.left) + rightQuery = @visit(r.right) + condition = @visit(r.predicate) + + new Monarch.Sql.Query( + new Monarch.Sql.JoinTableRef( + leftQuery.tableRef, + rightQuery.tableRef, + condition + )) + + visit_Expressions_And: (e) -> + visitBinaryOperator.call(this, e, "AND") + + visit_Expressions_Equal: (e) -> + visitBinaryOperator.call(this, e, "=") + + visit_Expressions_Column: (e) -> + new Monarch.Sql.Column(e.table.resourceName(), e.resourceName()) + + visit_Expressions_OrderBy: (e) -> + new Monarch.Sql.OrderByExpression( + e.column.table.resourceName(), + e.column.resourceName(), + directionString(e.directionCoefficient)) + + visit_String: (e) -> + new Monarch.Sql.StringLiteral(e) + + visit_Boolean: (e) -> + new Monarch.Sql.Literal(e) + + visit_Number: (e) -> + new Monarch.Sql.Literal(e) + + visitBinaryOperator = (e, operator) -> + new Monarch.Sql.BinaryOperator( + @visit(e.left), + @visit(e.right), + operator) + + directionString = (coefficient) -> + if (coefficient == -1) then 'DESC' else 'ASC' diff --git a/lib/server/sql/column.coffee b/lib/server/sql/column.coffee new file mode 100644 index 0000000..4644d4c --- /dev/null +++ b/lib/server/sql/column.coffee @@ -0,0 +1,7 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.Column + constructor: (@tableName, @name) -> + + toSql: -> + """ "#{@tableName}"."#{@name}" """ diff --git a/lib/server/sql/difference.coffee b/lib/server/sql/difference.coffee new file mode 100644 index 0000000..cde17ac --- /dev/null +++ b/lib/server/sql/difference.coffee @@ -0,0 +1,7 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.Difference + constructor: (@left, @right) -> + + toSql: -> + [@left.toSql(), "EXCEPT", @right.toSql()].join(' ') diff --git a/lib/server/sql/join_table_ref.coffee b/lib/server/sql/join_table_ref.coffee new file mode 100644 index 0000000..23b2120 --- /dev/null +++ b/lib/server/sql/join_table_ref.coffee @@ -0,0 +1,13 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.JoinTableRef + constructor: (@left, @right, @condition) -> + + toSql: -> + [ + @left.toSql(), + "INNER JOIN", + @right.toSql(), + "ON", + @condition.toSql() + ].join(' ') diff --git a/lib/server/sql/literal.coffee b/lib/server/sql/literal.coffee new file mode 100644 index 0000000..d67623a --- /dev/null +++ b/lib/server/sql/literal.coffee @@ -0,0 +1,7 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.Literal + constructor: (@value) -> + + toSql: -> + @value.toString() diff --git a/lib/server/sql/order_by_expression.coffee b/lib/server/sql/order_by_expression.coffee new file mode 100644 index 0000000..a655005 --- /dev/null +++ b/lib/server/sql/order_by_expression.coffee @@ -0,0 +1,9 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.OrderByExpression + constructor: (@tableName, @columnName, @directionString) -> + + toSql: -> + """ + "#{@tableName}"."#{@columnName}" #{@directionString} + """ diff --git a/lib/server/sql/query.coffee b/lib/server/sql/query.coffee new file mode 100644 index 0000000..eb5d723 --- /dev/null +++ b/lib/server/sql/query.coffee @@ -0,0 +1,37 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.Query + constructor: (@tableRef) -> + @condition = null + @orderByExpressions = [] + + toSql: -> + _.compact([ + @selectClauseSql(), + @fromClauseSql(), + @whereClauseSql(), + @orderByClauseSql(), + @limitClauseSql(), + @offsetClauseSql() + ]).join(' ') + + selectClauseSql: -> + "SELECT *" + + fromClauseSql: -> + "FROM " + @tableRef.toSql() + + whereClauseSql: -> + "WHERE " + @condition.toSql() if @condition + + orderByClauseSql: -> + if not _.isEmpty(@orderByExpressions) + "ORDER BY " + @orderByExpressions.map((e) -> e.toSql()).join(', ') + + limitClauseSql: -> + if @limitCount + "LIMIT " + @limitCount + + offsetClauseSql: -> + if @offsetCount + "OFFSET " + @offsetCount diff --git a/lib/server/sql/string_literal.coffee b/lib/server/sql/string_literal.coffee new file mode 100644 index 0000000..5726e7b --- /dev/null +++ b/lib/server/sql/string_literal.coffee @@ -0,0 +1,5 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.StringLiteral extends Monarch.Sql.Literal + toSql: -> + "'" + super + "'" diff --git a/lib/server/sql/table_ref.coffee b/lib/server/sql/table_ref.coffee new file mode 100644 index 0000000..d70cb76 --- /dev/null +++ b/lib/server/sql/table_ref.coffee @@ -0,0 +1,8 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.TableRef + constructor: (@tableName) -> + + toSql: -> + '"' + @tableName + '"' + diff --git a/lib/server/sql/union.coffee b/lib/server/sql/union.coffee new file mode 100644 index 0000000..06f4c5a --- /dev/null +++ b/lib/server/sql/union.coffee @@ -0,0 +1,7 @@ +module.exports = ({ Monarch, _ }) -> + + class Monarch.Sql.Union + constructor: (@left, @right) -> + + toSql: -> + [@left.toSql(), "UNION", @right.toSql()].join(' ') diff --git a/spec/server/spec_helper.coffee b/spec/server/spec_helper.coffee index d5b0cc7..8aaad39 100644 --- a/spec/server/spec_helper.coffee +++ b/spec/server/spec_helper.coffee @@ -1,3 +1,13 @@ +beforeEach -> + @addMatchers + toBeLikeQuery: (sql) -> + normalizeSql(@actual) == normalizeSql(sql) + +normalizeSql = (string) -> + string + .replace(/\s+/g, ' ') + .replace(/[(\s*$)]/g, '') + module.exports = Monarch: require "#{__dirname}/../../lib/server/index" _: require "underscore" diff --git a/spec/server/sql/builder_spec.coffee b/spec/server/sql/builder_spec.coffee new file mode 100644 index 0000000..88629d5 --- /dev/null +++ b/spec/server/sql/builder_spec.coffee @@ -0,0 +1,95 @@ +{ Monarch } = require "../spec_helper" + +describe "Sql.Builder", -> + blogs = blogPosts = null + + class Blog extends Monarch.Record + @extended(this) + @columns + public: 'boolean' + title: 'string' + authorId: 'integer' + + class BlogPost extends Monarch.Record + @extended(this) + @columns + public: 'boolean' + blogId: 'integer' + title: 'string' + + beforeEach -> + blogs = Blog.table + blogPosts = BlogPost.table + + describe "tables", -> + it "constructs a table query", -> + expect(blogPosts.toSql()).toBeLikeQuery(""" + SELECT * FROM "blog_posts" + """) + + describe "selections", -> + it "constructs a query with the right WHERE clause", -> + relation = blogPosts.where({ public: true, blogId: 1 }) + expect(relation.toSql()).toBeLikeQuery(""" + SELECT * FROM "blog_posts" + WHERE "blog_posts"."public" = true AND "blog_posts"."blog_id" = 1 + """) + + it "quotes string literals correctly", -> + relation = blogPosts.where({ title: "Node Fibers and You" }) + expect(relation.toSql()).toBeLikeQuery(""" + SELECT * FROM "blog_posts" + WHERE "blog_posts"."title" = 'Node Fibers and You' + """) + + describe "orderings", -> + it "constructs a query with a valid order by clause", -> + relation = blogPosts.orderBy("title desc") + expect(relation.toSql()).toBeLikeQuery(""" + SELECT * FROM "blog_posts" + ORDER BY "blog_posts"."title" DESC, "blog_posts"."id" ASC + """) + + describe "limits", -> + it "constructs a limit query", -> + relation = blogPosts.limit(5) + expect(relation.toSql()).toBeLikeQuery(""" + SELECT * FROM "blog_posts" LIMIT 5 + """) + + it "constructs a limit query with an offset", -> + relation = blogPosts.limit(5, 2) + expect(relation.toSql()).toBeLikeQuery(""" + SELECT * FROM "blog_posts" LIMIT 5 OFFSET 2 + """) + + describe "unions", -> + it "constructs a set union query", -> + left = blogPosts.where(blogId: 5) + right = blogPosts.where(public: true) + relation = left.union(right) + expect(relation.toSql()).toBeLikeQuery(""" + SELECT * FROM "blog_posts" WHERE "blog_posts"."blog_id" = 5 + UNION + SELECT * FROM "blog_posts" WHERE "blog_posts"."public" = true + """) + + describe "differences", -> + it "constructs a set difference query", -> + left = blogPosts.where(blogId: 5) + right = blogPosts.where(public: true) + relation = left.difference(right) + expect(relation.toSql()).toBeLikeQuery(""" + SELECT * FROM "blog_posts" WHERE "blog_posts"."blog_id" = 5 + EXCEPT + SELECT * FROM "blog_posts" WHERE "blog_posts"."public" = true + """) + + describe "joins", -> + it "constructs a join query", -> + relation = blogs.join(blogPosts) + expect(relation.toSql()).toBeLikeQuery(""" + SELECT * FROM "blogs" + INNER JOIN "blog_posts" + ON "blogs"."id" = "blog_posts"."blog_id" + """)