Skip to content

Commit

Permalink
Add the ability to compute the average of a given column in query sets (
Browse files Browse the repository at this point in the history
#149)

* Add the ability to compute the average of a given column in query sets

* Fix floating check

* Handle PG::Numeric

* Change float check

* Add model class

* Add spec

* Fix findings

* Change return type

* Fix spec

* Use ameba
  • Loading branch information
treagod authored Feb 3, 2024
1 parent a0a5f01 commit 03ccf77
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 0 deletions.
14 changes: 14 additions & 0 deletions docs/docs/models-and-databases/reference/query-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ qset = Article.all # returns a query set matching "all" the records of the Artic
qset2 = qset.all # returns a copy of the initial query set
```

### `average`

Allows calculating the average of a numeric field within the records of a specific model. The `#average` method can be used as a class method from any model class, or it can be used as an instance method from any query set object. When used on a query set, it calculates the average of the specified field for the records in that query set.

For example:

```crystal
average_price = Product.average(:price) # Calculate the average price of all products
# Calculate the average rating for a specific category of products
electronic_products = Product.filter(category: "Electronics")
average_rating = electronic_products.average(:rating)
```

### `distinct`

Returns a new query set that will use `SELECT DISTINCT` or `SELECT DISTINCT ON` in its SQL query.
Expand Down
10 changes: 10 additions & 0 deletions spec/marten/db/model/querying_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ describe Marten::DB::Model::Querying do
end
end

describe "::average" do
it "properly calculates the average" do
user = TestUser.create!(username: "jd1", email: "[email protected]", first_name: "John", last_name: "Doe")
Post.create!(author: user, title: "Example post 1", score: 5.0)
Post.create!(author: user, title: "Example post 2", score: 5.0)

Post.average(:score).not_nil!.should be_close(5.0, 0.00001)
end
end

describe "::bulk_create" do
it "allows to insert an array of records without specifying a batch size" do
objects = (1..100).map do |i|
Expand Down
8 changes: 8 additions & 0 deletions spec/marten/db/query/set_spec/models/product.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module Marten::DB::Query::SetSpec
class Product < Marten::Model
field :id, :big_int, primary_key: true, auto: true
field :name, :string, max_size: 255
field :price, :int
field :rating, :float, blank: true, null: true
end
end
66 changes: 66 additions & 0 deletions spec/marten/db/query/sql/query_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,72 @@ describe Marten::DB::Query::SQL::Query do
end
end

describe "#average" do
it "properly calculates the average" do
Marten::DB::Query::SQL::QuerySpec::Product.create!(
name: "Awesome Product",
price: 1000,
rating: 5.0,
)

Marten::DB::Query::SQL::QuerySpec::Product.create!(
name: "Necessary Product",
price: 200,
rating: 1.5,
)

query = Marten::DB::Query::SQL::Query(Marten::DB::Query::SQL::QuerySpec::Product).new
query.average("price").not_nil!.should be_close(600.0, 0.00001)
query.average("rating").not_nil!.should be_close(3.25, 0.00001)
end

it "properly calculates the average on a filtered set" do
Marten::DB::Query::SQL::QuerySpec::Product.create!(
name: "Awesome Product",
price: 1000,
rating: 5.0,
)

Marten::DB::Query::SQL::QuerySpec::Product.create!(
name: "Necessary Product",
price: 200,
rating: 1.5,
)

query = Marten::DB::Query::SQL::Query(Marten::DB::Query::SQL::QuerySpec::Product).new
query.add_query_node(Marten::DB::Query::Node.new(name__startswith: "Awesome"))
query.average("price").not_nil!.should be_close(1000.0, 0.00001)
query.average("rating").not_nil!.should be_close(5.0, 0.00001)
end

it "properly handles zero rows" do
query = Marten::DB::Query::SQL::Query(Marten::DB::Query::SQL::QuerySpec::Product).new
query.average("price").should be_nil
end

it "properly handles null values" do
Marten::DB::Query::SQL::QuerySpec::Product.create!(
name: "Awesome Product",
price: 1000,
rating: 5.0,
)

Marten::DB::Query::SQL::QuerySpec::Product.create!(
name: "Necessary Product",
price: 200,
rating: 1.5,
)

Marten::DB::Query::SQL::QuerySpec::Product.create!(
name: "Ratingless Product",
price: 200,
)

query = Marten::DB::Query::SQL::Query(Marten::DB::Query::SQL::QuerySpec::Product).new
query.average("rating").not_nil!.should be_close(3.25, 0.00001)
end
end

describe "#clone" do
it "results in a new object" do
query = Marten::DB::Query::SQL::Query(Tag).new
Expand Down
8 changes: 8 additions & 0 deletions spec/marten/db/query/sql/query_spec/models/product.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module Marten::DB::Query::SQL::QuerySpec
class Product < Marten::Model
field :id, :big_int, primary_key: true, auto: true
field :name, :string, max_size: 255
field :price, :int
field :rating, :float, blank: true, null: true
end
end
13 changes: 13 additions & 0 deletions src/marten/db/model/querying.cr
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ module Marten
exists?
end

# Returns the average of a field for the current model
#
# This method calculates the average value of the specified field for the considered model. For example:
#
# ```
# Product.average(:price) # => 25.0
# ```
#
# This will return the average price of all products in the database.
def average(field : String | Symbol)
default_queryset.average(field)
end

# Bulk inserts the passed model instances into the database.
#
# This method allows to insert multiple model instances into the database in a single query. This can be
Expand Down
14 changes: 14 additions & 0 deletions src/marten/db/query/set.cr
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ module Marten
exists?
end

# Returns the average of a field for the current query set.
#
# This method calculates the average value of the specified field for the considered query set. For example:
#
# ```
# query_set = Product.all
# query_set.average(:price) # => 25.0
# ```
#
# This will return the average price of all products in the database.
def average(field : String | Symbol)
@query.average(field.try(&.to_s))
end

# Bulk inserts the passed model instances into the database.
#
# This method allows to insert multiple model instances into the database in a single query. This can be useful
Expand Down
33 changes: 33 additions & 0 deletions src/marten/db/query/sql/query.cr
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ module Marten
ensure_join_for_field_path(field_path, selected: true)
end

def average(raw_field : String)
column_name = solve_field_and_column(raw_field).last

sql, parameters = build_average_query(column_name)
connection.open do |db|
result = db.scalar(sql, args: parameters)
result ? result.to_s.to_f : nil
end
end

def clone
self.class.new(
default_ordering: @default_ordering,
Expand Down Expand Up @@ -278,6 +288,29 @@ module Marten
rows_affected.not_nil!
end

private def build_average_query(column_name : String)
where, parameters = where_clause_and_parameters
limit = connection.limit_value(@limit)

sql = build_sql do |s|
s << "SELECT AVG(#{column_name.split(".")[-1]})"
s << "FROM ("
s << "SELECT"

s << connection.distinct_clause_for(distinct_columns) if distinct

s << column_name
s << "FROM #{table_name}"
s << build_joins
s << where
s << "LIMIT #{limit}" unless limit.nil?
s << "OFFSET #{@offset}" unless @offset.nil?
s << ") subquery"
end

{sql, parameters}
end

private def build_count_query(column_name : String?)
where, parameters = where_clause_and_parameters
limit = connection.limit_value(@limit)
Expand Down

0 comments on commit 03ccf77

Please sign in to comment.