~singpolyma/dhall-ruby

c6423bf295865465d375576c4de3d309c38c1112 — Stephen Paul Weber 3 years ago f5f10d0
Dhall::Coder

Full "to basic ruby types" deserializing, set up as a drop-in for
ActiveRecord::Base#serialize
5 files changed, 498 insertions(+), 1 deletions(-)

M Makefile
M lib/dhall.rb
A lib/dhall/coder.rb
M lib/dhall/util.rb
A test/test_coder.rb
M Makefile => Makefile +1 -1
@@ 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

M lib/dhall.rb => lib/dhall.rb +1 -0
@@ 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"

A lib/dhall/coder.rb => lib/dhall/coder.rb +189 -0
@@ 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

M lib/dhall/util.rb => lib/dhall/util.rb +18 -0
@@ 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

A test/test_coder.rb => test/test_coder.rb +289 -0
@@ 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