Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement raw subqueries #230

Merged
merged 16 commits into from
Jun 14, 2024
20 changes: 14 additions & 6 deletions docs/docs/models-and-databases/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,19 @@ By default, filters involving multiple parameters like in the above examples alw
```crystal
# Get Author records with either "Bob" or "Alice" as first name
Author.filter { q(first_name: "Bob") | q(first_name: "Alice") }

# Get Author records whose first names are not "John"
Author.filter { -q(first_name: "Alice") }
```

Marten also has the option to filter query sets using [raw SQL statements](./raw-sql#filtering-with-raw-sql-subqueries). This is useful when you want to leverage the flexibility of SQL for specific conditions, but still want Marten to handle the column selection and query building for the rest of the query.

```crystal
Author.filter("first_name = :first_name", first_name: "John")
Author.filter("first_name = ?", "John")
Author.filter { q("first_name = :first_name", first_name: "John") }
```

### Excluding specific records

Excluding records is achieved through the use of the `#exclude` method. This method provides exactly the same API as the [`#filter`](#filtering-specific-records) method outlined previously. It requires one or many predicate keyword arguments (in the format described in [Field predicates](#field-predicates)). For example:
Expand Down Expand Up @@ -252,15 +260,15 @@ Article.filter { q(title__startswith: "Top") | q(title__startswith: "10") }
Using this approach, it is possible to produce complex conditions by combining `q()` expressions with the `&`, `|`, and `-` operators. Parentheses can also be used to group statements:

```crystal
Article.filter {
Article.filter {
(q(title__startswith: "Top") | q(title__startswith: "10")) & -q(author__first_name: "John")
}
```

Finally it should be noted that you can define many field predicates _inside_ `q()` expressions. When doing so, the field predicates will be "AND"ed together:

```crystal
Article.filter {
Article.filter {
q(title__startswith: "Top") & -q(author__first_name: "John", author__last_name: "Doe")
}
```
Expand Down Expand Up @@ -302,15 +310,15 @@ It is also possible to explicitly define that a specific query set must "join" a
```crystal
author_1 = Author.filter(first_name: "John")
puts author_1.hometown # DB hit to retrieve the associated City record

author_2 = Author.join(:hometown).filter(first_name: "John")
puts author_2.hometown # No additional DB hit
```

The double underscores notations can also be used in the context of joins. For example:

```crystal
# The associated Author and City records will be selected and fully initialized
# The associated Author and City records will be selected and fully initialized
# with the selected Article record.
Article.join(:author__hometown).get(id: 42)
```
Expand Down Expand Up @@ -349,7 +357,7 @@ puts posts_2[0].tags.to_a
The double underscores notations can also be used when pre-fetching relations. In this situation, the records targeted by the original query set will be decorated with the prefetched records, and those records will be decorated with the following prefetched records. For example:

```crystal
# The associated Book and BookGenres records will be pre-fetched and fully initialized
# The associated Book and BookGenres records will be pre-fetched and fully initialized
# at the Author and Book records levels.
Author.prefetch(:books__genres)
```
Expand Down
42 changes: 39 additions & 3 deletions docs/docs/models-and-databases/raw-sql.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ And the following one uses named parameters:

```crystal
Article.raw(
"SELECT * FROM articles WHERE title = :title and created_at > :created_at",
title: "Hello World!",
"SELECT * FROM articles WHERE title = :title and created_at > :created_at",
title: "Hello World!",
created_at: "2022-10-30"
)
```

:::caution
:::caution
**Do not use string interpolations in your SQL queries!**

You should never use string interpolations in your raw SQL queries as this would expose your code to SQL injection attacks (where attackers can inject and execute arbitrary SQL into your database).
Expand All @@ -62,6 +62,42 @@ Also, note that the parameters are left **unquoted** in the raw SQL queries: thi

Finally, it should be noted that Marten does not validate the SQL queries you specify to the [`#raw`](./reference/query-set.md#raw) query set method. It is the developer's responsibility to ensure that these queries are (i) valid and (ii) that they return records that correspond to the considered model.

## Filtering with Raw SQL subqueries

Marten also provides a feature to filter query sets using raw SQL subqueries within the `#filter` method. This is useful when you need more complex filtering logic than simple field comparisons but still want to leverage Marten's query building capabilities.

### Positional arguments

You can pass a raw SQL subquery fragment along with its parameters directly to the `#filter` method:

```crystal
Post.all.filter("published = ?", true)
```

### Named arguments

To make your queries more readable, use named parameters:

```crystal
Post.all.filter("published = :is_published", is_published: true)
```

### Q expression

For even more flexibility, you can combine raw SQL subqueries with the [q expression](./queries#complex-filters-with-q-expressions) syntax within a block:

```crystal
Post.all.filter { q(category: "news") & q("created_at > ?", Time.local - 7.days) }
```

### Advanced queries

A subquery can also be used inside the `#filter` method:

```crystal
Product.all.filter("price < (SELECT AVG(price) FROM main_product)")
```

## Executing other SQL statements

If it is necessary to execute other SQL statements that don't fall into the scope of what's provided by the [`#raw`](./reference/query-set.md#raw) query set method, then it's possible to rely on the low-level DB connection capabilities.
Expand Down
45 changes: 45 additions & 0 deletions spec/marten/db/query/expression/filter_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,50 @@ describe Marten::DB::Query::Expression::Filter do
filter_expression = Marten::DB::Query::Expression::Filter.new
filter_expression.q(foo: "bar", test: 42).should eq Marten::DB::Query::Node.new(foo: "bar", test: 42)
end

it "provides a shortcut to generate raw query node in the context of a Q expression with named parameters" do
filter_expression = Marten::DB::Query::Expression::Filter.new

raw_params = {} of String => ::DB::Any
raw_params["foo"] = "bar"

filter_expression.q("foo = :foo", foo: "bar").should eq Marten::DB::Query::RawNode.new("foo = :foo", raw_params)
end

it "provides a shortcut to generate raw query node in the context of a Q expression with a hash" do
filter_expression = Marten::DB::Query::Expression::Filter.new

raw_params = {} of String => ::DB::Any
raw_params["foo"] = "bar"

filter_expression.q(
"foo = :foo", {"foo" => "bar"}
).should eq Marten::DB::Query::RawNode.new("foo = :foo", raw_params)
end

it "provides a shortcut to generate raw query node in the context of a Q expression with positional arguments" do
filter_expression = Marten::DB::Query::Expression::Filter.new

raw_params = ["bar"] of ::DB::Any

filter_expression.q("foo = ?", "bar").should eq Marten::DB::Query::RawNode.new("foo = ?", raw_params)
end

it "provides a shortcut to generate raw query node in the context of a Q expression with an array argument" do
filter_expression = Marten::DB::Query::Expression::Filter.new

raw_params = ["bar"] of ::DB::Any

filter_expression.q("foo = ?", ["bar"]).should eq Marten::DB::Query::RawNode.new("foo = ?", raw_params)
end

it "raises UnmetQuerySetCondition if query string is empty", tags: "raw" do
expect_raises(
Marten::DB::Errors::UnmetQuerySetCondition,
"Query string cannot be empty"
) do
Marten::DB::Query::Expression::Filter.new.q("")
end
end
end
end
Loading
Loading