From 7fcedc67111211eafec143895479d7b86711e8d0 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 28 Mar 2016 20:45:07 -0300 Subject: [PATCH] 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