mkunion

package module
v1.17.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2023 License: MIT Imports: 10 Imported by: 0

README

mkunion

Improves work with unions in golang by generating beautiful code (in other languages referred as sum types, variants, discriminators, tagged unions)

Project generates code for you, so you don't have to write it by hand. It's a good idea to use it when you have a lot of unions in your codebase.

What it offers?

  • Visitor interface with appropriate methods added to each union type
  • Default implementation of Visitor that simplifies work with unions
  • Reducer that can do recursive traversal (depth and breadth first) & default implementation of Reducer, fantastic for traversing ASTs

What it's useful for?

  • Custom DSL. When you want to create your own DSL, you can use this library to create AST for it. (./examples/ast)
  • State machine. When you need to manage state of your application, you can use this library to create states and transitions as unions and self document transitions using mermaid diagram format. (./examples/state)

Have fun! I hope you will find it useful.

Usage

Install mkunion

Make sure that you have installed mkunion and is in GOPATH/bin

go install github.com/widmogrod/mkunion/cmd/mkunion@v1.17
Create your first union

Create your first union. In our example it's a simple tree with Branch and Leaf nodes

package example

//go:generate mkunion -name=Tree
type (
    Branch struct{ L, R Tree }
    Leaf   struct{ Value int }
)
Generate code

Run

go generate ./...

Go will generate few files for you in the same location as union defnition

// source file
example/tree_example.go
// generated file
example/tree_example_mkunion_tree_default_reducer.go
example/tree_example_mkunion_tree_default_visitor.go
example/tree_example_mkunion_tree_reducer_bfs.go
example/tree_example_mkunion_tree_reducer_dfs.go
example/tree_example_mkunion_tree_visitor.go

Don't commit generated files to your repository. They are generated on the fly. In your CI/CD process you need to run go generate before testing & building your project.

Use generated code

With our example you may want to sum all values in tree.

To be precise, we want to sum values that Leaf struct holds. For example, such tree needs to be summed to 10:

tree := &Branch{
    L: &Leaf{Value: 1},
    R: &Branch{
        L: &Branch{
            L: &Leaf{Value: 2},
            R: &Leaf{Value: 3},
        },
        R: &Leaf{Value: 4},
    },
}

To sum up values in a tree we can do it in 3 ways. In all mkunion will help us to do it in a clean way.

1. Implement tree reducer with help of Match function

This approach is familiar with everyone who use functional programming.

  • In this approach you're responsible for defining how you want to travers tree. We will go with depth-first traversal.
  • MustMatchTree function will do type checking, you need to handle all cases.
func MyReduceDepthFirstTree[A any](x Tree, aggregate func (int, A) A, init A) A {
    // MustMatchTree is generated function my mkunion
    return MustMatchTree(
	    x, 
	    func (x *Leaf) B {
	        return aggregate(x.Value, init)
	    },
	    func (x *Branch) B {
	        // Note: here you define traversal order
	        // Right branch first, left branch second
	        return MyReduceDepthFirstTree(x.L, aggregate, MyReduceDepthFirstTree(x.R, f, init))
	    }, 
    )
}

You use this function like this:

result := MyReduceDepthFirstTree(tree, func (x, y int) int {
    return x + y
}, 0)
assert.Equal(t, 10, result)
2. Leverage generated default reduction with traversal strategies (depth first, breadth first)

You should use this approach

  • When you need to traverse tree in different way than a depth first, like breadth first without writing your own code
  • When you need to stop traversing of a tree at some point. For example, when you want to find a value in a tree, or meet some condition.

To demonstrate different traversal strategies, we will reduce a tree to a structure that will hold not only result of sum, but also order of nodes visited

// This structure will hold order of nodes visited, and resulting sum
type orderAgg struct {
    Order  []int
    Result int
}

// This is how we define reducer function for traversal of tree

var red TreeReducer[orderAgg] = &TreeDefaultReduction[orderAgg]{
    PanicOnFallback:      false,
    DefaultStopReduction: false,
    OnLeaf: func(x *Leaf, agg orderAgg) (orderAgg, bool) {
        return orderAgg{
            Order:  append(agg.Order, x.Value),
            Result: agg.Result + x.Value,
        }, false
    },
}

