Skip to content

Commit

Permalink
- Adding support for range with in? operator
Browse files Browse the repository at this point in the history
- Adding support for `window` clause (not yet tested)
- Improving node#resolve method implementation using tuple instead of
string interpolation
- Do some refactoring in the SelectBuilder class.
  • Loading branch information
Yacine Petitprez committed Jun 27, 2018
1 parent 888e2bc commit 127d0f8
Show file tree
Hide file tree
Showing 24 changed files with 253 additions and 133 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
- Add JSONB helpers
- Migrating the wiki to the sources of the project, to make easy to have PR for
updating the documentation !
- Add range support for `Sql::Node#in?` method:
```crystal
last_week_users = User.query.where{ created_at.in?(7.day.ago..Time.now) }.count
```
- Refactoring of the nodes and clause of the SQL builder, avoiding array instantiation (usage of tuple instead) and starting to use Node as much as possible to build clauses.

# v0.1.3alpha

Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,19 @@

[![Build Status](https://travis-ci.org/anykeyh/clear.svg?branch=master)](https://travis-ci.org/anykeyh/clear) [![Docs](https://img.shields.io/badge/docs-available-brightgreen.svg)](https://anykeyh.github.io/clear/) [![GitHub release](https://img.shields.io/github/release/anykeyh/clear.svg)](https://github.com/anykeyh/clear/releases)

Clear is an ORM built for PostgreSQL and Crystal.
Clear is an ORM built specifically for PostgreSQL in Crystal.
It follows Active Record design pattern, with emphasis on readability and
a coverage of postgres advanced features like jsonb.

## Getting started

- [Please check the manual which cover most of the library here](https://github.com/anykeyh/clear/tree/master/manual)
- [API Documentations](https://anykeyh.github.io/clear/)
- [Source Code](https://github.com/anykeyh/clear)
- [A simple example is available here](https://github.com/anykeyh/clear/blob/master/sample/wiki/getting_started.cr)

- [Changelog](https://github.com/anykeyh/clear/blob/master/CHANGELOG.md)

## Resources

|Resources| |
|---|---|
Expand Down Expand Up @@ -717,7 +727,7 @@ This shard is provided under the MIT license.

## Contribute

All contributions are welcome ! As a specialized ORM for postgreSQL,
All contributions are welcome ! As a specialized ORM for PostgreSQL,
be sure a great contribution on a very specific PG feature will be incorporated
to this shard.
I hope one day we will cover all the features of PG here !
2 changes: 1 addition & 1 deletion manual/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Welcome to the Clear!
Welcome to Clear!

Clear is currently in alpha stage, but already used by the author in some applications.
A lot of features have yet to be discovered, don't hesitate to make a pull request if something is impossible now, so I'll fix it.
Expand Down
35 changes: 32 additions & 3 deletions spec/sql/select_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ module SelectSpec
context "using simple engine" do
it "can use simple equals" do
r = select_request.from(:users).where({user_id: 1})
r.to_sql.should eq "SELECT * FROM users WHERE user_id = 1"
r.to_sql.should eq "SELECT * FROM users WHERE (user_id = 1)"
end

it "can use `in` operators in case of array" do
Expand All @@ -152,9 +152,20 @@ module SelectSpec
r.to_sql.should eq "SELECT * FROM users WHERE a = b"
end

it "manages ranges" do
select_request.from(:users).where({x: 1..4}).to_sql
.should eq "SELECT * FROM users WHERE (x >= 1 AND x <= 4)"

select_request.from(:users).where({x: 1...4}).to_sql
.should eq "SELECT * FROM users WHERE (x >= 1 AND x < 4)"
end

it "can prepare query" do
r = select_request.from(:users).where("a LIKE ?", ["hello"])
r.to_sql.should eq "SELECT * FROM users WHERE a LIKE 'hello'"

r = select_request.from(:users).where("a LIKE ?", {"hello"})
r.to_sql.should eq "SELECT * FROM users WHERE a LIKE 'hello'"
end

it "raises exception with prepared query" do
Expand Down Expand Up @@ -235,12 +246,12 @@ module SelectSpec

it "can use subquery into where clause" do
r = select_request.from(:users).where { users.id.in?(complex_query.clear_select.select(:id)) }
r.to_sql.should eq "SELECT * FROM users WHERE users.id IN ( " +
r.to_sql.should eq "SELECT * FROM users WHERE users.id IN (" +
"SELECT id FROM users INNER JOIN role_users ON " +
"((role_users.user_id = users.id)) INNER JOIN roles" +
" ON ((role_users.role_id = roles.id)) WHERE role IN" +
" ('admin', 'superadmin') ORDER BY priority DESC, " +
"name ASC LIMIT 50 OFFSET 50 )"
"name ASC LIMIT 50 OFFSET 50)"
end

it "can build locks" do
Expand All @@ -265,6 +276,24 @@ module SelectSpec
r = select_request.from(:users).where { raw("users.id").in?([1, 2, 3, 4]) }
r.to_sql.should eq "SELECT * FROM users WHERE users.id IN (1, 2, 3, 4)"
end

it "can check presence into range" do
# Simple number
select_request.from(:users).where { users.id.in?(1..3) }.to_sql
.should eq "SELECT * FROM users WHERE (users.id >= 1 AND users.id <= 3)"

# Date range.
range = 2.day.ago..1.day.ago

select_request.from(:users).where { created_at.in?(range) }.to_sql
.should eq "SELECT * FROM users WHERE " +
"(created_at >= #{Clear::Expression[range.begin]} AND" +
" created_at <= #{Clear::Expression[range.end]})"

# Exclusive range
select_request.from(:users).where { users.id.in?(1...3) }.to_sql
.should eq "SELECT * FROM users WHERE (users.id >= 1 AND users.id < 3)"
end
end
end

Expand Down
43 changes: 20 additions & 23 deletions src/clear/expression/expression.cr
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ class Clear::Expression
end

def self.safe_literal(x : String) : String
"'" + x.gsub(/\'/, "''") + "'"
{"'", x.gsub('\'', "''"), "'"}.join
end

def self.safe_literal(x : ::Clear::SQL::SelectBuilder)
"(#{x.to_sql})"
{"(", x.to_sql, ")"}
end

#
Expand All @@ -105,7 +105,7 @@ class Clear::Expression
# Clear::Expression[Time.now, date: true] # < "2017-04-03"
# ```
def self.safe_literal(x : Time, date : Bool = false) : String
"'" + x.to_s(date ? DATABASE_DATE_FORMAT : DATABASE_DATE_TIME_FORMAT) + "'"
{"'", x.to_s(date ? DATABASE_DATE_FORMAT : DATABASE_DATE_TIME_FORMAT), "'"}.join
end

def self.safe_literal(x : Bool) : String
Expand All @@ -124,25 +124,22 @@ class Clear::Expression
self.safe_literal(x.to_s)
end

def self.to_node(node) : Node
case node
when Node
node
when Bool
# UPDATE: Having precomputed boolean return is
# probably a mistake using the Expression engine
# It is advisable to raise an error in this case,
# because a developer mistake can create a boolean where he doesn't want to.
raise ArgumentError.new("The expression engine discovered a runtime-evaluable condition.\n" +
"It happens when a test is done with values on both sides.\n" +
"Maybe a local variable is breaking the expression engine like here:\n" +
"id = 1\n" +
"Users.where{ id == nil }\n\n" +
"In this case, please use `raw(\"id IS NULL\")` to allow the expression.")
# node = Node::Variable.new(node ? "TRUE" : "FALSE")
else
raise ArgumentError.new("Node is incorrect, it must be an ExpressionNode")
end
def self.ensure_node!(bool : Bool)
# UPDATE: Having precomputed boolean return is
# probably a mistake using the Expression engine
# It is advisable to raise an error in this case,
# because a developer mistake can create a boolean where he doesn't want to.
{% raise \
"The expression engine discovered a runtime-evaluable condition.\n" +
"It happens when a test is done with values on both sides.\n" +
"Maybe a local variable is breaking the expression engine like here:\n" +
"id = 1\n" +
"Users.where{ id == nil }\n\n" +
"In this case, please use `raw(\"id IS NULL\")` to allow the expression." %}
end

def self.ensure_node!(node : Node) : Node
node
end

# Return a node of the expression engine
Expand All @@ -152,7 +149,7 @@ class Clear::Expression
def self.where(&block) : Node
expression_engine = self.new

to_node(with expression_engine yield)
ensure_node!(with expression_engine yield)
end

# Not operator
Expand Down
9 changes: 7 additions & 2 deletions src/clear/expression/nodes/between.cr
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ class Clear::Expression::Node::Between < Clear::Expression::Node
end

def resolve
"(#{@target.resolve} BETWEEN " +
"#{Clear::Expression.safe_literal(@starts)} AND #{Clear::Expression.safe_literal(@ends)})"
{"(",
@target.resolve,
" BETWEEN ",
Clear::Expression.safe_literal(@starts),
" AND ",
Clear::Expression.safe_literal(@ends),
")"}.join
end
end
2 changes: 1 addition & 1 deletion src/clear/expression/nodes/double_operator.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ class Clear::Expression::Node::DoubleOperator < Clear::Expression::Node
def initialize(@a : Node, @b : Node, @op : String); end

def resolve
"(#{@a.resolve} #{@op} #{@b.resolve})"
{"(", @a.resolve, " ", @op, " ", @b.resolve, ")"}.join
end
end
6 changes: 3 additions & 3 deletions src/clear/expression/nodes/in_array.cr
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
require "./node"

class Clear::Expression::Node::InArray < Clear::Expression::Node
def initialize(@target : Node, @array : Array(Literal)); end
def initialize(@target : Node, @array : Array(String)); end

def resolve
if @array.size == 0
"FALSE" # Cannot be in empty :o
"FALSE" # If array is empty, return "FALSE" expression
else
"#{@target.resolve} IN (#{@array.map { |x| x.resolve }.join(", ")})"
{@target.resolve, " IN (", @array.join(", "), ")"}.join
end
end
end
12 changes: 12 additions & 0 deletions src/clear/expression/nodes/in_range.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
require "./node"

class Clear::Expression::Node::InRange < Clear::Expression::Node
def initialize(@target : Node, @range : Range(String, String), @exclusive = false); end

def resolve
rt = @target.resolve
final_op = @exclusive ? " < " : " <= "

{"(", rt, " >= ", @range.begin, " AND ", rt, final_op, @range.end, ")"}.join
end
end
2 changes: 1 addition & 1 deletion src/clear/expression/nodes/in_select.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ class Clear::Expression::Node::InSelect < Clear::Expression::Node
def initialize(@target : Node, @select : Clear::SQL::SelectBuilder); end

def resolve
"#{@target.resolve} IN ( #{@select.to_sql} )"
{@target.resolve, " IN (", @select.to_sql, ")"}.join
end
end
2 changes: 1 addition & 1 deletion src/clear/expression/nodes/minus.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ class Clear::Expression::Node::Minus < Clear::Expression::Node
def initialize(@a : Node); end

def resolve
"-#{@a.resolve}"
{"-", @a.resolve}.join
end
end
8 changes: 7 additions & 1 deletion src/clear/expression/nodes/node.cr
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ abstract class Clear::Expression::Node
define_operator("&", "AND")
define_operator("|", "OR")

def in?(range : Range(B, E)) forall B, E
Node::InRange.new(self,
Clear::Expression[range.begin]..Clear::Expression[range.end],
range.exclusive?)
end

def in?(arr : Array(T)) forall T
Node::InArray.new(self, arr.map { |x| Literal.new(x) })
Node::InArray.new(self, arr.map { |x| Clear::Expression[x] })
end

def in?(tuple : Tuple(*T)) forall T
Expand Down
2 changes: 1 addition & 1 deletion src/clear/expression/nodes/not.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ class Clear::Expression::Node::Not < Clear::Expression::Node
def initialize(@a : Node); end

def resolve
"NOT #{@a.resolve}"
{"NOT ", @a.resolve}.join
end
end
10 changes: 9 additions & 1 deletion src/clear/expression/nodes/not_between.cr
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ class Clear::Expression::Node::NotBetween < Clear::Expression::Node
end

def resolve
"(#{@target.resolve} NOT BETWEEN #{Clear::Expression.safe_literal(@starts)} AND #{Clear::Expression.safe_literal(@ends)})"
{
"(",
@target.resolve,
" NOT BETWEEN ",
Clear::Expression[@starts],
" AND ",
Clear::Expression[@ends],
")",
}.join
end
end
2 changes: 1 addition & 1 deletion src/clear/expression/nodes/variable.cr
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class Clear::Expression::Node::Variable < Clear::Expression::Node
def resolve
parent = @parent
if parent
[parent.resolve, @name].join(".")
{parent.resolve, ".", @name}.join
else # nil
@name
end
Expand Down
10 changes: 6 additions & 4 deletions src/clear/model/collection.cr
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,21 @@ module Clear::Model

# :nodoc:
def initialize(
@cte = {} of String => Clear::SQL::Query::CTE::CTEAuthorized,
@is_distinct = false,
@cte = {} of String => Clear::SQL::SelectBuilder | String,
@columns = [] of SQL::Column,
@froms = [] of SQL::From,
@joins = [] of SQL::Join,
@wheres = [] of Clear::Expression::Node,
@havings = [] of Clear::Expression::Node,
@windows = [] of {String, String},
@group_bys = [] of String,
@order_bys = [] of Clear::SQL::Query::OrderBy::Record,
@limit = nil,
@offset = nil,
@lock = nil,
@tags = {} of String => Clear::SQL::Any,
@before_query_triggers = [] of -> Void,
@tags = {} of String => Clear::SQL::Any,
@cache = Clear::Model::QueryCache.new,
@cached_result = nil
)
Expand Down Expand Up @@ -199,7 +201,7 @@ module Clear::Model

# A convenient way to write `where{ condition }.first`
def find(fetch_columns, &block) : T?
x = Clear::Expression.to_node(with Clear::Expression.new yield)
x = Clear::Expression.ensure_node!(with Clear::Expression.new yield)
where(x).first(fetch_columns)
end

Expand All @@ -210,7 +212,7 @@ module Clear::Model

# A convenient way to write `where({any_column: "any_value"}).first!`
def find!(fetch_columns = false, &block) : T
x = Clear::Expression.to_node(with Clear::Expression.new yield)
x = Clear::Expression.ensure_node!(with Clear::Expression.new yield)
where(x).first!(fetch_columns)
end

Expand Down
4 changes: 2 additions & 2 deletions src/clear/sql/jsonb/jsonb.cr
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ module Clear::SQL::JSONB
# jsonb_eq("data.sub.key", "value")
# ```
#
# => `data @> '{"sub": {key: "value"}}' `
# => `data @> '{"sub": {"key": "value"}}' `
def jsonb_eq(key, value)
arr = jsonb_k2a(key)

if arr.size == 1
return [arr[0], Clear::Expression[value]].join("=")
return [arr[0], Clear::Expression[value]].join(" = ")
else
return [arr[0], Clear::Expression[jsonb_arr2h(arr[1..-1], value).to_json]].join(" @> ")
end
Expand Down
Loading

0 comments on commit 127d0f8

Please sign in to comment.