safe_and_sound 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []