mkunion

module
v1.24.0 Latest Latest
Warning

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

Go to latest
Published: Jan 8, 2024 License: MIT

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.20
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_tree_gen.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. 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
  • x/shape library is integrated in extracting types from golang
  • munion shape-export --output-dir --input-go-file --type=typescript extract types from go file and generate typescript types
V1.19.x
  • Reduce number of generated defauls, to most essential ones: visitor - for interface, match functions, and schema for serialisation/deserialisation
  • mkunion offers include-extension for backward compatibility with previous versions
  • mkunion allows generation outside of //go:generate directive and now you can call it as mkunion -i=you/path/to/file.go -i=you/path/to/other/file.go
  • mkunion allows to generate code for multiple unions in one file. Use --no-compact to change behaviour to previous one
V1.20.x
  • mkunion significant refactoring to use internally x/shape for generation of types including union
  • mkunion introduce new json extension that generates json serialisation/deserialisation code which will replace schema extension in future
  • mkunion breaking change: remove -variants flag. Disable possibility to recompose union types from command line. In feature type aliases could be used for this purpose.
V1.21.x
  • mkunion breaking change: remove depth_first and breadth_first reducers generation. No replacement is planned. Use MustMatch* functions instead to write your own traversals.
V1.22.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.

Development

Setup containers and dependencies

dev/bootstrap.sh

Run tests

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

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.
shape
Code generated by mkunion.
Code generated by mkunion.
storage/predicate
Code generated by mkunion.
Code generated by mkunion.
storage/schemaless
Code generated by mkunion.
Code generated by mkunion.
storage/schemaless/projection
Code generated by mkunion.
Code generated by mkunion.
workflow
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