Database mapping macro

Add `from_rs` method to class to load instances from a resultset. Inspired by YAML and JSON mapping macros.
This commit is contained in:
Santiago Palladino 2016-03-28 20:45:07 -03:00
parent 6b065bd6b6
commit 7fcedc6711
4 changed files with 258 additions and 0 deletions

115
spec/mapping_spec.cr Normal file
View file

@ -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

View file

@ -127,3 +127,4 @@ require "./db/connection"
require "./db/statement"
require "./db/result_set"
require "./db/error"
require "./db/mapping"

View file

@ -1,4 +1,9 @@
module DB
class Error < Exception
end
class MappingException < Exception
end
end

137
src/db/mapping.cr Normal file
View file

@ -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