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
// We will enrich our data model with thisModel: {// 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.
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
funcinit() {
{{ $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
returnnil}
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
returnnil, 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
returnnil }
// 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)
returnnil }
// 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 {
returnnil, 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)
}
{{ elseif (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.
funcUserReadHandler(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)
}
funcTodoCreateHandler(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 parameter 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.