From 5773faaa5c946d8a0b01908acea7ab1a8f8a7af2 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 28 Mar 2016 19:29:15 -0300 Subject: [PATCH 1/6] Enumerate columns in result_set --- spec/result_set_spec.cr | 14 ++++++++++++++ src/db/result_set.cr | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/spec/result_set_spec.cr b/spec/result_set_spec.cr index f1cc306..56ad396 100644 --- a/spec/result_set_spec.cr +++ b/spec/result_set_spec.cr @@ -41,5 +41,19 @@ describe DB::ResultSet do end the_rs.closed?.should be_true end + + it "should enumerate columns" do + cols = [] of String + + with_dummy do |db| + db.query "3,4 1,2" do |rs| + rs.each_column do |col, col_type| + cols << col + col_type.should eq(Slice(UInt8)) + end + end + end + + cols.should eq(["c0", "c1"]) end end diff --git a/src/db/result_set.cr b/src/db/result_set.cr index 1b048ae..a739eac 100644 --- a/src/db/result_set.cr +++ b/src/db/result_set.cr @@ -41,6 +41,13 @@ module DB end end + # Iterates over all the columns + def each_column + column_count.times do |x| + yield column_name(x), column_type(x) + end + end + # Move the next row in the result. # Return `false` if no more rows are available. # See `#each` From 6b065bd6b648115ba22436ce000cf0f22842101c Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 28 Mar 2016 20:31:42 -0300 Subject: [PATCH 2/6] Handle different number of columns in dummy driver Number of cols is inferred from the number of fields in the first row. --- spec/dummy_driver.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/dummy_driver.cr b/spec/dummy_driver.cr index 9bf09f0..f62034d 100644 --- a/spec/dummy_driver.cr +++ b/spec/dummy_driver.cr @@ -81,6 +81,7 @@ class DummyDriver < DB::Driver def initialize(statement, query) super(statement) @top_values = query.split.map { |r| r.split(',') }.to_a + @column_count = @top_values.size > 0 ? @top_values[0].size : 2 @@last_result_set = self end @@ -99,7 +100,7 @@ class DummyDriver < DB::Driver end def column_count - 2 + @column_count end def column_name(index) From 7fcedc67111211eafec143895479d7b86711e8d0 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 28 Mar 2016 20:45:07 -0300 Subject: [PATCH 3/6] Database mapping macro Add `from_rs` method to class to load instances from a resultset. Inspired by YAML and JSON mapping macros. --- spec/mapping_spec.cr | 115 ++++++++++++++++++++++++++++++++++++ src/db.cr | 1 + src/db/error.cr | 5 ++ src/db/mapping.cr | 137 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 spec/mapping_spec.cr create mode 100644 src/db/mapping.cr diff --git a/spec/mapping_spec.cr b/spec/mapping_spec.cr new file mode 100644 index 0000000..b662275 --- /dev/null +++ b/spec/mapping_spec.cr @@ -0,0 +1,115 @@ +require "./spec_helper" +require "base64" + +class SimpleMapping + DB.mapping({ + c0: Int32, + c1: String + }) +end + +class MappingWithDefaults + DB.mapping({ + c0: { type: Int32, default: 10 }, + c1: { type: String, default: "c" }, + }) +end + +class MappingWithNilables + DB.mapping({ + c0: { type: Int32, nilable: true }, + c1: { type: String, nilable: true }, + }) +end + +class MappingWithKeys + DB.mapping({ + foo: { type: Int32, key: "c0" }, + bar: { type: String, key: "c1" }, + }) +end + +class MappingWithConverter + + module Base64Converter + def self.from_rs(rs) + Base64.decode(rs.read(String)) + end + end + + DB.mapping({ + c0: { type: Slice(UInt8), converter: MappingWithConverter::Base64Converter }, + c1: { type: String }, + }) +end + +macro from_dummy(query, type) + with_dummy do |db| + rs = db.query({{ query }}) + rs.move_next + %obj = {{ type }}.new(rs) + rs.close + %obj + end +end + +macro expect_mapping(query, t, values) + %obj = from_dummy({{ query }}, {{ t }}) + %obj.should be_a({{ t }}) + {% for key, value in values %} + %obj.{{key.id}}.should eq({{value}}) + {% end %} +end + +describe "DB.mapping" do + + it "should initialize a simple mapping" do + expect_mapping("1,a", SimpleMapping, {c0: 1, c1: "a"}) + end + + it "should fail to initialize a simple mapping if types do not match" do + expect_raises { from_dummy("b,a", SimpleMapping) } + end + + it "should fail to initialize a simple mapping if there is a missing column" do + expect_raises { from_dummy("1", SimpleMapping) } + end + + it "should fail to initialize a simple mapping if there is an unexpected column" do + expect_raises { from_dummy("1,a,b", SimpleMapping) } + end + + it "should initialize a mapping with default values" do + expect_mapping("1,a", MappingWithDefaults, {c0: 1, c1: "a"}) + end + + it "should initialize a mapping using default values if columns are missing" do + expect_mapping("1", MappingWithDefaults, {c0: 1, c1: "c"}) + end + + it "should initialize a mapping with nils if columns are missing" do + expect_mapping("1", MappingWithNilables, {c0: 1, c1: nil}) + end + + it "should initialize a mapping with different keys" do + expect_mapping("1,a", MappingWithKeys, {foo: 1, bar: "a"}) + end + + it "should initialize a mapping with a value converter" do + expect_mapping("Zm9v,a", MappingWithConverter, {c0: "foo".to_slice, c1: "a"}) + end + + it "should initialize multiple instances from a single resultset" do + with_dummy do |db| + db.query("1,a 2,b") do |rs| + objs = SimpleMapping.from_rs(rs) + objs.size.should eq(2) + objs[0].c0.should eq(1) + objs[0].c1.should eq("a") + objs[1].c0.should eq(2) + objs[1].c1.should eq("b") + end + end + end + +end diff --git a/src/db.cr b/src/db.cr index 1e0e2eb..3541099 100644 --- a/src/db.cr +++ b/src/db.cr @@ -127,3 +127,4 @@ require "./db/connection" require "./db/statement" require "./db/result_set" require "./db/error" +require "./db/mapping" diff --git a/src/db/error.cr b/src/db/error.cr index 595c2f1..7fb85af 100644 --- a/src/db/error.cr +++ b/src/db/error.cr @@ -1,4 +1,9 @@ module DB + class Error < Exception end + + class MappingException < Exception + end + end diff --git a/src/db/mapping.cr b/src/db/mapping.cr new file mode 100644 index 0000000..9c0cac1 --- /dev/null +++ b/src/db/mapping.cr @@ -0,0 +1,137 @@ +module DB + + # The `DB.mapping` macro defines how an object is built from a DB::ResultSet. + # + # It takes hash literal as argument, in which attributes and types are defined. + # Once defined, `DB::ResultSet#read(t)` populates properties of the class from the + # result set. + # + # ```crystal + # require "db" + # + # class Employee + # DB.mapping({ + # title: String, + # name: String, + # }) + # end + # + # employees = Employee.from_rs(db.query("SELECT title, name FROM employees")) + # employees[0].title # => "Manager" + # employees[0].name # => "John" + # ``` + # + # Attributes not mapped with `DB.mapping` are not defined as properties. + # Also, missing attributes raise a `DB::Exception`. + # + # You can also define attributes for each property. + # + # ```crystal + # class Employee + # DB.mapping({ + # title: String, + # name: { + # type: String, + # nilable: true, + # key: "firstname", + # }, + # }) + # end + # ``` + # + # Available attributes: + # + # * *type* (required) defines its type. In the example above, *title: String* is a shortcut to *title: {type: String}*. + # * *nilable* defines if a property can be a `Nil`. + # * **default**: value to use if the property is missing in the result set, or if it's `null` and `nilable` was not set to `true`. If the default value creates a new instance of an object (for example `[1, 2, 3]` or `SomeObject.new`), a different instance will be used each time a row is parsed. + # * *key* defines which column to read from a reusltset. It defaults to the name of the property. + # * *converter* takes an alternate type for parsing. It requires a `#from_rs` method in that class, and returns an instance of the given type. + # + # The mapping also automatically defines Crystal properties (getters and setters) for each + # of the keys. It doesn't define a constructor accepting those arguments, but you can provide + # an overload. + # + # The macro basically defines a constructor accepting a `DB::ResultSet` that reads from + # it and initializes this type's instance variables. + # + # This macro also declares instance variables of the types given in the mapping. + macro mapping(properties, strict = true) + {% for key, value in properties %} + {% properties[key] = {type: value} unless value.is_a?(HashLiteral) %} + {% end %} + + {% for key, value in properties %} + @{{key.id}} : {{value[:type]}} {{ (value[:nilable] ? "?" : "").id }} + + def {{key.id}}=(_{{key.id}} : {{value[:type]}} {{ (value[:nilable] ? "?" : "").id }}) + @{{key.id}} = _{{key.id}} + end + + def {{key.id}} + @{{key.id}} + end + {% end %} + + def self.from_rs(%rs : DB::ResultSet) + %objs = Array(self).new + %rs.each do + %objs << self.new(%rs) + end + %objs + end + + def initialize(%rs : DB::ResultSet) + {% for key, value in properties %} + %var{key.id} = nil + %found{key.id} = false + {% end %} + + %rs.each_column do |col_name, col_type| + case col_name + {% for key, value in properties %} + when {{value[:key] || key.id.stringify}} + %found{key.id} = true + %var{key.id} = + {% if value[:converter] %} + {{value[:converter]}}.from_rs(%rs) + {% elsif value[:nilable] || value[:default] != nil %} + %rs.read?({{value[:type]}}) + {% else %} + %rs.read({{value[:type]}}) + {% end %} + {% end %} + else + {% if strict %} + raise DB::MappingException.new("unknown result set attribute: #{col_name}") + {% else %} + # TODO: col_type can be Nil, and read?(Nil) is undefined; how to skip a column? + #%rs.read?(col_type) + {% end %} + end + end + + {% for key, value in properties %} + {% unless value[:nilable] || value[:default] != nil %} + if %var{key.id}.is_a?(Nil) && !%found{key.id} + raise DB::MappingException.new("missing result set attribute: {{(value[:key] || key).id}}") + end + {% end %} + {% end %} + + {% for key, value in properties %} + {% if value[:nilable] %} + {% if value[:default] != nil %} + @{{key.id}} = %found{key.id} ? %var{key.id} : {{value[:default]}} + {% else %} + @{{key.id}} = %var{key.id} + {% end %} + {% elsif value[:default] != nil %} + @{{key.id}} = %var{key.id}.is_a?(Nil) ? {{value[:default]}} : %var{key.id} + {% else %} + @{{key.id}} = %var{key.id}.not_nil! + {% end %} + {% end %} + end + end + +end From 552b6e12b47705f720cfd03d7d36b55f3b74ef9a Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 4 Jul 2016 12:13:39 -0300 Subject: [PATCH 4/6] Rebase to latest DB version and upgrade to Crystal 0.18 --- spec/dummy_driver.cr | 8 ++++++++ spec/dummy_driver_spec.cr | 13 +++++++++++++ spec/mapping_spec.cr | 21 ++++++++++++++++++++- spec/result_set_spec.cr | 4 ++-- src/db/mapping.cr | 9 ++++----- src/db/result_set.cr | 2 +- 6 files changed, 48 insertions(+), 9 deletions(-) diff --git a/spec/dummy_driver.cr b/spec/dummy_driver.cr index f62034d..aa05f83 100644 --- a/spec/dummy_driver.cr +++ b/spec/dummy_driver.cr @@ -131,10 +131,18 @@ class DummyDriver < DB::Driver read(String).to_i32 end + def read(t : Int32?.class) + read(String?).try &.to_i32 + end + def read(t : Int64.class) read(String).to_i64 end + def read(t : Int64?.class) + read(String?).try &.to_i64 + end + def read(t : Float32.class) read(String).to_f32 end diff --git a/spec/dummy_driver_spec.cr b/spec/dummy_driver_spec.cr index 328b7c8..97a47da 100644 --- a/spec/dummy_driver_spec.cr +++ b/spec/dummy_driver_spec.cr @@ -96,6 +96,19 @@ describe DummyDriver do end end + it "should enumerate nillable int64 fields" do + with_dummy do |db| + db.query "3,4 1,NULL" do |rs| + rs.move_next + rs.read(Int64 | Nil).should eq(3i64) + rs.read(Int64 | Nil).should eq(4i64) + rs.move_next + rs.read(Int64 | Nil).should eq(1i64) + rs.read(Int64 | Nil).should be_nil + end + end + end + describe "query one" do it "queries" do with_dummy do |db| diff --git a/spec/mapping_spec.cr b/spec/mapping_spec.cr index b662275..8c622af 100644 --- a/spec/mapping_spec.cr +++ b/spec/mapping_spec.cr @@ -8,6 +8,13 @@ class SimpleMapping }) end +class NonStrictMapping + DB.mapping({ + c1: Int32, + c2: String + }, strict: false) +end + class MappingWithDefaults DB.mapping({ c0: { type: Int32, default: 10 }, @@ -17,7 +24,7 @@ end class MappingWithNilables DB.mapping({ - c0: { type: Int32, nilable: true }, + c0: { type: Int32, nilable: true, default: 10 }, c1: { type: String, nilable: true }, }) end @@ -79,6 +86,10 @@ describe "DB.mapping" do expect_raises { from_dummy("1,a,b", SimpleMapping) } end + it "should initialize a non-strict mapping if there is an unexpected column" do + expect_mapping("1,2,a,b", NonStrictMapping, {c1: 2, c2: "a"}) + end + it "should initialize a mapping with default values" do expect_mapping("1,a", MappingWithDefaults, {c0: 1, c1: "a"}) end @@ -87,10 +98,18 @@ describe "DB.mapping" do expect_mapping("1", MappingWithDefaults, {c0: 1, c1: "c"}) end + it "should initialize a mapping using default values if values are nil and types are non nilable" do + expect_mapping("1,NULL", MappingWithDefaults, {c0: 1, c1: "c"}) + end + it "should initialize a mapping with nils if columns are missing" do expect_mapping("1", MappingWithNilables, {c0: 1, c1: nil}) end + it "should initialize a mapping with nils ignoring default value is type is nilable" do + expect_mapping("NULL,a", MappingWithNilables, {c0: nil, c1: "a"}) + end + it "should initialize a mapping with different keys" do expect_mapping("1,a", MappingWithKeys, {foo: 1, bar: "a"}) end diff --git a/spec/result_set_spec.cr b/spec/result_set_spec.cr index 56ad396..21d8424 100644 --- a/spec/result_set_spec.cr +++ b/spec/result_set_spec.cr @@ -41,15 +41,15 @@ describe DB::ResultSet do end the_rs.closed?.should be_true end + end it "should enumerate columns" do cols = [] of String with_dummy do |db| db.query "3,4 1,2" do |rs| - rs.each_column do |col, col_type| + rs.each_column do |col| cols << col - col_type.should eq(Slice(UInt8)) end end end diff --git a/src/db/mapping.cr b/src/db/mapping.cr index 9c0cac1..04949ce 100644 --- a/src/db/mapping.cr +++ b/src/db/mapping.cr @@ -57,7 +57,7 @@ module DB # This macro also declares instance variables of the types given in the mapping. macro mapping(properties, strict = true) {% for key, value in properties %} - {% properties[key] = {type: value} unless value.is_a?(HashLiteral) %} + {% properties[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %} {% end %} {% for key, value in properties %} @@ -86,7 +86,7 @@ module DB %found{key.id} = false {% end %} - %rs.each_column do |col_name, col_type| + %rs.each_column do |col_name| case col_name {% for key, value in properties %} when {{value[:key] || key.id.stringify}} @@ -95,7 +95,7 @@ module DB {% if value[:converter] %} {{value[:converter]}}.from_rs(%rs) {% elsif value[:nilable] || value[:default] != nil %} - %rs.read?({{value[:type]}}) + %rs.read(Union({{value[:type]}} | Nil)) {% else %} %rs.read({{value[:type]}}) {% end %} @@ -104,8 +104,7 @@ module DB {% if strict %} raise DB::MappingException.new("unknown result set attribute: #{col_name}") {% else %} - # TODO: col_type can be Nil, and read?(Nil) is undefined; how to skip a column? - #%rs.read?(col_type) + %rs.read(Nil) {% end %} end end diff --git a/src/db/result_set.cr b/src/db/result_set.cr index a739eac..c6a5f09 100644 --- a/src/db/result_set.cr +++ b/src/db/result_set.cr @@ -44,7 +44,7 @@ module DB # Iterates over all the columns def each_column column_count.times do |x| - yield column_name(x), column_type(x) + yield column_name(x) end end From 99352d9d2d6c8a10afdb26a68a5254ef7faa09c2 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 4 Jul 2016 12:44:31 -0300 Subject: [PATCH 5/6] Remove unneeded typecast when advancing a column in mapping macro --- src/db/mapping.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/mapping.cr b/src/db/mapping.cr index 04949ce..126b4c8 100644 --- a/src/db/mapping.cr +++ b/src/db/mapping.cr @@ -104,7 +104,7 @@ module DB {% if strict %} raise DB::MappingException.new("unknown result set attribute: #{col_name}") {% else %} - %rs.read(Nil) + %rs.read {% end %} end end From 9ca0b19d9e83173bed07d30e779997258219b475 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 4 Jul 2016 12:46:45 -0300 Subject: [PATCH 6/6] Support mappable classes in query_one and query_all methods --- spec/mapping_spec.cr | 19 +++++++++++++++++++ src/db/mapping.cr | 5 +++++ src/db/result_set.cr | 5 +++++ 3 files changed, 29 insertions(+) diff --git a/spec/mapping_spec.cr b/spec/mapping_spec.cr index 8c622af..42362fd 100644 --- a/spec/mapping_spec.cr +++ b/spec/mapping_spec.cr @@ -131,4 +131,23 @@ describe "DB.mapping" do end end + it "should initialize from a query_one" do + with_dummy do |db| + obj = db.query_one "1,a", as: SimpleMapping + obj.c0.should eq(1) + obj.c1.should eq("a") + end + end + + it "should initialize from a query_all" do + with_dummy do |db| + objs = db.query_all "1,a 2,b", as: SimpleMapping + objs.size.should eq(2) + objs[0].c0.should eq(1) + objs[0].c1.should eq("a") + objs[1].c0.should eq(2) + objs[1].c1.should eq("b") + end + end + end diff --git a/src/db/mapping.cr b/src/db/mapping.cr index 126b4c8..b7b0093 100644 --- a/src/db/mapping.cr +++ b/src/db/mapping.cr @@ -1,5 +1,8 @@ module DB + # Empty module used for marking a class as supporting DB:Mapping + module Mappable; end + # The `DB.mapping` macro defines how an object is built from a DB::ResultSet. # # It takes hash literal as argument, in which attributes and types are defined. @@ -56,6 +59,8 @@ module DB # # This macro also declares instance variables of the types given in the mapping. macro mapping(properties, strict = true) + include DB::Mappable + {% for key, value in properties %} {% properties[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %} {% end %} diff --git a/src/db/result_set.cr b/src/db/result_set.cr index c6a5f09..a3a4e6d 100644 --- a/src/db/result_set.cr +++ b/src/db/result_set.cr @@ -64,6 +64,11 @@ module DB # Reads the next column value abstract def read + # Reads the next columns and maps them to a class + def read(type : DB::Mappable.class) + type.new(self) + end + # Reads the next column value as a **type** def read(type : T.class) : T read.as(T)