diff --git a/spec/granite_orm/query/assembler/postgresql_spec.cr b/spec/granite_orm/query/assembler/postgresql_spec.cr new file mode 100644 index 00000000..5a79dacf --- /dev/null +++ b/spec/granite_orm/query/assembler/postgresql_spec.cr @@ -0,0 +1,43 @@ +require "../spec_helper" + +def ignore_whitespace(expected : String) + whitespace = "\\s+" + compiled = expected.split(/\s/).map {|s| Regex.escape s }.join(whitespace) + Regex.new compiled, Regex::Options::IGNORE_CASE ^ Regex::Options::MULTILINE +end + +describe Granite::Query::Assembler::Postgresql(Model) do + context "count" do + it "adds group_by fields for where/count queries" do + sql = "select count(*) from table where name = $1 group by name" + builder.where(name: "bob").count.raw_sql.should match ignore_whitespace sql + end + + it "counts without group_by fields for simple counts" do + builder.count.raw_sql.should match ignore_whitespace "select count(*) from table" + end + end + + context "where" do + it "properly numbers fields" do + sql = "select #{query_fields} from table where name = $1 and age = $2 order by id desc" + query = builder.where(name: "bob", age: "23") + query.raw_sql.should match ignore_whitespace sql + + assembler = query.assembler + assembler.build_where + assembler.numbered_parameters.should eq ["bob", "23"] + end + end + + context "order" do + it "uses default sort when no sort is provided" do + builder.raw_sql.should match ignore_whitespace "select #{query_fields} from table order by id desc" + end + + it "uses specified sort when provided" do + sql = "select #{query_fields} from table order by id asc" + builder.order(id: :asc).raw_sql.should match ignore_whitespace sql + end + end +end diff --git a/spec/granite_orm/query/builder_spec.cr b/spec/granite_orm/query/builder_spec.cr new file mode 100644 index 00000000..567f058c --- /dev/null +++ b/spec/granite_orm/query/builder_spec.cr @@ -0,0 +1,18 @@ +require "./spec_helper" + +describe Granite::Query::Builder(Model) do + it "stores where_fields" do + query = builder.where(name: "bob").where(age: 23) + expected = {"name" => "bob", "age" => 23} + query.where_fields.should eq expected + end + + it "stores order fields" do + query = builder.order(name: :desc).order(age: :asc) + expected = [ + {field: "name", direction: Granite::Query::Builder::Sort::Descending}, + {field: "age", direction: Granite::Query::Builder::Sort::Ascending} + ] + query.order_fields.should eq expected + end +end diff --git a/spec/granite_orm/query/executor_spec.cr b/spec/granite_orm/query/executor_spec.cr new file mode 100644 index 00000000..99d5037d --- /dev/null +++ b/spec/granite_orm/query/executor_spec.cr @@ -0,0 +1,2 @@ +# default value when a Value query is run +# delegates properly diff --git a/spec/granite_orm/query/spec_helper.cr b/spec/granite_orm/query/spec_helper.cr new file mode 100644 index 00000000..d5c8e8f8 --- /dev/null +++ b/spec/granite_orm/query/spec_helper.cr @@ -0,0 +1,29 @@ +require "spec" +require "db" +require "../../../src/query_builder" + +class Model + def self.table_name + "table" + end + + def self.fields + ["name", "age"] + end + + def self.primary_name + "id" + end +end + +def query_fields + Model.fields.join ", " +end + +def builder + builder = Granite::Query::Builder(Model).new +end + +def assembler + Granite::Query::Assembler::Postgresql(Model).new builder +end diff --git a/src/granite_orm.cr b/src/granite_orm.cr index f0eaa13d..296d3402 100644 --- a/src/granite_orm.cr +++ b/src/granite_orm.cr @@ -1,3 +1,4 @@ require "yaml" require "db" require "./granite_orm/base" +require "./query_builder" diff --git a/src/granite_orm/query/assemblers/base.cr b/src/granite_orm/query/assemblers/base.cr new file mode 100644 index 00000000..78762e85 --- /dev/null +++ b/src/granite_orm/query/assemblers/base.cr @@ -0,0 +1,33 @@ +module Granite::Query::Assembler + abstract class Base(Model) + def initialize(@query : Builder(Model)) + @numbered_parameters = [] of DB::Any + @aggregate_fields = [] of String + end + + def add_parameter(value : DB::Any) : String + @numbered_parameters << value + "$#{@numbered_parameters.size}" + end + + def numbered_parameters + @numbered_parameters + end + + def add_aggregate_field(name : String) + @aggregate_fields << name + end + + def table_name + Model.table_name + end + + def field_list + [Model.fields].flatten.join ", " + end + + abstract def count : Int64 + abstract def first(n : Int32 = 1) : Array(Model) + abstract def delete + end +end diff --git a/src/granite_orm/query/assemblers/postgresql.cr b/src/granite_orm/query/assemblers/postgresql.cr new file mode 100644 index 00000000..9a1ca676 --- /dev/null +++ b/src/granite_orm/query/assemblers/postgresql.cr @@ -0,0 +1,113 @@ +# Query runner which finalizes a query and runs it. +# This will likely require adapter specific subclassing :[. +module Granite::Query::Assembler + class Postgresql(Model) < Base(Model) + def build_where + clauses = @query.where_fields.map do |field, value| + add_aggregate_field field + + # TODO value is an array + if value.nil? + "#{field} IS NULL" + else + "#{field} = #{add_parameter value}" + end + end + + return "" if clauses.none? + + "WHERE #{clauses.join " AND "}" + end + + def build_order(use_default_order = true) + order_fields = @query.order_fields + + if order_fields.none? + if use_default_order + order_fields = default_order + else + return "" + end + end + + order_clauses = order_fields.map do |expression| + add_aggregate_field expression[:field] + + if expression[:direction] == Builder::Sort::Ascending + "#{expression[:field]} ASC" + else + "#{expression[:field]} DESC" + end + end + + "ORDER BY #{order_clauses.join ", "}" + end + + def log(*stuff) + end + + def default_order + [{ field: Model.primary_name, direction: "ASC" }] + end + + def build_group_by + if @aggregate_fields.any? + "GROUP BY #{@aggregate_fields.join ", "}" + else + "" + end + end + + def count : Executor::Value(Model, Int64) + where = build_where + order = build_order(use_default_order = false) + group = build_group_by + + sql = <<-SQL + SELECT COUNT(*) + FROM #{table_name} + #{where} + #{group} + #{order} + SQL + + Executor::Value(Model, Int64).new sql, numbered_parameters, default: 0_i64 + end + + def first(n : Int32 = 1) : Executor::List(Model) + sql = <<-SQL + SELECT #{field_list} + FROM #{table_name} + #{build_where} + #{build_order} + LIMIT #{n} + SQL + + Executor::List(Model).new sql, numbered_parameters + end + + def delete + sql = <<-SQL + DELETE + FROM #{table_name} + #{build_where} + SQL + + log sql, numbered_parameters + Model.adapter.open do |db| + db.exec sql, numbered_parameters + end + end + + def select + sql = <<-SQL + SELECT #{field_list} + FROM #{table_name} + #{build_where} + #{build_order} + SQL + + Executor::List(Model).new sql, numbered_parameters + end + end +end diff --git a/src/granite_orm/query/builder.cr b/src/granite_orm/query/builder.cr new file mode 100644 index 00000000..841be800 --- /dev/null +++ b/src/granite_orm/query/builder.cr @@ -0,0 +1,129 @@ +# Data structure which will allow chaining of query components, +# nesting of boolean logic, etc. +# +# Should return self, or another instance of Builder wherever +# chaining should be possible. +# +# Current query syntax: +# - where(field: value) => "WHERE field = 'value'" +# +# Hopefully soon: +# - Model.where(field: value).not( Model.where(field2: value2) ) +# or +# - Model.where(field: value).not { where(field2: value2) } +# +# - Model.where(field: value).or( Model.where(field3: value3) ) +# or +# - Model.where(field: value).or { whehre(field3: value3) } +class Granite::Query::Builder(Model) + alias FieldName = String + alias FieldData = DB::Any + + enum Sort + Ascending + Descending + end + + getter where_fields + getter order_fields + + def initialize(@boolean_operator = :and) + @where_fields = {} of FieldName => FieldData + @order_fields = [] of NamedTuple(field: String, direction: Sort) + end + + def assembler + # when adapter.postgresql? + Assembler::Postgresql(Model).new self + # when adapter.mysql? + # etc + end + + def where(**matches) + matches.each do |field, data| + @where_fields[field.to_s] = data + end + + self + end + + def order(field : Symbol) + @order_fields << { field: field.to_s, direction: Sort::Ascending } + + self + end + + def order(fields : Array(Symbol)) + fields.each do |field| + order field + end + + self + end + + def order(**dsl) + dsl.each do |field, dsl_direction| + direction = Sort::Ascending + + if dsl_direction == "desc" || dsl_direction == :desc + direction = Sort::Descending + end + + @order_fields << { field: field.to_s, direction: direction } + end + + self + end + + def raw_sql + assembler.select.raw_sql + end + + def count : Executor::Value(Model, Int64) + assembler.count + end + + def first : Model? + first(1).first? + end + + def first(n : Int32) : Executor::List(Model) + assembler.first(n) + end + + def any? : Bool + ! first.nil? + end + + def delete + assembler.delete + end + + def select + assembler.select.run + end + + def size + count + end + + def reject(&block) + assembler.select.run.reject do |record| + yield record + end + end + + def each(&block) + assembler.select.tap do |record_set| + record_set.each do |record| + yield record + end + end + end + + def map(&block) + assembler.select.run.map do |record| + yield record + end + end +end diff --git a/src/granite_orm/query/builder_methods.cr b/src/granite_orm/query/builder_methods.cr new file mode 100644 index 00000000..21c960fb --- /dev/null +++ b/src/granite_orm/query/builder_methods.cr @@ -0,0 +1,27 @@ +# DSL to be included into a model +# To activate, simply +# +# class Model < Granite::ORM::Base +# include Query::BuilderMethods +# end +module Granite::Query::BuilderMethods + def __builder + Builder(self).new + end + + def count : Executor::Value(self, Int64) + __builder.count + end + + def where(**match_data) : Builder + __builder.where **match_data + end + + def first : self? + __builder.first + end + + def first(n : Int32) : Executor::List(self) + __builder.first n + end +end diff --git a/src/granite_orm/query/executor.cr b/src/granite_orm/query/executor.cr new file mode 100644 index 00000000..e69de29b diff --git a/src/granite_orm/query/executors/base.cr b/src/granite_orm/query/executors/base.cr new file mode 100644 index 00000000..327ceb94 --- /dev/null +++ b/src/granite_orm/query/executors/base.cr @@ -0,0 +1,11 @@ +module Granite::Query::Executor + module Shared + def raw_sql : String + @sql + end + + def log(*messages) + Granite::Logger.log messages + end + end +end diff --git a/src/granite_orm/query/executors/list.cr b/src/granite_orm/query/executors/list.cr new file mode 100644 index 00000000..e98cc1e2 --- /dev/null +++ b/src/granite_orm/query/executors/list.cr @@ -0,0 +1,27 @@ +module Granite::Query::Executor + class List(Model) + include Shared + + def initialize(@sql : String, @args = [] of DB::Any) + end + + def run : Array(Model) + log @sql, @args + + results = [] of Model + + Model.adapter.open do |db| + db.query @sql, @args do |record_set| + record_set.each do + results << Model.from_sql record_set + end + end + end + + results + end + + delegate :[], :first?, :first, :each, :group_by, to: :run + delegate :to_s, to: :run + end +end diff --git a/src/granite_orm/query/executors/one.cr b/src/granite_orm/query/executors/one.cr new file mode 100644 index 00000000..e69de29b diff --git a/src/granite_orm/query/executors/value.cr b/src/granite_orm/query/executors/value.cr new file mode 100644 index 00000000..bd52a02f --- /dev/null +++ b/src/granite_orm/query/executors/value.cr @@ -0,0 +1,27 @@ +module Granite::Query::Executor + class Value(Model, Scalar) + include Shared + + def initialize(@sql : String, @args = [] of DB::Any, @default : Scalar = nil) + end + + def run : Scalar + log @sql, @args + # db.scalar raises when a query returns 0 results, so I'm using query_one? + # https://github.com/crystal-lang/crystal-db/blob/7d30e9f50e478cb6404d16d2ce91e639b6f9c476/src/db/statement.cr#L18 + + raise "No default provided" unless @default + + Model.adapter.open do |db| + db.query_one? @sql, @args do |record_set| + return record_set.read.as Scalar + end + end + + @default.not_nil! + end + + delegate :<, :>, :<=, :>=, to: :run + delegate :to_i, :to_s, to: :run + end +end diff --git a/src/query_builder.cr b/src/query_builder.cr new file mode 100644 index 00000000..9ccbbe32 --- /dev/null +++ b/src/query_builder.cr @@ -0,0 +1,5 @@ +module Granite::Query +end + +require "./granite_orm/query/executors/base" +require "./granite_orm/query/**"