Skip to content

Commit b225462

Browse files
authored
Support custom queries in builder (#403)
* initial implementation * fix single replacement * implement custom SQL method for OR builder * document where with SQL string * Tweak where documentation * Make custom where value optional * specify database placeholders * Add tests for raw SQL #where, #and, #or * Make SQL placeholders DB specific
1 parent af36d1e commit b225462

File tree

10 files changed

+127
-17
lines changed

10 files changed

+127
-17
lines changed

docs/querying.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ Post.where(:created_at, :gt, Time.local - 7.days)
1616

1717
Supported operators are :eq, :gteq, :lteq, :neq, :gt, :lt, :nlt, :ngt, :ltgt, :in, :nin, :like, :nlike
1818

19+
Alternatively, `#where`, `#and`, and `#or` accept a raw SQL clause, with an optional placeholder (`?` for MySQL/SQLite, `$` for Postgres) to avoid SQL Injection.
20+
```crystal
21+
# Example using Postgres adapter
22+
Post.where(:created_at, :gt, Time.local - 7.days)
23+
.where("LOWER(author_name) = $", name)
24+
.where("tags @> '{"Journal", "Book"}') # PG's array contains operator
25+
```
26+
This is useful for building more sophisticated queries, including queries dependent on database specific features not supported by the operators above. However, **clauses built with this method are not validated.**
27+
28+
1929
## Order
2030

2131
Order is using the QueryBuilder and supports providing an ORDER BY clause:

spec/granite/query/assemblers/mysql_spec.cr

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ require "../spec_helper"
7676
assembler.where
7777
assembler.numbered_parameters.should eq [] of Granite::Columns::Type
7878
end
79+
80+
it "handles raw SQL" do
81+
sql = "select #{query_fields} from table where name = 'bob' and age = ? and color = ? order by id desc"
82+
query = builder.where("name = 'bob'").where("age = ?", 23).where("color = ?", "red")
83+
query.raw_sql.should match ignore_whitespace sql
84+
85+
assembler = query.assembler
86+
assembler.where
87+
assembler.numbered_parameters.should eq [23, "red"]
88+
end
7989
end
8090

8191
context "order" do

spec/granite/query/assemblers/pg_spec.cr

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ require "../spec_helper"
7676
assembler.where
7777
assembler.numbered_parameters.should eq [] of Granite::Columns::Type
7878
end
79+
80+
it "handles raw SQL" do
81+
sql = "select #{query_fields} from table where name = 'bob' and age = $1 and color = $2 order by id desc"
82+
query = builder.where("name = 'bob'").where("age = $", 23).where("color = $", "red")
83+
query.raw_sql.should match ignore_whitespace sql
84+
85+
assembler = query.assembler
86+
assembler.where
87+
assembler.numbered_parameters.should eq [23, "red"]
88+
end
7989
end
8090

8191
context "order" do

spec/granite/query/assemblers/sqlite_spec.cr

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ require "../spec_helper"
7676
assembler.where
7777
assembler.numbered_parameters.should eq [] of Granite::Columns::Type
7878
end
79+
80+
it "handles raw SQL" do
81+
sql = "select #{query_fields} from table where name = 'bob' and age = ? and color = ? order by id desc"
82+
query = builder.where("name = 'bob'").where("age = ?", 23).where("color = ?", "red")
83+
query.raw_sql.should match ignore_whitespace sql
84+
85+
assembler = query.assembler
86+
assembler.where
87+
assembler.numbered_parameters.should eq [23, "red"]
88+
end
7989
end
8090

8191
context "order" do

spec/granite/query/builder_spec.cr

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,36 @@ describe Granite::Query::Builder(Model) do
4747
query = builder.offset(17)
4848
query.offset.should eq 17
4949
end
50+
51+
context "raw SQL builder" do
52+
placeholders = {
53+
Granite::Query::Builder::DbType::Mysql => "?",
54+
Granite::Query::Builder::DbType::Sqlite => "?",
55+
Granite::Query::Builder::DbType::Pg => "$",
56+
}
57+
58+
it "chains where statements" do
59+
placeholder = placeholders[builder.db_type]
60+
query = builder.where("name = #{placeholder}", "bob").where("age = #{placeholder}", 23)
61+
expected = [{join: :and, stmt: "name = #{placeholder}", value: "bob"}, {join: :and, stmt: "age = #{placeholder}", value: 23}]
62+
63+
query.where_fields.should eq expected
64+
end
65+
66+
it "chains and statements" do
67+
placeholder = placeholders[builder.db_type]
68+
query = builder.where("name = #{placeholder}", "bob").and("age = #{placeholder}", 23)
69+
expected = [{join: :and, stmt: "name = #{placeholder}", value: "bob"}, {join: :and, stmt: "age = #{placeholder}", value: 23}]
70+
71+
query.where_fields.should eq expected
72+
end
73+
74+
it "chains or statements" do
75+
placeholder = placeholders[builder.db_type]
76+
query = builder.where("name = #{placeholder}", "bob").or("age = #{placeholder}", 23)
77+
expected = [{join: :and, stmt: "name = #{placeholder}", value: "bob"}, {join: :or, stmt: "age = #{placeholder}", value: 23}]
78+
79+
query.where_fields.should eq expected
80+
end
81+
end
5082
end

src/granite/query/assemblers/base.cr

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module Granite::Query::Assembler
22
abstract class Base(Model)
3+
@placeholder : String = ""
34
@where : String?
45
@order : String?
56
@limit : String?
@@ -41,26 +42,40 @@ module Granite::Query::Assembler
4142
clauses = ["WHERE"]
4243

4344
@query.where_fields.each do |expression|
44-
add_aggregate_field expression[:field]
45-
4645
clauses << expression[:join].to_s.upcase unless clauses.size == 1
4746

48-
if expression[:value].nil?
49-
clauses << "#{expression[:field]} IS NULL"
50-
elsif expression[:value].is_a?(Array)
51-
in_stmt = String.build do |str|
52-
str << '('
53-
expression[:value].as(Array).each_with_index do |val, idx|
54-
str << '\'' if expression[:value].is_a?(Array(String))
55-
str << val
56-
str << '\'' if expression[:value].is_a?(Array(String))
57-
str << ',' if expression[:value].as(Array).size - 1 != idx
47+
if expression[:field]?.nil? # custom SQL
48+
expression = expression.as(NamedTuple(join: Symbol, stmt: String, value: Granite::Columns::Type))
49+
50+
if !expression[:value].nil?
51+
param_token = add_parameter expression[:value]
52+
clause = expression[:stmt].gsub(@placeholder, param_token)
53+
else
54+
clause = expression[:stmt]
55+
end
56+
57+
clauses << clause
58+
else # standard where query
59+
expression = expression.as(NamedTuple(join: Symbol, field: String, operator: Symbol, value: Granite::Columns::Type))
60+
add_aggregate_field expression[:field]
61+
62+
if expression[:value].nil?
63+
clauses << "#{expression[:field]} IS NULL"
64+
elsif expression[:value].is_a?(Array)
65+
in_stmt = String.build do |str|
66+
str << '('
67+
expression[:value].as(Array).each_with_index do |val, idx|
68+
str << '\'' if expression[:value].is_a?(Array(String))
69+
str << val
70+
str << '\'' if expression[:value].is_a?(Array(String))
71+
str << ',' if expression[:value].as(Array).size - 1 != idx
72+
end
73+
str << ')'
5874
end
59-
str << ')'
75+
clauses << "#{expression[:field]} #{sql_operator(expression[:operator])} #{in_stmt}"
76+
else
77+
clauses << "#{expression[:field]} #{sql_operator(expression[:operator])} #{add_parameter expression[:value]}"
6078
end
61-
clauses << "#{expression[:field]} #{sql_operator(expression[:operator])} #{in_stmt}"
62-
else
63-
clauses << "#{expression[:field]} #{sql_operator(expression[:operator])} #{add_parameter expression[:value]}"
6479
end
6580
end
6681

src/granite/query/assemblers/mysql.cr

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# This will likely require adapter specific subclassing :[.
33
module Granite::Query::Assembler
44
class Mysql(Model) < Base(Model)
5+
@placeholder = "?"
6+
57
def add_parameter(value : Granite::Columns::Type) : String
68
@numbered_parameters << value
79
"?"

src/granite/query/assemblers/pg.cr

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# This will likely require adapter specific subclassing :[.
33
module Granite::Query::Assembler
44
class Pg(Model) < Base(Model)
5+
@placeholder = "$"
6+
57
def add_parameter(value : Granite::Columns::Type) : String
68
@numbered_parameters << value
79
"$#{@numbered_parameters.size}"

src/granite/query/assemblers/sqlite.cr

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
module Granite::Query::Assembler
22
class Sqlite(Model) < Base(Model)
3+
@placeholder = "?"
4+
35
def add_parameter(value : Granite::Columns::Type) : String
46
@numbered_parameters << value
57
"?"

src/granite/query/builder.cr

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ class Granite::Query::Builder(Model)
2828
end
2929

3030
getter db_type : DbType
31-
getter where_fields = [] of NamedTuple(join: Symbol, field: String, operator: Symbol, value: Granite::Columns::Type)
31+
getter where_fields = [] of (NamedTuple(join: Symbol, field: String, operator: Symbol, value: Granite::Columns::Type) |
32+
NamedTuple(join: Symbol, stmt: String, value: Granite::Columns::Type))
3233
getter order_fields = [] of NamedTuple(field: String, direction: Sort)
3334
getter group_fields = [] of NamedTuple(field: String)
3435
getter offset : Int64?
@@ -70,6 +71,10 @@ class Granite::Query::Builder(Model)
7071
and(field: field.to_s, operator: operator, value: value)
7172
end
7273

74+
def where(stmt : String, value : Granite::Columns::Type = nil)
75+
and(stmt: stmt, value: value)
76+
end
77+
7378
def and(**matches)
7479
and(matches)
7580
end
@@ -88,6 +93,12 @@ class Granite::Query::Builder(Model)
8893
self
8994
end
9095

96+
def and(stmt : String, value : Granite::Columns::Type = nil)
97+
@where_fields << {join: :and, stmt: stmt, value: value}
98+
99+
self
100+
end
101+
91102
def or(**matches)
92103
or(matches)
93104
end
@@ -106,6 +117,12 @@ class Granite::Query::Builder(Model)
106117
self
107118
end
108119

120+
def or(stmt : String, value : Granite::Columns::Type = nil)
121+
@where_fields << {join: :or, stmt: stmt, value: value}
122+
123+
self
124+
end
125+
109126
def order(field : Symbol)
110127
@order_fields << {field: field.to_s, direction: Sort::Ascending}
111128

0 commit comments

Comments
 (0)