From e1832fc3598877254a3f59f180f284e61ef1b918 Mon Sep 17 00:00:00 2001 From: Nick Clifford Date: Thu, 31 Oct 2019 09:34:23 -0500 Subject: [PATCH] Add `DB::Serializable` (#115) --- shard.yml | 2 +- spec/serializable_spec.cr | 220 ++++++++++++++++++++++++++++++++++++++ src/db.cr | 1 + src/db/serializable.cr | 180 +++++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 spec/serializable_spec.cr create mode 100644 src/db/serializable.cr diff --git a/shard.yml b/shard.yml index 7a22b36..f55ab94 100644 --- a/shard.yml +++ b/shard.yml @@ -4,6 +4,6 @@ version: 0.7.0 authors: - Brian J. Cardiff -crystal: 0.24.0 +crystal: 0.25.0 license: MIT diff --git a/spec/serializable_spec.cr b/spec/serializable_spec.cr new file mode 100644 index 0000000..1a43576 --- /dev/null +++ b/spec/serializable_spec.cr @@ -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 diff --git a/src/db.cr b/src/db.cr index bbe0e3d..2ca45ff 100644 --- a/src/db.cr +++ b/src/db.cr @@ -199,3 +199,4 @@ require "./db/pool_unprepared_statement" require "./db/result_set" require "./db/error" require "./db/mapping" +require "./db/serializable" diff --git a/src/db/serializable.cr b/src/db/serializable.cr new file mode 100644 index 0000000..63b6579 --- /dev/null +++ b/src/db/serializable.cr @@ -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