safe_and_sound 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8ea298c0193785c3c404e220a34f34bd30f00ced83729c1dc576010e015b7165
4
+ data.tar.gz: 38455c727045bccff6dfa5cb17795960fcd51356f942aed7c6b982de21267482
5
+ SHA512:
6
+ metadata.gz: 395c899676cbd7656ace2655062f68b191798cec6ff9a68bdee5bf7b889b9cfd8ace657f74c1aaefc2d7a214fc1f8c4d6463f531064641e9febc31233a8b6b71
7
+ data.tar.gz: 749967441723e4dea3d01d43bb41cae4d6661a8826eb511b5431181098ad76a372c9634307a47069c2800d476f868202b3d823a2bdb5e74a6fa30b6ffbe30d2f
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1 (04-Apr-22)
4
+
5
+ * Initial release
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ ![Unit tests](https://github.com/axelerator/safe_and_sound/actions/workflows/run-tests.yml/badge.svg)
2
+
3
+ # Safe and Sound: Sum Data Types and utilities for Ruby
4
+
5
+ This library gives you and alternative syntax to declare new types/classes.
6
+ It's inspired by the concise syntax to declare new types in [Elm](https://guide.elm-lang.org/types/custom_types.html) or [Haskell](https://www.schoolofhaskell.com/user/Gabriel439/sum-types)
7
+ These types share some properties with types referred to as _algebaric data types_, _sum types_ or _union types_.
8
+
9
+ We can model similar relationships more verbosely in plain Ruby with classes and subclasses.
10
+ This library provides some syntactic shortcuts to create these hierarchy.
11
+
12
+ ```ruby
13
+ Vehicle = SafeAndSound.new(
14
+ Car: { horsepower: Integer },
15
+ Bike: { gears: Integer}
16
+ )
17
+ ```
18
+
19
+ This will create an abstract base class `Vehicle`.
20
+ Instances can only be created for the __concrete__ subclasses `Car` or `Bike`.
21
+ The class names act as "constructor" functions and values created that way are immutable.
22
+
23
+ ```ruby
24
+ car = Vehicle.car(horsepower: 100)
25
+ puts car.horsepower # 100
26
+
27
+ bike = Vehicle.bike(gears: 'twentyone')
28
+ # SafeAndSound::WrgonConstructorArgType (gears must be of type Integer but was String)
29
+ ```
30
+
31
+ `nil` is **not** a valid constructor argument. Optional values can be modeled with the [`Maybe` type](examples/maybe.rb) that is also provided with the library.
32
+
33
+ To add polymorphic behavior we can write functions __without__ having to touch the new types themselves.
34
+
35
+ By including the `SafeAndSound::Functions` module we get access to the `chase` function.
36
+ It immitates the `case` statement but uses the knowledge about our types to make it more safe.
37
+
38
+ ```ruby
39
+ include SafeAndSound::Functions
40
+
41
+ def to_human(vehicle)
42
+ chase vehicle do
43
+ Vehicle::Car, -> { "I'm a car with #{horsepower} horsepower" }
44
+ Vehicle::Bike, -> { "I'm a bike with #{gears} gears" }
45
+ end
46
+ end
47
+ ```
48
+
49
+ This offers a stricter version of the case statement.
50
+ Specifically it makes sure that all variants are handled (unless an `otherwise` block is given).
51
+ This check will still be only performed at runtime, but as long as there is at least one test executing this
52
+ `chase` expression we'll get an early, precise exception telling us what's missing.
53
+
54
+ If you want a more detailed explanation why working with such objects can be appealing I recommend you watch the
55
+ [Functional Core, Imperative Shell](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell)
56
+ episode of the _Destroy all software_ screencast.
57
+
58
+ I'm not trying to change how Ruby code is written. This is merely an experiment how far the sum type concept can be taken in terms
59
+ of making a syntax for it look like the syntax in languages where this concept is more central.
60
+
61
+
62
+
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeAndSound
4
+ ##
5
+ # Include this module to get access to functions that
6
+ # work on the algebraic data types.
7
+ module Functions
8
+ ##
9
+ # This is an interpretation of Ruby's case statement for
10
+ # ADTs.
11
+ def chase(value, &block)
12
+ Chase.new(value).run(block)
13
+ end
14
+ end
15
+
16
+ class MatchedChaseValueNotAVariant < StandardError; end
17
+ class MissingChaseBranch < StandardError; end
18
+ class DuplicateChaseBranch < StandardError; end
19
+ class ChaseValueNotAVariant < StandardError; end
20
+
21
+ ##
22
+ # Instance represent one application of a 'chase'
23
+ # statement.
24
+ class Chase
25
+ NO_MATCH = Object.new
26
+ NO_FALLBACK = Object.new
27
+
28
+ def initialize(object)
29
+ unless object.respond_to?(:variant_type?)
30
+ raise ChaseValueNotAVariant,
31
+ "Matched value in chase expression must be a variant but is a #{object.class.name}"
32
+ end
33
+
34
+ @result = NO_MATCH
35
+ @object = object
36
+ @to_match = object.class.superclass.variants.dup
37
+ @otherwise = NO_FALLBACK
38
+ end
39
+
40
+ def run(block)
41
+ instance_eval(&block)
42
+ unless @to_match.empty? || @otherwise != NO_FALLBACK
43
+ raise MissingChaseBranch,
44
+ "Missing branches for variants: #{@to_match.map(&:variant_name).join(',')}"
45
+ end
46
+ return @otherwise if @result == NO_MATCH
47
+
48
+ @result
49
+ end
50
+
51
+ def wenn(variant, lmda)
52
+ unless variant.is_a?(Class) && variant.superclass == @object.class.superclass
53
+ raise MatchedChaseValueNotAVariant,
54
+ "The value matched against must be a variant of #{@object.class.superclass.name} "\
55
+ "but is a #{variant.class}"
56
+ end
57
+ unless @to_match.delete(variant)
58
+ raise DuplicateChaseBranch,
59
+ "There are multiple branches for variant: #{variant.variant_name}"
60
+ end
61
+ return unless @result == NO_MATCH
62
+
63
+ @result = @object.instance_exec(&lmda) if @object.is_a? variant
64
+ end
65
+
66
+ def otherwise(value)
67
+ @otherwise = value.call
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeAndSound
4
+ Maybe = SafeAndSound.new(
5
+ Just: { value: Object },
6
+ Nothing: {}
7
+ )
8
+
9
+ module MaybeMethods
10
+ def with_default(default)
11
+ chase self do
12
+ wenn Maybe::Just, -> { value }
13
+ wenn Maybe::Nothing, -> { default }
14
+ end
15
+ end
16
+ end
17
+
18
+ module MaybeClassMethods
19
+ include SafeAndSound::Functions
20
+ def values(maybes)
21
+ maybes.inject([]) do |sum, maybe|
22
+ chase maybe do
23
+ wenn Maybe::Just, -> { sum.push(value) }
24
+ wenn Maybe::Nothing, -> { sum }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ Maybe.include(MaybeMethods)
30
+ Maybe.extend(MaybeClassMethods)
31
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeAndSound
4
+ Result = SafeAndSound.new(
5
+ Ok: { value: Object },
6
+ Err: { error: Object }
7
+ )
8
+ ##
9
+ # Module with methods we want to add to our Result type
10
+ module ResultMethods
11
+ def and_then(&block)
12
+ chase self do
13
+ wenn Result::Ok, -> { block.call(value) }
14
+ wenn Result::Err, -> { self }
15
+ end
16
+ end
17
+ end
18
+ Result.include ResultMethods
19
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeAndSound
4
+ class BaseTypeCannotBeInstantiated < StandardError; end
5
+
6
+ ##
7
+ # base class for the newly defined types
8
+ class Type
9
+ def initialize
10
+ raise BaseTypeCannotBeInstantiated, 'You cannot create instances of this type directly but only of its variants'
11
+ end
12
+
13
+ class << self
14
+ attr_accessor :variants
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeAndSound
4
+ class MissingConstructorArg < StandardError; end
5
+ class UnknownConstructorArg < StandardError; end
6
+ class WrgonConstructorArgType < StandardError; end
7
+
8
+ ##
9
+ # base class for a variant of the newly defined types
10
+ module Variant
11
+ ##
12
+ # Mixin used in Variant types to initialize fields from
13
+ # constructor arguments.
14
+ module Initializer
15
+ def initialize_fields(**args)
16
+ missing_fields = self.class.fields.keys
17
+ args.each do |field_name, value|
18
+ initialize_field(field_name, value)
19
+ missing_fields.delete(field_name)
20
+ end
21
+
22
+ return if missing_fields.empty?
23
+
24
+ raise MissingConstructorArg, "Not all constructor arguments were supplied: #{missing_fields}"
25
+ end
26
+
27
+ def to_s
28
+ "<#{self.class.superclass.name}:#{self.class.variant_name} ..."
29
+ end
30
+
31
+ def variant_type?
32
+ true
33
+ end
34
+
35
+ private
36
+
37
+ def initialize_field(field_name, value)
38
+ field_type = self.class.fields[field_name]
39
+ if field_type.nil?
40
+ raise UnknownConstructorArg,
41
+ "#{self.class.name} does not have a constructor argument #{field_name}"
42
+ end
43
+
44
+ unless value.is_a?(field_type)
45
+ raise WrgonConstructorArgType,
46
+ "#{field_name} must be of type #{field_type} but was #{value.class.name}"
47
+ end
48
+ instance_variable_set("@#{field_name}", value)
49
+ end
50
+ end
51
+
52
+ def self.build(variant_name, fields, parent_type)
53
+ new_variant = create_variant_type(parent_type)
54
+ new_variant.fields = fields
55
+ new_variant.variant_name = variant_name
56
+ fields.each { |field, _| new_variant.attr_reader field }
57
+ parent_type.const_set(variant_name.to_s, new_variant)
58
+ parent_type.define_singleton_method(variant_name) do |**args|
59
+ new_variant.new(**args)
60
+ end
61
+ new_variant
62
+ end
63
+
64
+ def self.create_variant_type(parent_type)
65
+ Class.new(parent_type) do
66
+ include(Initializer)
67
+ include(Functions)
68
+ class << self
69
+ attr_accessor :fields, :variant_name
70
+ end
71
+
72
+ def initialize(**args)
73
+ initialize_fields(**args)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeAndSound
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'safe_and_sound/functions'
4
+ require 'safe_and_sound/type'
5
+ require 'safe_and_sound/variant'
6
+ require 'safe_and_sound/version'
7
+
8
+ ##
9
+ # Namespace for the safe_and_sound classes
10
+ module SafeAndSound
11
+ def self.new(**variants)
12
+ new_type = Class.new(Type)
13
+
14
+ new_type.variants =
15
+ variants.map do |variant_name, fields|
16
+ Variant.build(variant_name, fields, new_type)
17
+ end
18
+ new_type
19
+ end
20
+ end
21
+
22
+ require 'safe_and_sound/result'
23
+ require 'safe_and_sound/maybe'
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: safe_and_sound
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Axel Tetzlaff
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-04-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 13.0.6
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 13.0.6
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.15'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.15'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest-reporters
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.5'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.26'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.26'
69
+ description: A compact DSL to let you declare sum data types and define safe functions
70
+ on them.
71
+ email: axel.tetzlaff@gmx.de
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files:
75
+ - README.md
76
+ files:
77
+ - CHANGELOG.md
78
+ - README.md
79
+ - lib/safe_and_sound.rb
80
+ - lib/safe_and_sound/functions.rb
81
+ - lib/safe_and_sound/maybe.rb
82
+ - lib/safe_and_sound/result.rb
83
+ - lib/safe_and_sound/type.rb
84
+ - lib/safe_and_sound/variant.rb
85
+ - lib/safe_and_sound/version.rb
86
+ homepage: https://github.com/axelerator/safe_and_sound
87
+ licenses:
88
+ - MIT
89
+ metadata:
90
+ rubygems_mfa_required: 'true'
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: 2.5.0
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 3.1.6
107
+ signing_key:
108
+ specification_version: 4
109
+ summary: Sum Data Types
110
+ test_files: []