The Go standard library includes an 'ast' package, which provides a set of tools for working with the abstract syntax tree (AST). The AST represents the structure of a Go program in a tree-like data structure and can be used to analyze, modify, or generate code programmatically. In this post, we investigate the possibility of analyzing structs and generating a SQL query based on them.
Goal
Our goal involves generating a SQL query based on a given struct with specific formatting requirements. The struct name should be pluralized and lowercase, while the field names should be in snake_case and also lowercase.
For example, given the following struct
type User struct {
ID int
Name string
InvitationCode string
}
The generated SQL query should be an INSERT statement into a table named users with columns id, name, and invitation_code. The values to be inserted should be represented using placeholders in the query, such as :id, :name, and :invitation_code.
Formatting Requirements
Let's create some helper functions to normalize the structs and field names.
The first requirement we look at is to transform UpperCamelCase into snake_case
We can do this by simply iterating over the string and anytime we see a uppercase character and the previous character is lower case we insert a underscore _
func SnakeCase(s string) string {
var str strings.Builder
var prev rune
for i, r := range s {
// check if we should insert a underscore
if i > 0 && unicode.IsUpper(r) && unicode.IsLower(prev) {
str.WriteRune('_')
}
// lower case all characters
str.WriteRune(unicode.ToLower(r))
prev = r
}
return str.String()
}
Next up we want to pluralize the string,
this could be done in similar fashion to this
func Pluralize(s string) string {
if strings.HasSuffix(s, "y") {
return strings.TrimSuffix(s, "y") + "ies"
}
return s + "s"
}
As a little helper function lets add a NormalizeTableName() function which combines the above into one
// normalize the struct name to lowercase, pluralize it and apply snakeCase
// for example, User -> users, ReviewPost -> review_posts
func normalizeTableName(name string) string {
return pluralize(snakeCase(name))
}
With those helper functions out of the way let's have a look on how to parse and analyse the file.
To do this we can use the go/ast package, the ast package provides us with a set of tools for working with the abstract syntax tree.
File Parsing
The Go standard library comes with a handy package to parse Go source code files and generate a AST based on it.
func ExtractStructs(filePath string) []QueryBuilder {
// create a file set, this keeps track of file positions for error messages and other diagnostic output
fset := token.NewFileSet()
// parse the whole file including errors
parsedAST, err := parser.ParseFile(fset, filePath, nil, parser.AllErrors)
}
After parsing the file and checking for potential errors we use the go/ast package to inspect the abstract syntax tree.
Inspect traverses an AST in depth first order. If the function returns true, Inspect is called recursively.
ast.Inspect(parsedAST, func(n ast.Node) bool {
return true
})
In our case we are only interested if our node of type ast.TypeSpec and ast.StructType.
TypeSpec is needed to get the actual struct name and ast.StructType let's us iterate over the struct fields
ast.Inspect(parsedAST, func(n ast.Node) bool {
// try to convert n to ast.TypeSpec
if typeSpec, isTypeSpec := n.(*ast.TypeSpec); isTypeSpec {
s, isStructType := typeSpec.Type.(*ast.StructType)
// check if conversion was successful
if !isStructType {
return true
}
// get the struct name
structName := normalizeTableName(typeSpec.Name.Name)
// get Fields helper function
fields := getFields(s)
}
return true
})
As you've probably noticed there is another helper function getFields
The implementation looks like this.
func getFields(s *ast.StructType) []string {
fields := make([]string, len(s.Fields.List))
for i, field := range s.Fields.List {
if len(field.Names) == 0 {
continue
}
fields[i] = SnakeCase(field.Names[0].Name)
}
return fields
}
With this function we got all we need. We are able to extract the struct name and the field names additionally apply snake_case and pluralization respectively.
Query Generation
As we already extracted all the needed data we can introduce a simple function to generate the needed queries. To keep this nice and clean we use another struct which defines the needed data for a query and a struct method to return a insert query.
// define the QueryBuilder struct
type QueryBuilder struct {
TableName string
Fields []string
}
// Generate a insert query
func (q QueryBuilder) InsertQuery() string {
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (:%s)", strings.ToLower(q.TableName), strings.Join(q.Fields, ", "), strings.Join(q.Fields, ", :"))
}
// add more implementation for the needed queries
and at last let's update the ExtractStructs function and combine everything
func ExtractStructs(filePath string) []QueryBuilder {
fset := token.NewFileSet()
parsedAST, err := parser.ParseFile(fset, filePath, nil, parser.AllErrors)
if err != nil {
fmt.Println(err)
return nil
}
// Find all struct declarations
var structs []QueryBuilder
ast.Inspect(parsedAST, func(n ast.Node) bool {
// try to convert n to ast.TypeSpec
if typeSpec, isTypeSpec := n.(*ast.TypeSpec); isTypeSpec {
s, isStructType := typeSpec.Type.(*ast.StructType)
// check if conversion was successful
if !isStructType {
return true
}
// get the struct name
structName := typeSpec.Name.Name
// get Fields helper function
fields := getFields(s)
structs = append(structs, QueryBuilder{TableName: normalizeTableName(structName), Fields: fields})
}
return true
})
return structs
}
You can find the full code example here
Top comments (0)