mirror of
https://gitea.invidious.io/iv-org/shard-crystal-db.git
synced 2024-08-15 00:53:32 +00:00
Add DB::Serializable
(#115)
This commit is contained in:
parent
2b95d69e68
commit
e1832fc359
4 changed files with 402 additions and 1 deletions
|
@ -4,6 +4,6 @@ version: 0.7.0
|
||||||
authors:
|
authors:
|
||||||
- Brian J. Cardiff <bcardiff@manas.tech>
|
- Brian J. Cardiff <bcardiff@manas.tech>
|
||||||
|
|
||||||
crystal: 0.24.0
|
crystal: 0.25.0
|
||||||
|
|
||||||
license: MIT
|
license: MIT
|
||||||
|
|
220
spec/serializable_spec.cr
Normal file
220
spec/serializable_spec.cr
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
require "./spec_helper"
|
||||||
|
require "base64"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
class SimpleModel
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property c0 : Int32
|
||||||
|
property c1 : String
|
||||||
|
end
|
||||||
|
|
||||||
|
class NonStrictModel
|
||||||
|
include DB::Serializable
|
||||||
|
include DB::Serializable::NonStrict
|
||||||
|
|
||||||
|
property c1 : Int32
|
||||||
|
property c2 : String
|
||||||
|
end
|
||||||
|
|
||||||
|
class ModelWithDefaults
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property c0 : Int32 = 10
|
||||||
|
property c1 : String = "c"
|
||||||
|
end
|
||||||
|
|
||||||
|
class ModelWithNilables
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property c0 : Int32? = 10
|
||||||
|
property c1 : String?
|
||||||
|
end
|
||||||
|
|
||||||
|
class ModelWithNilUnionTypes
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property c0 : Int32 | Nil = 10
|
||||||
|
property c1 : String | Nil
|
||||||
|
end
|
||||||
|
|
||||||
|
class ModelWithKeys
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
@[DB::Field(key: "c0")]
|
||||||
|
property foo : Int32
|
||||||
|
@[DB::Field(key: "c1")]
|
||||||
|
property bar : String
|
||||||
|
end
|
||||||
|
|
||||||
|
class ModelWithConverter
|
||||||
|
module Base64Converter
|
||||||
|
def self.from_rs(rs)
|
||||||
|
Base64.decode(rs.read(String))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
@[DB::Field(converter: ModelWithConverter::Base64Converter)]
|
||||||
|
property c0 : Slice(UInt8)
|
||||||
|
property c1 : String
|
||||||
|
end
|
||||||
|
|
||||||
|
class ModelWithInitialize
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property c0 : Int32
|
||||||
|
property c1 : String
|
||||||
|
|
||||||
|
def_equals c0, c1
|
||||||
|
|
||||||
|
def initialize(@c0, @c1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class ModelWithJSON
|
||||||
|
include JSON::Serializable
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property c0 : Int32
|
||||||
|
property c1 : 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_model(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::Serializable" do
|
||||||
|
it "should initialize a simple model" do
|
||||||
|
expect_model("1,a", SimpleModel, {c0: 1, c1: "a"})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should fail to initialize a simple model if types do not match" do
|
||||||
|
expect_raises ArgumentError do
|
||||||
|
from_dummy("b,a", SimpleModel)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should fail to initialize a simple model if there is a missing column" do
|
||||||
|
expect_raises DB::MappingException do
|
||||||
|
from_dummy("1", SimpleModel)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should fail to initialize a simple model if there is an unexpected column" do
|
||||||
|
expect_raises DB::MappingException do
|
||||||
|
from_dummy("1,a,b", SimpleModel)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a non-strict model if there is an unexpected column" do
|
||||||
|
expect_model("1,2,a,b", NonStrictModel, {c1: 2, c2: "a"})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model with default values" do
|
||||||
|
expect_model("1,a", ModelWithDefaults, {c0: 1, c1: "a"})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model using default values if columns are missing" do
|
||||||
|
expect_model("1", ModelWithDefaults, {c0: 1, c1: "c"})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model using default values if values are nil and types are non nilable" do
|
||||||
|
expect_model("1,NULL", ModelWithDefaults, {c0: 1, c1: "c"})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model with nilables if columns are missing" do
|
||||||
|
expect_model("1", ModelWithNilables, {c0: 1, c1: nil})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model with nilables ignoring default value if NULL" do
|
||||||
|
expect_model("NULL,a", ModelWithNilables, {c0: nil, c1: "a"})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model with nil union types if columns are missing" do
|
||||||
|
expect_model("1", ModelWithNilUnionTypes, {c0: 1, c1: nil})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model with nil union types ignoring default value if NULL" do
|
||||||
|
expect_model("NULL,a", ModelWithNilUnionTypes, {c0: nil, c1: "a"})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model with different keys" do
|
||||||
|
expect_model("1,a", ModelWithKeys, {foo: 1, bar: "a"})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model with a value converter" do
|
||||||
|
expect_model("Zm9v,a", ModelWithConverter, {c0: "foo".to_slice, c1: "a"})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model with an initialize" do
|
||||||
|
obj1 = from_dummy("1,a", ModelWithInitialize)
|
||||||
|
obj2 = ModelWithInitialize.new(1, "a")
|
||||||
|
obj1.should eq obj2
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should initialize a model with JSON serialization also defined" do
|
||||||
|
expect_model("1,a", ModelWithJSON, {c0: 1, 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 = SimpleModel.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 "Class.from_rs should close resultset" do
|
||||||
|
with_dummy do |db|
|
||||||
|
rs = db.query("1,a 2,b")
|
||||||
|
objs = SimpleModel.from_rs(rs)
|
||||||
|
rs.closed?.should be_true
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
it "should initialize from a query_one" do
|
||||||
|
with_dummy do |db|
|
||||||
|
obj = db.query_one "1,a", as: SimpleModel
|
||||||
|
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: SimpleModel
|
||||||
|
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
|
|
@ -199,3 +199,4 @@ require "./db/pool_unprepared_statement"
|
||||||
require "./db/result_set"
|
require "./db/result_set"
|
||||||
require "./db/error"
|
require "./db/error"
|
||||||
require "./db/mapping"
|
require "./db/mapping"
|
||||||
|
require "./db/serializable"
|
||||||
|
|
180
src/db/serializable.cr
Normal file
180
src/db/serializable.cr
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
module DB
|
||||||
|
annotation Field
|
||||||
|
end
|
||||||
|
|
||||||
|
# The `DB::Serialization` module automatically generates methods for DB serialization when included.
|
||||||
|
#
|
||||||
|
# Once included, `ResultSet#read(t)` populates properties of the class from the
|
||||||
|
# `ResultSet`.
|
||||||
|
#
|
||||||
|
# ### Example
|
||||||
|
#
|
||||||
|
# ```crystal
|
||||||
|
# require "db"
|
||||||
|
#
|
||||||
|
# class Employee
|
||||||
|
# include DB::Serializable
|
||||||
|
#
|
||||||
|
# property title : String
|
||||||
|
# property name : String
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# employees = Employee.from_rs(db.query("SELECT title, name FROM employees"))
|
||||||
|
# employees[0].title # => "Manager"
|
||||||
|
# employees[0].name # => "John"
|
||||||
|
# ```
|
||||||
|
#
|
||||||
|
# ### Usage
|
||||||
|
#
|
||||||
|
# `DB::Serializable` was designed in analogue with `JSON::Serializable`, so usage is identical.
|
||||||
|
# However, like `DB.mapping`, `DB::Serializable` is **strict by default**, so extra columns will raise `DB::MappingException`s.
|
||||||
|
#
|
||||||
|
# Similar to `JSON::Field`, there is an annotation `DB::Field` that can be used to set serialization behavior
|
||||||
|
# on individual instance variables.
|
||||||
|
#
|
||||||
|
# ```crystal
|
||||||
|
# class Employee
|
||||||
|
# include DB::Serializable
|
||||||
|
#
|
||||||
|
# property title : String
|
||||||
|
#
|
||||||
|
# @[DB::Field(key: "firstname")]
|
||||||
|
# property name : String?
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
|
#
|
||||||
|
# `DB::Field` properties:
|
||||||
|
# * **ignore**: if `true`, skip this field in serialization and deserialization (`false` by default)
|
||||||
|
# * **key**: defines which column to read from a `ResultSet` (name of the instance variable by default)
|
||||||
|
# * **converter**: defines an alternate type for parsing results. The given type must define `#from_rs(DB::ResultSet)` and return an instance of the included type.
|
||||||
|
#
|
||||||
|
# ### `DB::Serializable::NonStrict`
|
||||||
|
#
|
||||||
|
# Including this module is functionally identical to passing `{strict: false}` to `DB.mapping`: extra columns will not raise.
|
||||||
|
#
|
||||||
|
# ```crystal
|
||||||
|
# class Employee
|
||||||
|
# include DB::Serializable
|
||||||
|
# include DB::Serializable::NonStrict
|
||||||
|
#
|
||||||
|
# property title : String
|
||||||
|
# property name : String
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# # does not raise!
|
||||||
|
# employees = Employee.from_rs(db.query("SELECT title, name, age FROM employees"))
|
||||||
|
# ```
|
||||||
|
module Serializable
|
||||||
|
macro included
|
||||||
|
include ::DB::Mappable
|
||||||
|
|
||||||
|
# Define a `new` and `from_rs` directly in the type, like JSON::Serializable
|
||||||
|
# For proper overload resolution
|
||||||
|
|
||||||
|
def self.new(rs : ::DB::ResultSet)
|
||||||
|
instance = allocate
|
||||||
|
instance.initialize(__set_for_db_serializable: rs)
|
||||||
|
GC.add_finalizer(instance) if instance.responds_to?(:finalize)
|
||||||
|
instance
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_rs(rs : ::DB::ResultSet)
|
||||||
|
objs = Array(self).new
|
||||||
|
rs.each do
|
||||||
|
objs << self.new(rs)
|
||||||
|
end
|
||||||
|
objs
|
||||||
|
ensure
|
||||||
|
rs.close
|
||||||
|
end
|
||||||
|
|
||||||
|
# Inject the class methods into subclasses as well
|
||||||
|
|
||||||
|
macro inherited
|
||||||
|
def self.new(rs : ::DB::ResultSet)
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_rs(rs : ::DB::Result_set)
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(*, __set_for_db_serializable rs : ::DB::ResultSet)
|
||||||
|
{% begin %}
|
||||||
|
{% properties = {} of Nil => Nil %}
|
||||||
|
{% for ivar in @type.instance_vars %}
|
||||||
|
{% ann = ivar.annotation(::DB::Field) %}
|
||||||
|
{% unless ann && ann[:ignore] %}
|
||||||
|
{%
|
||||||
|
properties[ivar.id] = {
|
||||||
|
type: ivar.type,
|
||||||
|
key: ((ann && ann[:key]) || ivar).id.stringify,
|
||||||
|
default: ivar.default_value,
|
||||||
|
nilable: ivar.type.nilable?,
|
||||||
|
converter: ann && ann[:converter],
|
||||||
|
}
|
||||||
|
%}
|
||||||
|
{% end %}
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
{% for name, value in properties %}
|
||||||
|
%var{name} = nil
|
||||||
|
%found{name} = false
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
rs.each_column do |col_name|
|
||||||
|
case col_name
|
||||||
|
{% for name, value in properties %}
|
||||||
|
when {{value[:key]}}
|
||||||
|
%found{name} = true
|
||||||
|
%var{name} =
|
||||||
|
{% 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
|
||||||
|
rs.read # Advance set, but discard result
|
||||||
|
on_unknown_db_column(col_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{% for key, value in properties %}
|
||||||
|
{% unless value[:nilable] || value[:default] != nil %}
|
||||||
|
if %var{key}.is_a?(Nil) && !%found{key}
|
||||||
|
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}} = %found{key} ? %var{key} : {{value[:default]}}
|
||||||
|
{% else %}
|
||||||
|
@{{key}} = %var{key}
|
||||||
|
{% end %}
|
||||||
|
{% elsif value[:default] != nil %}
|
||||||
|
@{{key}} = %var{key}.is_a?(Nil) ? {{value[:default]}} : %var{key}
|
||||||
|
{% else %}
|
||||||
|
@{{key}} = %var{key}.as({{value[:type]}})
|
||||||
|
{% end %}
|
||||||
|
{% end %}
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
||||||
|
protected def on_unknown_db_column(col_name)
|
||||||
|
raise ::DB::MappingException.new("unknown result set attribute: #{col_name}")
|
||||||
|
end
|
||||||
|
|
||||||
|
module NonStrict
|
||||||
|
protected def on_unknown_db_column(col_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue