Relations



To add relations to our types and code, we need to do a few things.

  • add relations to the schemas and example
  • update type template to add fields and helpers
  • update our custom code in the resource handlers

Updating Schemas

Recall that the core hof/schema/dm.#Datamodel has a #Relation with two strings, Reln and Type. To show you how we can calculate extra fields from existing ones, we will modify our example #Datamodel schema, which already extends hof’s core #Datamodel.

Here, we add a GoType field for template rendering

schema/dm.cue

#Model: dm.#Model & {

	// Adds GoType
	Relations: [string]: R={
		GoType: string

		// Switch pattern
		GoType: [
			if R.Reln == "BelongsTo" {"*\(R.Type)"},
			if R.Reln == "HasOne" {"*\(R.Type)"},
			if R.Reln == "HasMany" {"[]*\(R.Type)"},
			if R.Reln == "ManyToMany" {"[]*\(R.Type)"},
			"unknown relation type: \(R.Reln)",
		][0]
	}
}

Updating Design

We of course need to add the relations between our types to the datamodel we are using for our server. Note how we do not have to add GoType. It will still be available in our template input though.

We specify that a User has many Todos and that a Todo belongs to a User.

example/dm.cue

ServerDatamodel: schema.#Datamodel & {
	Models: {
		User: {
			Relations: {
				Todos: {
					Reln: "HasMany"
					Type: "Todo"
				}
			}
		}
		Todo: {
			Relations: {
				Author: {
					Reln: "BelongsTo"
					Type: "User"
				}
			}
		}
	}
}

Type Template

We now need to implement type relations in our templates. We show the whole file here as much was added. Note

  • We stored the top-level TYPE in $T so we can reference it in nested template scopes.
  • some edge cases in the *With* helpers are omitted and left to the user.

templates/type.go

{{ $ROOT := . }}
{{ $T := .TYPE }}

package types

import (
	"fmt"
)

// Represents a {{ .TYPE.Name }}
type {{ $T.Name }} struct {
	{{ range $T.OrderedFields }}
	{{ .Name }} {{ .Type }}
	{{- end }}

	{{ range $R := $T.Relations }}
	{{ $R.Name }} {{ $R.GoType }}
	{{ end }}
}

// A map type to store {{ .TYPE.Name }}
type {{ $T.Name }}Map map[string]*{{ $T.Name }}

// A var to work with
var {{ $T.Name }}Store {{ $T.Name }}Map

// Note, we are omitting locking and allowing concurrency issues

// initialize our storage
func init() {
	{{ $T.Name }}Store = make({{ $T.Name }}Map)
}

//
//// library funcs
//


func {{ $T.Name }}Create(in *{{ $T.Name }}) error {
	idx := in.{{ $T.Index }}

	// check if already exists
	if _, ok := {{ $T.Name }}Store[idx]; ok {
		return fmt.Errorf("Entry with %v already exists", idx)
	}

	// store the new value
	{{ $T.Name }}Store[idx] = in

	return nil
}

func {{ $T.Name }}Read(idx string) (*{{ $T.Name }}, error) {

	// return if exists
	if val, ok := {{ $T.Name }}Store[idx]; ok {
		return val, nil
	}

	// otherwise return error
	return nil, fmt.Errorf("Entry with %v does not exist", idx)
}

func {{ $T.Name }}List() ([]*{{ $T.Name }}, error) {
	ret := []*{{ $T.Name }}{}

	// return if exists
	for _, elem := range {{ $T.Name }}Store {
		ret = append(ret, elem)
	}

	return ret, nil
}

func {{ $T.Name }}Update(in *{{ $T.Name }}) error {
	idx := in.{{ $T.Index }}

	// replace if exists, note we are not dealing with partial updates here
	if _, ok := {{ $T.Name }}Store[idx]; ok {
		{{ $T.Name }}Store[idx] = in
		return nil
	}

	// otherwise return error
	return fmt.Errorf("Entry with %v does not exist", idx)
}

func {{ $T.Name }}Delete(idx string) error {

	// replace if exists, note we are not dealing with partial updates here
	if _, ok := {{ $T.Name }}Store[idx]; ok {
		delete({{ $T.Name }}Store, idx)
		return nil
	}

	// otherwise return error
	return fmt.Errorf("Entry with %v does not exist", idx)
}

{{ range $R := $T.Relations }}
{{/* 
	we need to look up the Model on the other side of the relation
	we use hof's dref custom template function
*/}}

{{- $M := (dref $R.Type $ROOT.DM.Models )}}
{{/* reverse lookup to find the relation which points back at our top-level TYPE for this template*/}}
{{ $D := ( printf "Relations.[:].[Type==%s]" $T.Name) }}
{{ $Reverse := (dref $D $M)}}
/*
{{ yaml $Reverse }}
*/

func {{ $T.Name }}ReadWith{{ $R.Name }}(idx string) (*{{ $T.Name }}, error) {

	val, ok := {{ $T.Name }}Store[idx]
	if !ok {
		return nil, fmt.Errorf("Entry with %v does not exist", idx)
	}

	// make copy, so we don't fill the relation in the store
	ret := *val

	for _, elem := range {{ $R.Type }}Store {
			// make copy, so we don't fill the relation in the store
		local := *elem
		{{ if (eq $R.Reln "HasMany" "ManyToMany") }}
		if local.{{ $Reverse.Name }}.{{ $T.Index }} == ret.{{ $T.Index }} {
			// avoid cyclic decoding by Echo return
			local.{{ $Reverse.Name }} = nil
			ret.{{ $R.Name }} = append(ret.{{ $R.Name }}, &local)	
		}
		{{ else if (eq $R.Reln "BelongsTo") }}
		if local.{{ $M.Index }} == ret.{{ $T.Index }} {
			ret.{{ $R.Name }} = &local
			break
		}
		{{ else }}
		if local.{{ $Reverse.Name }}.{{ $M.Index }} == ret.{{ $T.Index }} {
			ret.{{ $R.Name }} = &local
			break
		}
		{{ end }}
	}

	return &ret, nil
}
{{ end }}

Custom Code

We change

  • UserReadHandler to use the new library function UserReadWithTodos
  • TodoCreateHandler to have a username query parameter and assign the user. Note, we would normally determine the User with auth and context.
func UserReadHandler(c echo.Context) (err error) {

	username := c.Param("username")

	// note the changed function here
	u, err := types.UserReadWithTodos(username)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	return c.JSON(http.StatusOK, u)
}

func TodoCreateHandler(c echo.Context) (err error) {

	var t types.Todo
	if err := c.Bind(&t); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	username := c.QueryParam("username")
	if username == "" {
		return echo.NewHTTPError(http.StatusBadRequest, "missing query param 'username'")
	}

	u, err := types.UserReadWithTodos(username)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	t.Author = u

	if err := types.TodoCreate(&t); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	return c.String(http.StatusOK, "OK")
}

Query Param

We also need to add a query parameter to an automatically generated route from a resource from our datamodel.

For now, we added it manually to the handler, as in the code just above. We leave addition of this query paramater as a thought experiment.

  • Can you conditionally add it in the #DatamodelToResource helper? (hint, this is probably the complex, but better way, check for Reln == "BelongsTo")
  • Can you unconditionally add it through the example design? (hint, you will need to unify with the generator In)

Regen, Rebuild, Rerun

We can now hof gen ./examples/server.cue. If you see any merge conflicts, you will need to fix those first.

Try rebuilding, rerunning, and making calls to the server to create users and todos.

// make a user and a todo
curl -H 'Content-Type: application/json' -X POST -d '{ "Username": "bob", "Email": "bob@hof.io" }' localhost:8080/user
curl -H 'Content-Type: application/json' -X POST -d '{ "Title": "hello", "Content": "world" }' localhost:8080/todo?username=bob

// read a user and a todo
curl localhost:8080/user/bob
curl localhost:8080/todo/hello

Commentary

Some comments on the difficulties of data modeling, templating, and the spectrum of languages and technologies.

  • type and relation generation is going to language and technology specific
  • hof aims to maintain an abstract representation
  • users can extend to add the specifics needed
  • we expect that middle layers will capture common groups like SQL, OOP, and visibility (public/private)
  • best practice is to use labels which are not likely to conflict, can always namespace and nest

[Link to a more in depth discussion] of the issue with creating a schema for data models and the complexities with multiple

We’ll also see better ways to construct the library later, when we introduce Views.

2022 Hofstadter, Inc