mirror of
https://gitea.invidious.io/iv-org/shard-crystal-db.git
synced 2024-08-15 00:53:32 +00:00
Merge pull request #2 from spalladino/feature/db-mapping
Database mapping macro
This commit is contained in:
commit
8a913d1ef2
8 changed files with 349 additions and 1 deletions
|
@ -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)
|
||||
|
@ -130,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
|
||||
|
|
|
@ -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|
|
||||
|
|
153
spec/mapping_spec.cr
Normal file
153
spec/mapping_spec.cr
Normal file
|
@ -0,0 +1,153 @@
|
|||
require "./spec_helper"
|
||||
require "base64"
|
||||
|
||||
class SimpleMapping
|
||||
DB.mapping({
|
||||
c0: Int32,
|
||||
c1: String
|
||||
})
|
||||
end
|
||||
|
||||
class NonStrictMapping
|
||||
DB.mapping({
|
||||
c1: Int32,
|
||||
c2: String
|
||||
}, strict: false)
|
||||
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, default: 10 },
|
||||
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 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
|
||||
|
||||
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 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
|
||||
|
||||
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
|
||||
|
||||
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
|
|
@ -42,4 +42,18 @@ describe DB::ResultSet do
|
|||
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|
|
||||
cols << col
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
cols.should eq(["c0", "c1"])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -127,3 +127,4 @@ require "./db/connection"
|
|||
require "./db/statement"
|
||||
require "./db/result_set"
|
||||
require "./db/error"
|
||||
require "./db/mapping"
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
module DB
|
||||
|
||||
class Error < Exception
|
||||
end
|
||||
|
||||
class MappingException < Exception
|
||||
end
|
||||
|
||||
end
|
||||
|
|
141
src/db/mapping.cr
Normal file
141
src/db/mapping.cr
Normal file
|
@ -0,0 +1,141 @@
|
|||
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.
|
||||
# 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)
|
||||
include DB::Mappable
|
||||
|
||||
{% for key, value in properties %}
|
||||
{% properties[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %}
|
||||
{% 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|
|
||||
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(Union({{value[:type]}} | Nil))
|
||||
{% else %}
|
||||
%rs.read({{value[:type]}})
|
||||
{% end %}
|
||||
{% end %}
|
||||
else
|
||||
{% if strict %}
|
||||
raise DB::MappingException.new("unknown result set attribute: #{col_name}")
|
||||
{% else %}
|
||||
%rs.read
|
||||
{% 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
|
|
@ -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)
|
||||
end
|
||||
end
|
||||
|
||||
# Move the next row in the result.
|
||||
# Return `false` if no more rows are available.
|
||||
# See `#each`
|
||||
|
@ -57,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)
|
||||
|
|
Loading…
Reference in a new issue