From fd804dd592b3650fe1716ea025a6a7f4a9ea492d Mon Sep 17 00:00:00 2001 From: "Brian J. Cardiff" Date: Sun, 31 Jan 2016 19:40:02 -0300 Subject: [PATCH] switch to 0-pased positional arguments add docs, many docs --- spec/std/db/dummy_driver.cr | 2 +- spec/std/db/statement_spec.cr | 24 +++++----- src/db/connection.cr | 17 +++++++ src/db/database.cr | 13 ++++-- src/db/db.cr | 87 +++++++++++++++++++++++++++++++---- src/db/driver.cr | 26 +++++++++++ src/db/query_methods.cr | 35 ++++++++++++++ src/db/result_set.cr | 29 +++++++++++- src/db/statement.cr | 26 +++++++++-- 9 files changed, 229 insertions(+), 30 deletions(-) diff --git a/spec/std/db/dummy_driver.cr b/spec/std/db/dummy_driver.cr index 301ed27..83806c7 100644 --- a/spec/std/db/dummy_driver.cr +++ b/spec/std/db/dummy_driver.cr @@ -86,7 +86,7 @@ class DummyDriver < DB::Driver return nil if n == "NULL" if n == "?" - return @statement.params[1] + return @statement.params[0] end if n.starts_with?(":") diff --git a/spec/std/db/statement_spec.cr b/spec/std/db/statement_spec.cr index 9442482..1bfcd60 100644 --- a/spec/std/db/statement_spec.cr +++ b/spec/std/db/statement_spec.cr @@ -13,9 +13,9 @@ describe DB::Statement do with_dummy do |db| stmt = db.prepare("the query") stmt.query "a", 1, nil - stmt.params[1].should eq("a") - stmt.params[2].should eq(1) - stmt.params[3].should eq(nil) + stmt.params[0].should eq("a") + stmt.params[1].should eq(1) + stmt.params[2].should eq(nil) end end @@ -43,9 +43,9 @@ describe DB::Statement do with_dummy do |db| stmt = db.prepare("the query") stmt.exec "a", 1, nil - stmt.params[1].should eq("a") - stmt.params[2].should eq(1) - stmt.params[3].should eq(nil) + stmt.params[0].should eq("a") + stmt.params[1].should eq(1) + stmt.params[2].should eq(nil) end end @@ -73,9 +73,9 @@ describe DB::Statement do with_dummy do |db| stmt = db.prepare("the query") stmt.scalar String, "a", 1, nil - stmt.params[1].should eq("a") - stmt.params[2].should eq(1) - stmt.params[3].should eq(nil) + stmt.params[0].should eq("a") + stmt.params[1].should eq(1) + stmt.params[2].should eq(nil) end end @@ -103,9 +103,9 @@ describe DB::Statement do with_dummy do |db| stmt = db.prepare("the query") stmt.scalar? String, "a", 1, nil - stmt.params[1].should eq("a") - stmt.params[2].should eq(1) - stmt.params[3].should eq(nil) + stmt.params[0].should eq("a") + stmt.params[1].should eq(1) + stmt.params[2].should eq(nil) end end diff --git a/src/db/connection.cr b/src/db/connection.cr index c4a1107..ad67283 100644 --- a/src/db/connection.cr +++ b/src/db/connection.cr @@ -1,4 +1,19 @@ module DB + # Database driver implementors must subclass `Connection`. + # + # Represents one active connection to a database. + # + # Users should never instantiate a `Connection` manually. Use `DB#open` or `Database#connection`. + # + # Refer to `QueryMethods` for documentation about querying the database through this connection. + # + # ### Note to implementors + # + # The connection must be initialized in `#initialize` and closed in `#perform_close`. + # + # To allow quering override `#prepare` method in order to return a prepared `Statement`. + # Also override `#last_insert_id` to allow safe access to the last inserted id through this connection. + # abstract class Connection getter connection_string @@ -23,10 +38,12 @@ module DB # close unless closed? # end + # Returns an `Statement` with the prepared `query` abstract def prepare(query) : Statement include QueryMethods + # Returns the last inserted id through this connection. abstract def last_insert_id : Int64 protected abstract def perform_close diff --git a/src/db/database.cr b/src/db/database.cr index 2f85943..b6b1dc7 100644 --- a/src/db/database.cr +++ b/src/db/database.cr @@ -3,22 +3,29 @@ module DB # Currently it creates a single connection to the database. # Eventually a connection pool will be handled. # - # It should be created from DB module. See `DB.open`. + # It should be created from DB module. See `DB#open`. + # + # Refer to `QueryMethods` for documentation about querying the database. class Database + # :nodoc: getter driver_class + + # Connection configuration to the database. getter connection_string + # :nodoc: def initialize(@driver_class, @connection_string) @driver = @driver_class.new(@connection_string) @connection = @driver.build_connection end - # Closes all connection to the database + # Closes all connection to the database. def close @connection.close end - # Returns a `Connection` to the database + # Returns a `Connection` to the database. + # Useful if you need to ensure the statements are executed in the connection. def connection @connection end diff --git a/src/db/db.cr b/src/db/db.cr index d33cac3..4093c6c 100644 --- a/src/db/db.cr +++ b/src/db/db.cr @@ -1,23 +1,94 @@ +# The DB module is a unified interface to database access. +# Database dialects is supported by custom database driver shards. +# Check [manastech/crystal-sqlite3](https://github.com/manastech/crystal-sqlite3) for example. +# +# Drivers implementors check `Driver` class. +# +# Currently a *single connection* to the database is stablished. +# In the future a connection pool and transaction support will be available. +# +# ### Usage +# +# Assuming `crystal-sqlite3` is included a sqlite3 database can be opened with `#open`. +# +# ``` +# db = DB.open "sqlite3", ":memory:" # or the sqlite3 file path +# db.close +# ``` +# +# If a block is given to `#open` the database is closed automatically +# +# ``` +# DB.open "sqlite3", ":memory:" do |db| +# # work with db +# end # db is closed +# ``` +# +# Three kind of statements can be performed: +# 1. `Database#exec` waits no response from the database. +# 2. `Database#scalar` reads a single value of the response. +# 3. `Database#query` returns a ResultSet that allows iteration over the rows in the response and column information. +# +# All of the above methods allows parametrised query. Either positional or named arguments. +# +# Check a full working version: +# +# ``` +# require "db" +# require "sqlite3" +# +# DB.open "sqlite3", ":memory:" do |db| +# db.exec "create table contacts (name string, age integer)" +# db.exec "insert into contacts values (?, ?)", "John Doe", 30 +# db.exec "insert into contacts values (:name, :age)", {name: "Sarah", age: 33} +# +# puts "max age:" +# puts db.scalar "select max(age) from contacts" # => 33 +# +# puts "contacts:" +# db.query "select name, age from contacts order by age desc" do |rs| +# puts "#{rs.column_name(0)} (#{rs.column_name(1)})" +# # => name (age) +# rs.each do +# puts "#{rs.read(String)} (#{rs.read(Int32)})" +# # => Sarah (33) +# # => John Doe (30) +# end +# end +# end +# ``` +# module DB + # Types supported to interface with database driver. + # These can be used in any `ResultSet#read` or any `Database#query` related + # method to be used as query parameters TYPES = [String, Int32, Int64, Float32, Float64, Slice(UInt8)] + + # See `DB::TYPES` in `DB` alias Any = String | Int32 | Int64 | Float32 | Float64 | Slice(UInt8) # :nodoc: - def self.driver_class(name) # : Driver.class - @@drivers.not_nil![name] + def self.driver_class(driver_name) # : Driver.class + @@drivers.not_nil![driver_name] end - def self.register_driver(name, klass : Driver.class) + # Registers a driver class for a given `driver_name`. + # Should be called by drivers implementors only. + def self.register_driver(driver_name, driver_class : Driver.class) @@drivers ||= {} of String => Driver.class - @@drivers.not_nil![name] = klass + @@drivers.not_nil![driver_name] = driver_class end - def self.open(name, connection_string) - Database.new(driver_class(name), connection_string) + # Opens a database using the `driver_name` registered driver. + # Uses `connection_string` for connection configuration. + # Returned database must be closed by `Database#close`. + def self.open(driver_name, connection_string) + Database.new(driver_class(driver_name), connection_string) end - def self.open(name, connection_string, &block) - open(name, connection_string).tap do |db| + # Same as `#open` but the database is yielded and closed automatically. + def self.open(driver_name, connection_string, &block) + open(driver_name, connection_string).tap do |db| yield db db.close end diff --git a/src/db/driver.cr b/src/db/driver.cr index 3aaab8d..b5f495b 100644 --- a/src/db/driver.cr +++ b/src/db/driver.cr @@ -1,4 +1,30 @@ module DB + # Database driver implementors must subclass `Driver`, + # register with a driver_name using `DB#register_driver` and + # override the factory method `#build_connection`. + # + # ``` + # require "db" + # + # class FakeDriver < Driver + # def build_connection + # FakeConnection.new connection_string + # end + # end + # + # DB.register_driver "fake", FakeDriver + # ``` + # + # Access to this fake datbase will be available with + # + # ``` + # DB.open "fake", "..." do |db| + # # ... use db ... + # end + # ``` + # + # Refer to `Connection`, `Statement` and `ResultSet` for further + # driver implementation instructions. abstract class Driver getter connection_string diff --git a/src/db/query_methods.cr b/src/db/query_methods.cr index e13e5b2..9a50250 100644 --- a/src/db/query_methods.cr +++ b/src/db/query_methods.cr @@ -1,9 +1,29 @@ module DB + # Methods to allow querying a database. + # All methods accepts a `query : String` and a set arguments. + # + # Three kind of statements can be performed: + # 1. `#exec` waits no response from the database. + # 2. `#scalar` reads a single value of the response. Use `#scalar?` if the response is nillable. + # 3. `#query` returns a ResultSet that allows iteration over the rows in the response and column information. + # + # Arguments can be passed: + # * by position: `db.query("SELECT name FROM ... WHERE age > ?", age)` + # * by symbol: `db.query("SELECT name FROM ... WHERE age > :age", {age: age})` + # * by string: `db.query("SELECT name FROM ... WHERE age > :age", {"age": age})` + # + # Convention of mapping how arguments are mapped to the query depends on each driver. + # + # Including `QueryMethods` requires a `prepare(query) : Statement` method. module QueryMethods + # Returns a `ResultSet` for the `query`. + # The `ResultSet` must be closed manually. def query(query, *args) prepare(query).query(*args) end + # Yields a `ResultSet` for the `query`. + # The `ResultSet` is closed automatically. def query(query, *args) # CHECK prepare(query).query(*args, &block) query(query, *args).tap do |rs| @@ -15,22 +35,37 @@ module DB end end + # Performs the `query` discarding any response def exec(query, *args) prepare(query).exec(*args) end + # Performs the `query` and returns a single scalar `Int32` value def scalar(query, *args) prepare(query).scalar(*args) end + # Performs the `query` and returns a single scalar value of type `t`. + # `t` must be any of the allowed `DB::Any` types. + # + # ``` + # puts db.scalar(String, "SELECT MAX(name)") # => (a String) + # ``` def scalar(t, query, *args) prepare(query).scalar(t, *args) end + # Performs the `query` and returns a single scalar `Int32 | Nil` value def scalar?(query, *args) prepare(query).scalar?(*args) end + # Performs the `query` and returns a single scalar value of type `t` or `Nil`. + # `t` must be any of the allowed `DB::Any` types. + # + # ``` + # puts db.scalar?(String, "SELECT MAX(name)") # => (a String | Nil) + # ``` def scalar?(t, query, *args) prepare(query).scalar?(t, *args) end diff --git a/src/db/result_set.cr b/src/db/result_set.cr index 5696206..669e630 100644 --- a/src/db/result_set.cr +++ b/src/db/result_set.cr @@ -1,39 +1,66 @@ module DB + # The response of a query performed on a `Database`. + # + # See `DB` for a complete sample. + # + # Each `#read` call consumes the result and moves to the next column. + # + # ### Note to implementors + # + # 1. Override `#move_next` to move to the next row. + # 2. Override `#read?(t)` for all `t` in `DB::TYPES`. + # 3. (Optional) Override `#read(t)` for all `t` in `DB::TYPES`. + # 4. Override `#column_count`, `#column_name`. + # 5. Override `#column_type`. It must return a type in `DB::TYPES`. abstract class ResultSet + # :nodoc: getter statement def initialize(@statement : Statement) end + # Iterates over all the rows def each while move_next yield end end + # Closes the result set. def close @statement.close end - # :nodoc: # Ensures it executes the query def exec move_next end + # Move the next row in the result. + # Return `false` if no more rows are available. + # See `#each` abstract def move_next : Bool # TODO def empty? : Bool, handle internally with move_next (?) + # Returns the number of columns in the result abstract def column_count : Int32 + + # Returns the name of the column in `index` 0-based position. abstract def column_name(index : Int32) : String + + # Returns the type of the column in `index` 0-based position. + # The result is one of `DB::TYPES`. abstract def column_type(index : Int32) # list datatypes that must be supported form the driver # users will call read(String) or read?(String) for nillables + {% for t in DB::TYPES %} + # Reads the next column as a nillable {{t}}. abstract def read?(t : {{t}}.class) : {{t}}? + # Reads the next column as a {{t}}. def read(t : {{t}}.class) : {{t}} read?({{t}}).not_nil! end diff --git a/src/db/statement.cr b/src/db/statement.cr index ffcd8cf..c97d7f8 100644 --- a/src/db/statement.cr +++ b/src/db/statement.cr @@ -1,4 +1,15 @@ module DB + # Represents a prepared query in a `Connection`. + # It should be created by `QueryMethods`. + # + # ### Note to implementors + # + # 1. Subclass `Statements` + # 2. `Statements` are created from a custom driver `Connection#prepare` method. + # 3. `#begin_parameters` is called before the parameters are set. + # 4. `#add_parameter` methods helps to support 0-based positional arguments and named arguments + # 5. After parameters are set `#perform` is called to return a `ResultSet` + # 6. `#on_close` is called to release the statement resources. abstract class Statement getter connection @@ -6,17 +17,19 @@ module DB @closed = false end + # See `QueryMethods#exec` def exec(*args) query(*args) do |rs| rs.exec end end + # See `QueryMethods#scalar` def scalar(*args) scalar(Int32, *args) end - # t in DB::TYPES + # See `QueryMethods#scalar`. `t` must be in DB::TYPES def scalar(t, *args) query(*args) do |rs| rs.each do @@ -27,11 +40,12 @@ module DB raise "no results" end + # See `QueryMethods#scalar?` def scalar?(*args) scalar?(Int32, *args) end - # t in DB::TYPES + # See `QueryMethods#scalar?`. `t` must be in DB::TYPES def scalar?(t, *args) query(*args) do |rs| rs.each do @@ -42,10 +56,12 @@ module DB raise "no results" end + # See `QueryMethods#query` def query(*args) execute *args end + # See `QueryMethods#query` def query(*args) execute(*args).tap do |rs| begin @@ -62,13 +78,13 @@ module DB private def execute(arg : Slice(UInt8)) begin_parameters - add_parameter 1, arg + add_parameter 0, arg perform end private def execute(args : Enumerable) begin_parameters - args.each_with_index(1) do |arg, index| + args.each_with_index do |arg, index| if arg.is_a?(Hash) arg.each do |key, value| add_parameter key.to_s, value @@ -97,7 +113,7 @@ module DB # close unless closed? # end - # 1-based positional arguments + # 0-based positional arguments protected def begin_parameters end protected abstract def add_parameter(index : Int32, value)