Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added query macro, tests, and docs #215

Merged
merged 4 commits into from
Jun 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,12 +341,13 @@ puts "deleted" unless post

### Queries

The where clause will give you full control over your query.
The query macro and where clause combine to give you full control over your query.

#### All

When using the `all` method, the SQL selected fields will always match the
fields specified in the model.
When using the `all` method, the SQL selected fields will match the
fields specified in the model unless the `query` macro was used to customize
the SELECT.

Always pass in parameters to avoid SQL Injection. Use a `?`
in your query as placeholder. Checkout the [Crystal DB Driver](https://github.com/crystal-lang/crystal-db)
Expand Down Expand Up @@ -390,6 +391,31 @@ This is the same as:
post = Post.all("ORDER BY posts.name DESC LIMIT 1").first
```

#### Customizing SELECT

The `query` macro allows you to customize the entire query, including the SELECT portion. This shouldn't be necessary in most cases, but allows you to craft more complex (i.e. cross-table) queries if needed:

```crystal
class CustomView < Granite:Base
adapter pg
field articlebody : String
field commentbody : String

query <<-SQL
SELECT articles.articlebody, comments.commentbody
FROM articles
JOIN comments
ON comments.articleid = articles.id
SQL
end
```

You can combine this with an argument to `all` or `first` for maximum flexibility:

```crystal
results = CustomView.all("WHERE articles.author = ?", ["Noah"])
```

### Relationships

#### One to Many
Expand Down
47 changes: 47 additions & 0 deletions spec/granite/select/select_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
require "../../spec_helper"

{% for adapter in GraniteExample::ADAPTERS %}
module {{adapter.capitalize.id}}
describe "{{ adapter.id }} custom select" do
it "generates custom SQL with the query macro" do
ArticleViewModel.select.should eq "SELECT articles.id, articles.articlebody, comments.commentbody FROM articles JOIN comments ON comments.articleid = articles.id"
end

it "uses custom SQL to populate a view model - #all" do
first = Article.new.tap do |model|
model.articlebody = "The Article Body"
model.save
end

second = Comment.new.tap do |model|
model.commentbody = "The Comment Body"
model.articleid = first.id
model.save
end

viewmodel = ArticleViewModel.all
viewmodel.first.articlebody.should eq "The Article Body"
viewmodel.first.commentbody.should eq "The Comment Body"
end

# TODO: `find` on this ViewModel fails because "id" is ambiguous in a complex SELECT.

# it "uses custom SQL to populate a view model - #find" do
# first = Article.new.tap do |model|
# model.articlebody = "The Article Body"
# model.save
# end

# second = Comment.new.tap do |model|
# model.commentbody = "The Comment Body"
# model.articleid = first.id
# model.save
# end

# viewmodel = ArticleViewModel.find!(first.id)
# viewmodel.articlebody.should eq "The Article Body"
# viewmodel.commentbody.should eq "The Comment Body"
# end
end
end
{% end %}
31 changes: 31 additions & 0 deletions spec/spec_models.cr
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,34 @@ require "uuid"
field name : String
end

class Article < Granite::Base
adapter {{ adapter.id }}
table_name articles

primary id : Int64
field articlebody : String
end

class Comment < Granite::Base
adapter {{ adapter.id }}
table_name comments

primary id : Int64
field commentbody : String
field articleid : Int64
end

class ArticleViewModel < Granite::Base
adapter {{ adapter.id }}

field articlebody : String
field commentbody : String

query <<-SQL
SELECT articles.id, articles.articlebody, comments.commentbody FROM articles JOIN comments ON comments.articleid = articles.id
SQL
end

Parent.migrator.drop_and_create
Teacher.migrator.drop_and_create
Student.migrator.drop_and_create
Expand All @@ -231,5 +259,8 @@ require "uuid"
Item.migrator.drop_and_create
NonAutoDefaultPK.migrator.drop_and_create
NonAutoCustomPK.migrator.drop_and_create
Article.migrator.drop_and_create
Comment.migrator.drop_and_create

end
{% end %}
9 changes: 5 additions & 4 deletions src/adapter/base.cr
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ abstract class Granite::Adapter::Base
# remove all rows from a table and reset the counter on the id.
abstract def clear(table_name)

# select performs a query against a table. The table_name and fields are
# configured using the sql_mapping directive in your model. The clause and
# params is the query and params that is passed in via .all() method
abstract def select(table_name, fields, clause = "", params = nil, &block)
# select performs a query against a table. The query object containes table_name,
# fields (configured using the sql_mapping directive in your model), and an optional
# raw query string. The clause and params is the query and params that is passed
# in via .all() method
abstract def select(query : Granite::Select::Container, clause = "", params = nil, &block)

# This will insert a row in the database and return the id generated.
abstract def insert(table_name, fields, params, lastval) : Int64
Expand Down
15 changes: 8 additions & 7 deletions src/adapter/mysql.cr
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ class Granite::Adapter::Mysql < Granite::Adapter::Base
end
end

# select performs a query against a table. The table_name and fields are
# configured using the sql_mapping directive in your model. The clause and
# params is the query and params that is passed in via .all() method
def select(table_name, fields, clause = "", params = [] of DB::Any, &block)
statement = String.build do |stmt|
# select performs a query against a table. The query object containes table_name,
# fields (configured using the sql_mapping directive in your model), and an optional
# raw query string. The clause and params is the query and params that is passed
# in via .all() method
def select(query : Granite::Select::Container, clause = "", params = [] of DB::Any, &block)
statement = query.custom || String.build do |stmt|
stmt << "SELECT "
stmt << fields.map { |name| "#{quote(table_name)}.#{quote(name)}" }.join(", ")
stmt << " FROM #{quote(table_name)} #{clause}"
stmt << query.fields.map { |name| "#{quote(query.table_name)}.#{quote(name)}" }.join(", ")
stmt << " FROM #{quote(query.table_name)} #{clause}"
end

log statement, params
Expand Down
16 changes: 8 additions & 8 deletions src/adapter/pg.cr
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ class Granite::Adapter::Pg < Granite::Adapter::Base
end
end

# select performs a query against a table. The table_name and fields are
# configured using the sql_mapping directive in your model. The clause and
# params is the query and params that is passed in via .all() method
def select(table_name, fields, clause = "", params = [] of DB::Any, &block)
# select performs a query against a table. The query object containes table_name,
# fields (configured using the sql_mapping directive in your model), and an optional
# raw query string. The clause and params is the query and params that is passed
# in via .all() method
def select(query : Granite::Select::Container, clause = "", params = [] of DB::Any, &block)
clause = _ensure_clause_template(clause)

statement = String.build do |stmt|
statement = query.custom || String.build do |stmt|
stmt << "SELECT "
stmt << fields.map { |name| "#{quote(table_name)}.#{quote(name)}" }.join(", ")
stmt << " FROM #{quote(table_name)} #{clause}"
stmt << query.fields.map { |name| "#{quote(query.table_name)}.#{quote(name)}" }.join(", ")
stmt << " FROM #{quote(query.table_name)} #{clause}"
end

log statement, params
Expand Down
17 changes: 9 additions & 8 deletions src/adapter/sqlite.cr
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ class Granite::Adapter::Sqlite < Granite::Adapter::Base
end
end

# select performs a query against a table. The table_name and fields are
# configured using the sql_mapping directive in your model. The clause and
# params is the query and params that is passed in via .all() method
def select(table_name, fields, clause = "", params = [] of DB::Any, &block)
statement = String.build do |stmt|
# select performs a query against a table. The query object containes table_name,
# fields (configured using the sql_mapping directive in your model), and an optional
# raw query string. The clause and params is the query and params that is passed
# in via .all() method
def select(query : Granite::Select::Container, clause = "", params = [] of DB::Any, &block)
statement = query.custom || String.build do |stmt|
stmt << "SELECT "
stmt << fields.map { |name| "#{quote(table_name)}.#{quote(name)}" }.join(", ")
stmt << " FROM #{quote(table_name)} #{clause}"
stmt << query.fields.map { |name| "#{quote(query.table_name)}.#{quote(name)}" }.join(", ")
stmt << " FROM #{quote(query.table_name)} #{clause}"
end

log statement, params
Expand All @@ -45,7 +46,7 @@ class Granite::Adapter::Sqlite < Granite::Adapter::Base
end
end
end

def insert(table_name, fields, params, lastval)
statement = String.build do |stmt|
stmt << "INSERT INTO #{quote(table_name)} ("
Expand Down
3 changes: 3 additions & 0 deletions src/granite/base.cr
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require "./transactions"
require "./validators"
require "./validation_helpers"
require "./migrator"
require "./select"
require "./version"

# Granite::Base is the base class for your model objects.
Expand All @@ -22,6 +23,7 @@ class Granite::Base
include Validators
include ValidationHelpers
include Migrator
include Select

extend Querying
extend Transactions::ClassMethods
Expand All @@ -30,6 +32,7 @@ class Granite::Base
macro finished
__process_table
__process_fields
__process_select
__process_querying
__process_transactions
__process_migrator
Expand Down
2 changes: 1 addition & 1 deletion src/granite/querying.cr
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ module Granite::Querying

def raw_all(clause = "", params = [] of DB::Any)
rows = [] of self
@@adapter.select(@@table_name, fields, clause, params) do |results|
@@adapter.select(@@select, clause, params) do |results|
results.each do
rows << from_sql(results)
end
Expand Down
21 changes: 21 additions & 0 deletions src/granite/select.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module Granite::Select
struct Container
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be called a fieldset?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not so much a set of fields as it is a (loose) container for the query (or SELECT) statement. Granite::Query is used in your new query builder, so I tried to capture this as simply as possible. I don't love Granite::Select::Container though. Open to ideas.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure Container is a little vague, but is it going to show up in an Amber devs code? If not, I don't think it's important to be 100% on target for merging this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. No, the only dev interface is the query macro, so not a big deal here.

property custom : String?
getter table_name, fields

def initialize(@custom = nil, @table_name = "", @fields = [] of String)
end
end

macro query(text)
@@select.custom = {{text.strip}}

def self.select
@@select.custom
end
end

macro __process_select
@@select = Container.new(table_name: @@table_name, fields: fields)
end
end