// Dept first traversal
result := ReduceTreeDepthFirst(red, tree, orderAgg{})
assert.Equal(t, 10, result.Result)
assert.Equal(t, []int{1, 2, 3, 4}, result.Order) // notice that order is different!

// Breadth first traversal
result = ReduceTreeBreadthFirst(red, tree, orderAgg{})
assert.Equal(t, 10, result.Result)
assert.Equal(t, []int{1, 4, 2, 3}, result.Order) // notice that order is different!

Note:

  • You can see that generated code knows how to traverse union recursively.
    • You can write flat code and don't worry about recursion.
  • Generator assumes that if in structure is reference to union type Tree, then it's recursive.
    • Such code can also work on slices. You can take a look at example/where_predicate_example.go to see something more complex
3. Implement visitor interface

This is most open way to traverse tree.

  • You have to implement TreeVisitor interface that was generated for you by mkunion tool.
  • You have to define how traversal should happen

This approach is better when you want to hold state or references in sumVisitor struct. In simple example this is not necessary, but in more complex cases you may store HTTP client, database connection or something else.

// assert that sumVisitor implements TreeVisitor interface
var _ TreeVisitor = (*sumVisitor)(nil)

// implementation of sumVisitor
type sumVisitor struct{}

func (s sumVisitor) VisitBranch(v *Branch) any {
    return v.L.AcceptTree(s).(int) + v.R.AcceptTree(s).(int)
}

func (s sumVisitor) VisitLeaf(v *Leaf) any {
    return v.Value
}

You can use sumVisitor like this:

assert.Equal(t, 10, tree.AcceptTree(&sumVisitor{}))
Use mkunion to simplify state management

Let's build our intuition first and crete simple state machine that increments counter using github.com/widmogrod/mkunion/x/machine package

Let's import the package

import "github.com/widmogrod/mkunion/x/machine"

Let's define our state machine

m := NewSimpleMachineWithState(func(cmd string, state int) (int, error) {
  switch cmd {
  case "inc":
    return state + 1, nil
  case "dec":
    return state - 1, nil
  default:
    return 0, fmt.Errorf("unknown cmd: %s", cmd)
  }
}, 10)

Now to increment or decrement counter we can do it like this:

err := m.Handle("inc")
assert.NoError(t, err)
assert.Equal(t, 11, m.State())

Simple, right?

Let's use mkunion crete more complex state machine

We learn how API of machine looks like. Let's complicate above example and use mkunion to express distinct commands and states.

We will build state machine to manage Tic Tac Toe game. I will not explain rules of Tic Tac Toe, and focus on how to use mkunion to model state transitions.

You can find full example in example

  • When we want to play a game, we need to start it first. CreateGameCMD is command that defines rules of the game
  • To allow other player to join the game we have JoinGameCMD
  • And lastly we need a command to make a move MakeMoveCMD

Here is how we define those interactions:

//go:generate mkunion -name=Command
type (
	CreateGameCMD struct {
		FirstPlayerID PlayerID
		BoardRows     int
		BoardCols     int
		WinningLength int
	}
	JoinGameCMD  struct{ 
		SecondPlayerID PlayerID 
	}
	MoveCMD struct {
		PlayerID PlayerID
		Position Move
	}
)

Next we need to rules of the game.

  • We cannot start a game without two players. GameWaitingForPlayer state will be used to indicate his.
  • When we have two players, we can start a game. GameInProgress state will be used to indicate his. This state allows to make moves.
  • When we have a winner, we can end a game. GameEndWithWin or GameEndWithDraw state will be used to indicate his. This state does not allow to make moves.
//go:generate mkunion -name=State
type (
	GameWaitingForPlayer struct {
		TicTacToeBaseState
	}

	GameProgress struct {
		TicTacToeBaseState

		NextMovePlayerID Move
		MovesTaken       map[Move]PlayerID
		MovesOrder       []Move
	}

	GameEndWithWin struct {
		TicTacToeBaseState

		Winner         PlayerID
		WiningSequence []Move
		MovesTaken     map[Move]PlayerID
	}
	GameEndWithDraw struct {
		TicTacToeBaseState

		MovesTaken map[Move]PlayerID
	}
)

