Skip to content

Commit

Permalink
Minimally useful query syntax (#132)
Browse files Browse the repository at this point in the history
* First stab at a query syntax

* Update to be more generic

* #first can return nil

* [GQL] Fix parameter numbering on where clauses

* [GQL] add Builder#any?

* [GQL] implement Builder#order

* [GQL] implement Builder#delete

* refactor query generation into one place

* A couple simple tests for the query builder

* Builds out better Executors, refactors generic naming

* [GQL] PSQL properly render GROUP BY only if aggregate fields are specified

* [GQL] PSQL properly query with Nil values

* [GQL] fix builder#order

* [GQL] provide builder #each

* Updates querybuilder proposal for latest changes to granite core

* moving everything into the correct namespace

* Use granite logger instead of puts
  • Loading branch information
robacarp authored Apr 27, 2018
1 parent 644491e commit ea226b4
Show file tree
Hide file tree
Showing 15 changed files with 465 additions and 0 deletions.
43 changes: 43 additions & 0 deletions spec/granite_orm/query/assembler/postgresql_spec.cr
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions spec/granite_orm/query/builder_spec.cr
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions spec/granite_orm/query/executor_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# default value when a Value query is run
# delegates properly
29 changes: 29 additions & 0 deletions spec/granite_orm/query/spec_helper.cr
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/granite_orm.cr
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "yaml"
require "db"
require "./granite_orm/base"
require "./query_builder"
33 changes: 33 additions & 0 deletions src/granite_orm/query/assemblers/base.cr
Original file line number Diff line number Diff line change
@@ -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
113 changes: 113 additions & 0 deletions src/granite_orm/query/assemblers/postgresql.cr
Original file line number Diff line number Diff line change
@@ -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
129 changes: 129 additions & 0 deletions src/granite_orm/query/builder.cr
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit ea226b4

Please sign in to comment.