From faa7ea201dc805874d30fc12f7bfc277e52054c1 Mon Sep 17 00:00:00 2001 From: Stuart Frost Date: Thu, 26 Oct 2023 20:45:44 +0200 Subject: [PATCH] Add reload method (#491) * Add #reload method to Granite::Querying Co-authored-by: jphaward * Add documentation for #reload Co-authored-by: jphaward * Only define #reload when `Spec` module is defined * Move macro condition inside #reload Co-authored-by: Seth T. --------- Co-authored-by: jphaward Co-authored-by: Seth T. --- docs/crud.md | 12 ++ spec/granite/querying/reload_spec.cr | 24 +++ src/granite/base.cr | 3 +- src/granite/querying.cr | 252 +++++++++++++++------------ 4 files changed, 174 insertions(+), 117 deletions(-) create mode 100644 spec/granite/querying/reload_spec.cr diff --git a/docs/crud.md b/docs/crud.md index c1f26de7..842d8179 100644 --- a/docs/crud.md +++ b/docs/crud.md @@ -87,6 +87,18 @@ end post = Post.first! # raises when no records exist ``` +### reload + +Returns the record with the attributes reloaded from the database. + +**Note:** this method is only defined when the `Spec` module is present. + +``` +post = Post.create(name: "Granite Rocks!", body: "Check this out.") +# record gets updated by another process +post.reload # performs another find to fetch the record again +``` + ### where, order, limit, offset, group_by See [querying](./querying.md) for more details of using the QueryBuilder. diff --git a/spec/granite/querying/reload_spec.cr b/spec/granite/querying/reload_spec.cr new file mode 100644 index 00000000..9592176c --- /dev/null +++ b/spec/granite/querying/reload_spec.cr @@ -0,0 +1,24 @@ +require "../../spec_helper" + +describe "#reload" do + before_each do + Parent.clear + end + + it "reloads the record from the database" do + parent = Parent.create(name: "Parent") + + Parent.find!(parent.id).update(name: "Other") + + parent.reload.name.should eq "Other" + end + + it "raises an error if the record no longer exists" do + parent = Parent.create(name: "Parent") + parent.destroy + + expect_raises(Granite::Querying::NotFound) do + parent.reload + end + end +end diff --git a/src/granite/base.cr b/src/granite/base.cr index 431c761b..64e11369 100644 --- a/src/granite/base.cr +++ b/src/granite/base.cr @@ -29,6 +29,7 @@ abstract class Granite::Base include ValidationHelpers include Migrator include Select + include Querying include ConnectionManagement @@ -36,7 +37,7 @@ abstract class Granite::Base extend Tables::ClassMethods extend Granite::Migrator::ClassMethods - extend Querying + extend Querying::ClassMethods extend Query::BuilderMethods extend Transactions::ClassMethods extend Integrators diff --git a/src/granite/querying.cr b/src/granite/querying.cr index 2d28a0f7..7185a14f 100644 --- a/src/granite/querying.cr +++ b/src/granite/querying.cr @@ -2,153 +2,173 @@ module Granite::Querying class NotFound < Exception end - # Entrypoint for creating a new object from a result set. - def from_rs(result : DB::ResultSet) : self - model = new - model.new_record = false - model.from_rs result - model - end + module ClassMethods + # Entrypoint for creating a new object from a result set. + def from_rs(result : DB::ResultSet) : self + model = new + model.new_record = false + model.from_rs result + model + end - def raw_all(clause = "", params = [] of Granite::Columns::Type) - rows = [] of self - adapter.select(select_container, clause, params) do |results| - results.each do - rows << from_rs(results) + def raw_all(clause = "", params = [] of Granite::Columns::Type) + rows = [] of self + adapter.select(select_container, clause, params) do |results| + results.each do + rows << from_rs(results) + end end + rows end - rows - end - # All will return all rows in the database. The clause allows you to specify - # a WHERE, JOIN, GROUP BY, ORDER BY and any other SQL92 compatible query to - # your table. The result will be a Collection(Model) object which lazy loads - # an array of instantiated instances of your Model class. - # This allows you to take full advantage of the database - # that you are using so you are not restricted or dummied down to support a - # DSL. - # Lazy load prevent running unnecessary queries from unused variables. - def all(clause = "", params = [] of Granite::Columns::Type, use_primary_adapter = true) - switch_to_writer_adapter if use_primary_adapter == true - Collection(self).new(->{ raw_all(clause, params) }) - end + # All will return all rows in the database. The clause allows you to specify + # a WHERE, JOIN, GROUP BY, ORDER BY and any other SQL92 compatible query to + # your table. The result will be a Collection(Model) object which lazy loads + # an array of instantiated instances of your Model class. + # This allows you to take full advantage of the database + # that you are using so you are not restricted or dummied down to support a + # DSL. + # Lazy load prevent running unnecessary queries from unused variables. + def all(clause = "", params = [] of Granite::Columns::Type, use_primary_adapter = true) + switch_to_writer_adapter if use_primary_adapter == true + Collection(self).new(->{ raw_all(clause, params) }) + end - # First adds a `LIMIT 1` clause to the query and returns the first result - def first(clause = "", params = [] of Granite::Columns::Type) - all([clause.strip, "LIMIT 1"].join(" "), params, false).first? - end + # First adds a `LIMIT 1` clause to the query and returns the first result + def first(clause = "", params = [] of Granite::Columns::Type) + all([clause.strip, "LIMIT 1"].join(" "), params, false).first? + end - def first!(clause = "", params = [] of Granite::Columns::Type) - first(clause, params) || raise NotFound.new("No #{{{@type.name.stringify}}} found with first(#{clause})") - end + def first!(clause = "", params = [] of Granite::Columns::Type) + first(clause, params) || raise NotFound.new("No #{{{@type.name.stringify}}} found with first(#{clause})") + end - # find returns the row with the primary key specified. Otherwise nil. - def find(value) - first("WHERE #{primary_name} = ?", [value]) - end + # find returns the row with the primary key specified. Otherwise nil. + def find(value) + first("WHERE #{primary_name} = ?", [value]) + end - # find returns the row with the primary key specified. Otherwise raises an exception. - def find!(value) - find(value) || raise Granite::Querying::NotFound.new("No #{{{@type.name.stringify}}} found where #{primary_name} = #{value}") - end + # find returns the row with the primary key specified. Otherwise raises an exception. + def find!(value) + find(value) || raise Granite::Querying::NotFound.new("No #{{{@type.name.stringify}}} found where #{primary_name} = #{value}") + end - # Returns the first row found that matches *criteria*. Otherwise `nil`. - def find_by(**criteria : Granite::Columns::Type) - find_by criteria.to_h - end + # Returns the first row found that matches *criteria*. Otherwise `nil`. + def find_by(**criteria : Granite::Columns::Type) + find_by criteria.to_h + end - # :ditto: - def find_by(criteria : Granite::ModelArgs) - clause, params = build_find_by_clause(criteria) - first "WHERE #{clause}", params - end + # :ditto: + def find_by(criteria : Granite::ModelArgs) + clause, params = build_find_by_clause(criteria) + first "WHERE #{clause}", params + end - # Returns the first row found that matches *criteria*. Otherwise raises a `NotFound` exception. - def find_by!(**criteria : Granite::Columns::Type) - find_by!(criteria.to_h) - end + # Returns the first row found that matches *criteria*. Otherwise raises a `NotFound` exception. + def find_by!(**criteria : Granite::Columns::Type) + find_by!(criteria.to_h) + end - # :ditto: - def find_by!(criteria : Granite::ModelArgs) - find_by(criteria) || raise NotFound.new("No #{{{@type.name.stringify}}} found where #{criteria.map { |k, v| %(#{k} #{v.nil? ? "is NULL" : "= #{v}"}) }.join(" and ")}") - end + # :ditto: + def find_by!(criteria : Granite::ModelArgs) + find_by(criteria) || raise NotFound.new("No #{{{@type.name.stringify}}} found where #{criteria.map { |k, v| %(#{k} #{v.nil? ? "is NULL" : "= #{v}"}) }.join(" and ")}") + end - def find_each(clause = "", params = [] of Granite::Columns::Type, batch_size limit = 100, offset = 0, &) - find_in_batches(clause, params, batch_size: limit, offset: offset) do |batch| - batch.each do |record| - yield record + def find_each(clause = "", params = [] of Granite::Columns::Type, batch_size limit = 100, offset = 0, &) + find_in_batches(clause, params, batch_size: limit, offset: offset) do |batch| + batch.each do |record| + yield record + end end end - end - def find_in_batches(clause = "", params = [] of Granite::Columns::Type, batch_size limit = 100, offset = 0, &) - if limit < 1 - raise ArgumentError.new("batch_size must be >= 1") + def find_in_batches(clause = "", params = [] of Granite::Columns::Type, batch_size limit = 100, offset = 0, &) + if limit < 1 + raise ArgumentError.new("batch_size must be >= 1") + end + + loop do + results = all "#{clause} LIMIT ? OFFSET ?", params + [limit, offset], false + break if results.empty? + yield results + offset += limit + end end - loop do - results = all "#{clause} LIMIT ? OFFSET ?", params + [limit, offset], false - break if results.empty? - yield results - offset += limit + # Returns `true` if a records exists with a PK of *id*, otherwise `false`. + def exists?(id : Number | String | Nil) : Bool + return false if id.nil? + exec_exists "#{primary_name} = ?", [id] end - end - # Returns `true` if a records exists with a PK of *id*, otherwise `false`. - def exists?(id : Number | String | Nil) : Bool - return false if id.nil? - exec_exists "#{primary_name} = ?", [id] - end + # Returns `true` if a records exists that matches *criteria*, otherwise `false`. + def exists?(**criteria : Granite::Columns::Type) : Bool + exists? criteria.to_h + end - # Returns `true` if a records exists that matches *criteria*, otherwise `false`. - def exists?(**criteria : Granite::Columns::Type) : Bool - exists? criteria.to_h - end + # :ditto: + def exists?(criteria : Granite::ModelArgs) : Bool + exec_exists *build_find_by_clause(criteria) + end - # :ditto: - def exists?(criteria : Granite::ModelArgs) : Bool - exec_exists *build_find_by_clause(criteria) - end + # count returns a count of all the records + def count : Int32 + scalar "SELECT COUNT(*) FROM #{quoted_table_name}", &.to_s.to_i + end - # count returns a count of all the records - def count : Int32 - scalar "SELECT COUNT(*) FROM #{quoted_table_name}", &.to_s.to_i - end + def exec(clause = "") + switch_to_writer_adapter + adapter.open(&.exec(clause)) + end - def exec(clause = "") - switch_to_writer_adapter - adapter.open(&.exec(clause)) - end + def query(clause = "", params = [] of Granite::Columns::Type, &) + switch_to_writer_adapter + adapter.open { |db| yield db.query(clause, args: params) } + end - def query(clause = "", params = [] of Granite::Columns::Type, &) - switch_to_writer_adapter - adapter.open { |db| yield db.query(clause, args: params) } - end + def scalar(clause = "", &) + switch_to_writer_adapter + adapter.open { |db| yield db.scalar(clause) } + end - def scalar(clause = "", &) - switch_to_writer_adapter - adapter.open { |db| yield db.scalar(clause) } - end + private def exec_exists(clause : String, params : Array(Granite::Columns::Type)) : Bool + self.adapter.exists? quoted_table_name, clause, params + end - private def exec_exists(clause : String, params : Array(Granite::Columns::Type)) : Bool - self.adapter.exists? quoted_table_name, clause, params - end + private def build_find_by_clause(criteria : Granite::ModelArgs) + keys = criteria.keys + criteria_hash = criteria.dup - private def build_find_by_clause(criteria : Granite::ModelArgs) - keys = criteria.keys - criteria_hash = criteria.dup + clauses = keys.map do |name| + if criteria_hash.has_key?(name) && !criteria_hash[name].nil? + matcher = "= ?" + else + matcher = "IS NULL" + criteria_hash.delete name + end - clauses = keys.map do |name| - if criteria_hash.has_key?(name) && !criteria_hash[name].nil? - matcher = "= ?" - else - matcher = "IS NULL" - criteria_hash.delete name + "#{quoted_table_name}.#{quote(name.to_s)} #{matcher}" end - "#{quoted_table_name}.#{quote(name.to_s)} #{matcher}" + {clauses.join(" AND "), criteria_hash.values} end - - {clauses.join(" AND "), criteria_hash.values} end + + {% if parse_type("Spec").resolve? %} + # Returns the record with the attributes reloaded from the database. + # + # **Note:** this method is only defined when the `Spec` module is present. + # + # ``` + # post = Post.create(name: "Granite Rocks!", body: "Check this out.") + # # record gets updated by another process + # post.reload # performs another find to fetch the record again + # ``` + def reload + {% if !@top_level.has_constant? "Spec" %} + raise "#reload is a convenience method for testing only, please use #find in your application code" + {% end %} + self.class.find!(primary_key_value) + end + {% end %} end