Now we have to connect those rules by state transition.

This is how transition function looks like. Implementation is omitted for brevity, but whole code can be found in machine.go

func Transition(cmd Command, state State) (State, error) {
	return MustMatchCommandR2(
		cmd,
		func(x *CreateGameCMD) (State, error) {
			if state != nil {
				return nil, ErrGameAlreadyStarted
			}

			// validate game rules
			rows, cols, length := GameRules(x.BoardRows, x.BoardCols, x.WinningLength)

			return &GameWaitingForPlayer{
				TicTacToeBaseState: TicTacToeBaseState{
					FirstPlayerID: x.FirstPlayerID,
					BoardRows:     rows,
					BoardCols:     cols,
					WinningLength: length,
				},
			}, nil
		},
		func(x *JoinGameCMD) (State, error) {
			// omitted for brevity
		},
		func(x *MoveCMD) (State, error) {
          // omitted for brevity
		},
	)
}

We define Transition that use MustMatchCommandR2 that was generated by mkunion to manage state transition Now we will use github.com/widmogrod/mkunion/x/machine package to provide unifed API for state machine

m := machine.NewMachineWithState(Transition, nil)
err := m.Handle(&CreateGameCMD{
    FirstPlayerID: "player1",
    BoardRows:     3,
    BoardCols:     3,
    WinningLength: 3,
})
assert.NoError(t, err)
assert.Equal(t, &GameWaitingForPlayer{
    TicTacToeBaseState: TicTacToeBaseState{
        FirstPlayerID: "player1",
        BoardRows:     3,
        BoardCols:     3,
        WinningLength: 3,
    },
}, m.State())

This is it. We have created state machine that manages Tic Tac Toe game.

Now with power of x/schema transiting golang records and union types over network like JSON is easy.

// deserialise client commands
schemaCommand, err := schema.FromJSON(data)
cmd, err := schema.ToGo(schemaCommand)

// apply command to state (you may want to load it from database, s/schema package can help with that, it has DynamoDB schema support)
m := machine.NewMachineWithState(Transition, nil)
err = m.Handle(cmd)

// serialise state to send to client
schemaState := schema.FromGo(m.State())
data, err := schema.ToJSON(schemaState)

This is all. I hope you will find this useful.

Pattern matching

mkunion can generate pattern matching code for few diverse tuples, triples etc for you. Tou just need to define interface with methods that you want to pattern match:

//go:generate mkunion match -name=MyTriesMatch
type MyTriesMatch[T0, T1 Tree] interface {
    MatchLeafs(*Leaf, *Leaf)
    MatchBranches(*Branch, any)
    MatchMixed(any, any)
}

Example interface definition will be used to generate matching functions. Function will have name starting with interface name, and will have suffixes like R0, R1, R2, that indicate number of return values.

Below is example of generated code for MyTriesMatchR1 function:

MyTriesMatchR1(
    &Leaf{Value: 1}, &Leaf{Value: 3},
    func(x0 *Leaf, x1 *Leaf) int {
		// your matching code goes here
    },
    func(x0 *Branch, x1 any) int {
        // your matching code goes here
    },
    func(x0 any, x1 any) int {
        // your matching code goes here
    },
)

Note: Current implementation don't test if pattern matchers are exhaustive. You can easily create pattern matchers that will never be called, just make (any, any) case a first invocation, and none fo the other cases will be ever called.

In future version, you can expect that this will change.

More examples

Please take a look at ./example directory. It contains more examples of generated code.

Have fun! I hope you will find it useful.

Development & contribution

When you want to contribute to this project, go for it! Unit test are must have for any PR.

Other than that, nothing special is required. You may want to create issue to describe your idea before you start working on it. That will help other developers to understand your idea and give you feedback.

go generate ./...
go test ./...

Roadmap ideas

V1.0.x
  • Add visitor generation for unions
  • Add support for depth-first traversal
  • Add support for slice []{Variant} type traversal
V1.1.x
  • Add support for map[any]{Variant} type
