-
Notifications
You must be signed in to change notification settings - Fork 88
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Minimally useful query syntax (#132)
* 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
Showing
15 changed files
with
465 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
# default value when a Value query is run | ||
# delegates properly |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.