From c6423bf295865465d375576c4de3d309c38c1112 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Sat, 27 Apr 2019 20:37:08 -0500 Subject: [PATCH] Dhall::Coder Full "to basic ruby types" deserializing, set up as a drop-in for ActiveRecord::Base#serialize --- Makefile | 2 +- lib/dhall.rb | 1 + lib/dhall/coder.rb | 189 +++++++++++++++++++++++++++++ lib/dhall/util.rb | 18 +++ test/test_coder.rb | 289 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 lib/dhall/coder.rb create mode 100644 test/test_coder.rb diff --git a/Makefile b/Makefile index 856599b..7f4835c 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ test: lib/dhall/parser.citrus bundle exec ruby -E UTF-8 test/test_suite.rb unit: lib/dhall/parser.citrus - bundle exec ruby -E UTF-8 test/test_suite.rb -n'/unit|import|TestReadme|TestLoad|TestAsDhall|TestResolvers|TestBinary|TestAsJson/' + bundle exec ruby -E UTF-8 test/test_suite.rb -n'/unit|import|TestReadme|TestLoad|TestAsDhall|TestResolvers|TestBinary|TestAsJson|TestCoder/' clean: $(RM) lib/dhall/parser.citrus diff --git a/lib/dhall.rb b/lib/dhall.rb index 8449e3b..f28ba66 100644 --- a/lib/dhall.rb +++ b/lib/dhall.rb @@ -4,6 +4,7 @@ require "dhall/as_dhall" require "dhall/ast" require "dhall/binary" require "dhall/builtins" +require "dhall/coder" require "dhall/normalize" require "dhall/parser" require "dhall/resolve" diff --git a/lib/dhall/coder.rb b/lib/dhall/coder.rb new file mode 100644 index 0000000..c6c2677 --- /dev/null +++ b/lib/dhall/coder.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "psych" + +module Dhall + class Coder + JSON_LIKE = [ + ::Array, ::Hash, + ::TrueClass, ::FalseClass, ::NilClass, + ::Integer, ::Float, ::String + ].freeze + + class Verifier + def initialize(*classes) + @classes = classes + @matcher = ValueSemantics::Either.new(classes) + end + + def verify_class(klass, op) + if @classes.any? { |safe| klass <= safe } + klass + else + raise ArgumentError, "#{op} does not match "\ + "#{@classes.inspect}: #{klass}" + end + end + + def verify(obj, op) + if @matcher === obj + obj + else + raise ArgumentError, "#{op} does not match "\ + "#{@classes.inspect}: #{obj.inspect}" + end + end + end + + def self.load(source, transform_keys: :to_s) + new.load(source, transform_keys: transform_keys) + end + + def self.dump(obj) + new.dump(obj) + end + + def initialize(default: nil, safe: JSON_LIKE) + @default = default + @verifier = Verifier.new(*Array(safe)) + @verifier.verify(default, "default value") + end + + def load_async(source, op="load_async", transform_keys: :to_s) + return Promise.resolve(@default) if source.nil? + return Promise.resolve(source) unless source.is_a?(String) + + Dhall.load(source).then do |expr| + decode(expr, op, transform_keys: transform_keys) + end + end + + def load(source, transform_keys: :to_s) + load_async(source, "load", transform_keys: transform_keys).sync + end + + module ToRuby + refine Expression do + def to_ruby + self + end + end + + refine Natural do + alias_method :to_ruby, :to_i + end + + refine Integer do + alias_method :to_ruby, :to_i + end + + refine Double do + alias_method :to_ruby, :to_f + end + + refine Text do + alias_method :to_ruby, :to_s + end + + refine Bool do + def to_ruby + self === true + end + end + + refine Record do + def to_ruby(&decode) + Hash[to_h.map { |k, v| [k, decode[v]] }] + end + end + + refine EmptyRecord do + def to_ruby + {} + end + end + + refine List do + def to_ruby(&decode) + to_a.map(&decode) + end + end + + refine Optional do + def to_ruby(&decode) + reduce(nil, &decode) + end + end + + refine Function do + def to_ruby(&decode) + ->(*args) { decode[expr.call(*args)] } + end + end + + refine Union do + def to_ruby + if !value.nil? && tag.match(/\A\p{Upper}/) && + Object.const_defined?(tag) + yield extract, Object.const_get(tag) + elsif extract == :None + nil + else + yield extract + end + end + end + + refine TypeAnnotation do + def to_ruby + yield value + end + end + end + + using ToRuby + + module InitWith + refine Object do + def init_with(coder) + coder.map.each do |k, v| + instance_variable_set(:"@#{k}", v) + end + end + end + end + + using InitWith + + def revive(klass, expr, op="revive", transform_keys: :to_s) + @verifier.verify_class(klass, op) + return klass.from_dhall(expr) if klass.respond_to?(:from_dhall) + + klass.allocate.tap do |o| + o.init_with(Util.psych_coder_for( + klass.name, + decode(expr, op, transform_keys: transform_keys) + )) + end + end + + def decode(expr, op="decode", klass: nil, transform_keys: :to_s) + return revive(klass, expr, op, transform_keys: transform_keys) if klass + @verifier.verify( + Util.transform_keys( + expr.to_ruby { |dexpr, dklass| + decode(dexpr, op, klass: dklass, transform_keys: transform_keys) + }, + &transform_keys + ), + op + ) + end + + def dump(obj) + return if obj.nil? + + Dhall.dump(@verifier.verify(obj, "dump")) + end + end +end diff --git a/lib/dhall/util.rb b/lib/dhall/util.rb index 8ed4eba..f2c2b8a 100644 --- a/lib/dhall/util.rb +++ b/lib/dhall/util.rb @@ -85,6 +85,18 @@ module Dhall end end + def self.psych_coder_for(tag, v) + c = Psych::Coder.new(tag) + case v + when Hash + c.map = v + when Array + c.seq = v + else + c.scalar = v + end + end + def self.psych_coder_from(tag, o) coder = Psych::Coder.new(tag) @@ -98,5 +110,11 @@ module Dhall coder end + + def self.transform_keys(hash_or_not) + return hash_or_not unless hash_or_not.is_a?(Hash) + + Hash[hash_or_not.map { |k, v| [(yield k), v] }] + end end end diff --git a/test/test_coder.rb b/test/test_coder.rb new file mode 100644 index 0000000..0bb207f --- /dev/null +++ b/test/test_coder.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +require "minitest/autorun" + +require "dhall" + +class TestCoder < Minitest::Test + def test_dump_integer + assert_equal "\x82\x0F\x01".b, Dhall::Coder.dump(1) + end + + def test_dump_integer_negative + assert_equal "\x82\x10 ".b, Dhall::Coder.dump(-1) + end + + def test_dump_float + assert_equal "\xFA?\x80\x00\x00".b, Dhall::Coder.dump(1.0) + end + + def test_dump_string + assert_equal "\x82\x12ehello".b, Dhall::Coder.dump("hello") + end + + def test_dump_nil + assert_nil Dhall::Coder.dump(nil) + end + + def test_dump_array + assert_equal( + "\x84\x04\xF6\x82\x0F\x01\x82\x0F\x02".b, + Dhall::Coder.dump([1, 2]) + ) + end + + def test_dump_array_with_nil + assert_equal( + "\x84\x04\xF6\x83\x05\xF6\x82\x0F\x01\x83\x00dNonegNatural".b, + Dhall::Coder.dump([1, nil]) + ) + end + + def test_dump_array_heterogenous + assert_equal( + "\x86\x04\xF6\x83\x00\x83\t\x82\v\xA4fDoublefDoublegNatural" \ + "gNaturaldNone\xF6dTextdTextgNatural\x82\x0F\x01\x83\t\x82\v\xA4" \ + "fDoublefDoublegNaturalgNaturaldNone\xF6dTextdTextdNone\x83\x00\x83" \ + "\t\x82\v\xA4fDoublefDoublegNaturalgNaturaldNone\xF6dTextdText" \ + "fDouble\xFA?\x80\x00\x00\x83\x00\x83\t\x82\v\xA4fDoublefDoubleg" \ + "NaturalgNaturaldNone\xF6dTextdTextdText\x82\x12ehello".b, + Dhall::Coder.dump([1, nil, 1.0, "hello"]) + ) + end + + def test_dump_hash + assert_equal( + "\x82\b\xA2aa\x82\x0F\x01ab\xFA?\x80\x00\x00".b, + Dhall::Coder.dump(a: 1, b: 1.0) + ) + end + + def test_dump_object + assert_raises ArgumentError do + Dhall::Coder.dump(Object.new) + end + end + + def test_load_loaded + assert_equal 1, Dhall::Coder.load(1) + end + + def test_load_integer + assert_equal 1, Dhall::Coder.load("\x82\x0F\x01".b) + end + + def test_load_integer_negative + assert_equal(-1, Dhall::Coder.load("\x82\x10 ".b)) + end + + def test_load_float + assert_equal 1.0, Dhall::Coder.load("\xFA?\x80\x00\x00".b) + end + + def test_load_string + assert_equal "hello", Dhall::Coder.load("\x82\x12ehello".b) + end + + def test_load_nil + assert_nil Dhall::Coder.load(nil) + end + + def test_load_array + assert_equal( + [1, 2], + Dhall::Coder.load("\x84\x04\xF6\x82\x0F\x01\x82\x0F\x02".b) + ) + end + + def test_load_array_with_nil + assert_equal( + [1, nil], + Dhall::Coder.load( + "\x84\x04\xF6\x83\x05\xF6\x82\x0F\x01\x83\x00dNonegNatural".b + ) + ) + end + + def test_load_array_heterogenous + assert_equal( + [1, nil, 1.0, "hello"], + Dhall::Coder.load( + "\x86\x04\xF6\x83\x00\x83\t\x82\v\xA4fDoublefDoublegNatural" \ + "gNaturaldNone\xF6dTextdTextgNatural\x82\x0F\x01\x83\t\x82\v\xA4" \ + "fDoublefDoublegNaturalgNaturaldNone\xF6dTextdTextdNone\x83\x00\x83" \ + "\t\x82\v\xA4fDoublefDoublegNaturalgNaturaldNone\xF6dTextdText" \ + "fDouble\xFA?\x80\x00\x00\x83\x00\x83\t\x82\v\xA4fDoublefDoubleg" \ + "NaturalgNaturaldNone\xF6dTextdTextdText\x82\x12ehello".b + ) + ) + end + + def test_load_hash + assert_equal( + { "a" => 1, "b" => 1.0 }, + Dhall::Coder.load("\x82\b\xA2aa\x82\x0F\x01ab\xFA?\x80\x00\x00".b) + ) + end + + def test_load_hash_symbolize + assert_equal( + { a: 1, b: 1.0 }, + Dhall::Coder.load( + "\x82\b\xA2aa\x82\x0F\x01ab\xFA?\x80\x00\x00".b, + transform_keys: :to_sym + ) + ) + end + + def test_load_object + assert_raises ArgumentError do + Dhall::Coder.load( + "\x83\x00\x83\t\x82\v\xA1fObject\x82\a\xA0fObject\x82\b\xA0".b + ) + end + end + + class Custom + attr_reader :a, :b + + def initialize + @a = true + @b = "true" + end + + def ==(other) + a == other.a && b == other.b + end + end + + def test_bad_default + assert_raises ArgumentError do + Dhall::Coder.new(safe: Custom) + end + end + + def test_dump_custom + assert_equal( + "\x83\x00\x83\t\x82\v\xA1qTestCoder::Custom\x82\a\xA2aadBoolabd" \ + "TextqTestCoder::Custom\x82\b\xA2aa\xF5ab\x82\x12dtrue".b, + Dhall::Coder.new(default: Custom.new, safe: Custom).dump(Custom.new) + ) + end + + def test_load_custom + coder = Dhall::Coder.new( + default: Custom.new, + safe: Dhall::Coder::JSON_LIKE + [Custom] + ) + assert_equal( + Custom.new, + coder.load( + "\x83\x00\x83\t\x82\v\xA1qTestCoder::Custom\x82\a\xA2aadBoolabd" \ + "TextqTestCoder::Custom\x82\b\xA2aa\xF5ab\x82\x12dtrue".b + ) + ) + end + + class CustomCoding + attr_reader :a, :b + + def initialize + @a = true + @b = "true" + end + + def ==(other) + a == other.a && b == other.b + end + + def init_with(coder) + @a = coder["abool"] + @b = coder["astring"] + end + + def encode_with(coder) + coder["abool"] = @a + coder["astring"] = @b + end + end + + def test_dump_custom_coding + assert_equal( + "\x83\x00\x83\t\x82\v\xA1wTestCoder::CustomCoding\x82\a\xA2" \ + "eabooldBoolgastringdTextwTestCoder::CustomCoding\x82\b\xA2" \ + "eabool\xF5gastring\x82\x12dtrue".b, + Dhall::Coder.new( + default: CustomCoding.new, + safe: CustomCoding + ).dump(CustomCoding.new) + ) + end + + def test_load_custom_coding + coder = Dhall::Coder.new( + default: CustomCoding.new, + safe: Dhall::Coder::JSON_LIKE + [CustomCoding] + ) + assert_equal( + CustomCoding.new, + coder.load( + "\x83\x00\x83\t\x82\v\xA1wTestCoder::CustomCoding\x82\a\xA2" \ + "eabooldBoolgastringdTextwTestCoder::CustomCoding\x82\b\xA2" \ + "eabool\xF5gastring\x82\x12dtrue".b + ) + ) + end + + class CustomDhall + using Dhall::AsDhall + + attr_reader :str + + def self.from_dhall(expr) + new(expr.to_s) + end + + def initialize(str="test") + @str = str + end + + def ==(other) + str == other.str + end + + def as_dhall + Dhall::Union.from( + Dhall::UnionType.new( + alternatives: { self.class.name => Dhall::Text.as_dhall } + ), + self.class.name, + @str.as_dhall + ) + end + end + + def test_dump_custom_dhall + assert_equal( + "\x83\x00\x83\t\x82\v\xA1vTestCoder::CustomDhalldText" \ + "vTestCoder::CustomDhall\x82\x12dtest".b, + Dhall::Coder.new( + default: CustomDhall.new, + safe: CustomDhall + ).dump(CustomDhall.new) + ) + end + + def test_load_custom_dhall + coder = Dhall::Coder.new( + default: CustomDhall.new, + safe: Dhall::Coder::JSON_LIKE + [CustomDhall] + ) + assert_equal( + CustomDhall.new, + coder.load( + "\x83\x00\x83\t\x82\v\xA1vTestCoder::CustomDhalldText" \ + "vTestCoder::CustomDhall\x82\x12dtest".b + ) + ) + end +end -- 2.38.5