V1.2.x
  • Add breadth-first reducer traversal
V1.3.x
  • Use go:embed for templates
V1.4.x
  • Add function generation like Match to simplify work with unions
  • Benchmark implementation of Match vs Reducer (depth-first has close performance, but breadth-first is much slower)
V1.5.x
  • Add support for multiple go:generate mkunion in one file
V1.6.x
  • Add variant types inference
  • Add Unwrap method to OneOf
V1.7.x
  • MustMatch*R2 function return tuple as result
  • Introduce recursive schema prototype (github.com/widmogrod/mkunion/x/schema package)
  • Integrate with schema for json serialization/deserialization
  • mkunion can skip extensions -skip-extensions=<generator_name> to be generated
  • Remove OneOf to be the same as variant! (breaking change)
V1.8.x
  • Introduce github.com/widmogrod/mkunion/x/machine for simple state machine construction
V1.9.x
  • Introduce schema helper functions like Get(schema, location), As[int](schema, default), Reduce[A](schema, init, func(schema, A) A) A
  • Allow to have union with one element, this is useful for domain model that is not yet fully defined
  • x/schema breaking change. ToGo returns any and error. Use MustToGo to get panic on error
V1.10.x
  • Introduce Match* and Match*R2 functions, that offer possibility to specif behaviour when value is nil
V1.14.x
  • Introduce Match*R0 and MustMatch*R0 functions, that allow matching but don't return any value
V1.15.x
  • Union interface has method Accept{Varian} instead of just Accept. Thanks to that is possible to use the same type in multiple unions. Such feature is beneficial for domain modelling.
  • CLI mkunion change flag -types to -variants
V1.16.x
  • Pattern matching. Use munion match -name=MyMatcher where MyMather is interface. Function will generate functions that can pattern match and have return types MyMatcherR0, MyMatcherR1, MyMatcherR2
V1.17.x
  • Introduce self documenting state machines through tests README.md
V1.18.x
  • Exhaustive pattern matching checks during generation
  • Allow extending (embedding) base Visitor interface with external interface
  • Schema Registry should reject registration of names that are already registered!
  • Add configurable behaviour how schema should act when field is missing, but schema has a value for it
V2.x.x
  • Add support for generic union types
Removed from roadmap
  • [-] Allow to change visitor name form Visit* to i.e Handle*. Matcher functions are elastic enough that rename is not needed.

Documentation

Index

Constants

View Source
const (
	Program = "mkunion"
	Header  = `// Code generated by ` + Program + `. DO NOT EDIT.`
)

Variables

This section is empty.

Functions

func PtrStr

func PtrStr(x string) *string

Types

type Branching

type Branching struct {
	Lit  *string
	List *string
	Map  *string
}

type DeriveFuncMatchGenerator added in v1.16.0

type DeriveFuncMatchGenerator struct {
	Header      string
	PackageName string
	MatchSpec   MatchSpec
}

func (*DeriveFuncMatchGenerator) Generate added in v1.16.0

func (g *DeriveFuncMatchGenerator) Generate() ([]byte, error)

type FunctionMatchGenerator added in v1.4.0

type FunctionMatchGenerator struct {
	Header      string
	PackageName string
	MaxSize     int
}

func (*FunctionMatchGenerator) Generate added in v1.4.0

func (t *FunctionMatchGenerator) Generate() ([]byte, error)

type Generator

type Generator interface {
	Generate() ([]byte, error)
}

type InferredDeriveFuncMatchInfo added in v1.16.0

type InferredDeriveFuncMatchInfo struct {
	PackageName string
	// contains filtered or unexported fields
}

func InferDeriveFuncMatchFromFile added in v1.16.0

func InferDeriveFuncMatchFromFile(filename string) (*InferredDeriveFuncMatchInfo, error)

func (*InferredDeriveFuncMatchInfo) MatchSpec added in v1.16.0

func (f *InferredDeriveFuncMatchInfo) MatchSpec(name string) (*MatchSpec, error)

func (*InferredDeriveFuncMatchInfo) Visit added in v1.16.0

type InferredInfo

type InferredInfo struct {
	PackageName string
	Types       map[string]map[string][]Branching
	// contains filtered or unexported fields
}

func InferFromFile

func InferFromFile(filename string) (*InferredInfo, error)

func (*InferredInfo) ForVariantType

func (f *InferredInfo) ForVariantType(name string, types []string) map[string][]Branching

func (*InferredInfo) PossibleVariantsTypes added in v1.6.0

func (f *InferredInfo) PossibleVariantsTypes(unionName string) []string

func (*InferredInfo) Visit

func (f *InferredInfo) Visit(n ast.Node) ast.Visitor

type MatchBuilder added in v1.16.0

type MatchBuilder struct {
	// contains filtered or unexported fields
}

func NewMatchBuilder added in v1.16.0

func NewMatchBuilder() *MatchBuilder

func (*MatchBuilder) AddCase added in v1.16.0

func (b *MatchBuilder) AddCase(name string, inputs ...string) error

func (*MatchBuilder) Build added in v1.16.0

func (b *MatchBuilder) Build() (*MatchSpec, error)

func (*MatchBuilder) SetInputs added in v1.16.0

func (b *MatchBuilder) SetInputs(types ...string) error

func (*MatchBuilder) SetName added in v1.16.0

func (b *MatchBuilder) SetName(name string) error

type MatchSpec added in v1.16.0

type MatchSpec struct {
	Name   string
	Names  []string
	Inputs []string
	Cases  [][]string
}

type ReducerBreadthFirstGenerator added in v1.2.0

type ReducerBreadthFirstGenerator struct {
	Header      string
	Name        variantName
	Types       []typeName
	PackageName string
	Branches    map[typeName][]Branching
}

func (*ReducerBreadthFirstGenerator) Generate added in v1.2.0

func (t *ReducerBreadthFirstGenerator) Generate() ([]byte, error)

type ReducerDefaultReductionGenerator added in v1.2.0

type ReducerDefaultReductionGenerator struct {
	Header      string
	Name        variantName
	Types       []typeName
	PackageName string
}

func (*ReducerDefaultReductionGenerator) Generate added in v1.2.0

func (t *ReducerDefaultReductionGenerator) Generate() ([]byte, error)

type ReducerDepthFirstGenerator added in v1.2.0

type ReducerDepthFirstGenerator struct {
	Header      string
	Name        variantName
	Types       []typeName
	PackageName string
	Branches    map[typeName][]Branching
}

func (*ReducerDepthFirstGenerator) Generate added in v1.2.0

func (t *ReducerDepthFirstGenerator) Generate() ([]byte, error)

type SchemaGenerator added in v1.7.2

type SchemaGenerator struct {
	Header      string
	Types       []string
	Name        string
	PackageName string
}

func (*SchemaGenerator) Generate added in v1.7.2

func (g *SchemaGenerator) Generate() ([]byte, error)

type VisitorDefaultGenerator

type VisitorDefaultGenerator struct {
	Header      string
	Name        string
	Types       []string
	PackageName string
}

func (*VisitorDefaultGenerator) Generate

func (g *VisitorDefaultGenerator) Generate() ([]byte, error)

type VisitorGenerator

type VisitorGenerator struct {
	Header      string
	Types       []string
	Name        string
	PackageName string
}

func (*VisitorGenerator) Generate

func (g *VisitorGenerator) Generate() ([]byte, error)

Directories

Path Synopsis
cmd
ast
Package ast defines the AST for simple language AST can be created either by parser or by hand, it's up to implementer to decide how to create AST This package provides few examples of AST creation mostly by parsing JSON - ast_sugar.go - ast_human_friendly.go - ast_description_of_best_result Much more advance parser is also possible, but it's not implemented here
Package ast defines the AST for simple language AST can be created either by parser or by hand, it's up to implementer to decide how to create AST This package provides few examples of AST creation mostly by parsing JSON - ast_sugar.go - ast_human_friendly.go - ast_description_of_best_result Much more advance parser is also possible, but it's not implemented here
f
Code generated by mkfunc.
Code generated by mkfunc.
x
schema
Code generated by mkunion.
Code generated by mkunion.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL