Большинство современных сайтов реализуют некий MV*-фреймворк как формально, так и неформально. Если вы пишете много кода, скорее всего, вы пишете много моделей снова и снова. Они в основном похожи по структуре и отличаются только деталями схемы. Вы определяете SQL-схему, создаете структуры и соединяете некоторые базовые CRUD API. Затем вы подправляете всё это по мере развития логики приложения. Разве не здорово было бы автоматизировать что-то из этого, чтобы сократить и время, и количество ошибок? В этой статье мы сделаем именно это. Мы рассмотрим метапрограммирование в Go для автоматического создания простого CRUD API, основанного на определениях таблиц SQL.

Введение

Метапрограммирование —  это, в сущности, написание программы для написания программы. Таким образом, вы на один уровень абстракции выше конкретной программы и решаете задачу для класса программ. Известный пример этого  —  генераторы Rails (Ruby on Rails). Запустив new test rails, вы создадите полнофункциональный проект под названием test. Метапрограммирование обычно занимает немного больше времени, но в конце концов у вас появляется инструмент, который вы можете повторно использовать для решения схожих проблем.

Обычно я использую Postgres, поэтому хотел бы автоматически преобразовать набор определений таблиц SQL Create Table в код на Go. Синтаксис Postgres для CREATE TABLE невероятно богат, хотя это всего лишь одно выражение. К счастью, нам не нужно разбирать весь синтаксис, чтобы достичь цели. Чтобы построить CRUD API, нам нужно распознавать:

  1. Существующее выражение.
  2. Имя таблицы.
  3. Имена колонок.
  4. Типы данных.

Кажется, много? На самом деле это позволит игнорировать большинство необязательных частей синтаксиса CREATE TABLE. Мы можем искать, что хотим, игнорируя всё остальное.

Создадим простой компилятор для просеивания определений таблиц SQL, сохраняя необходимые части, чтобы генерировать наш новый код из найденных выражений. Компилятор будет состоять из трех основных частей: лексера, парсера и генератора. Грамматика основана на документации Postgres и выглядит так:

CREATE [some_stuff]* TABLE [IF NOT EXISTS] table_name (
    column_name data_type [some_stuff]*
    [, ...]
) [some_stuff]*;

Наш код должен пропускать некоторый код, но игнорировать остальной синтаксис SQL: например, мы должны разобрать опцию [IF NOT EXISTS]. Если мы будем рассматривать ее как произвольный текст, то можем пропустить table_name. Кроме того, для простоты мы не будем поддерживать table_constraints и подобные операторы в списке столбцов: это вынудит нас к гораздо более сложному синтаксическому анализу. Выражение должно заканчиваться на ;. Мы будем поддерживать все основные типы данных и переводить их в Go следующим образом:

SQL              GO  
----------------|-------                                                 
BOOLEAN          bool                                                 
BOOL             bool                                                 
CHAR(n)          string                                               
VARCHAR(n)       string                                               
TEXT             string                                               
SMALLINT         int16                                                
INT              int32                                                
INTEGER          int32                                                
BIGINT           int64                                                
SMALLSERIAL      int16                                                
SERIAL           int32                                                
BIGSERIAL        int64                                                
FLOAT(n)         float64                                              
REAL             float32                                              
FLOAT8           float32                                              
DECIMAL          float64                                              
NUMERIC          float64                                              
NUMERIC(p,s)     float64                                              
DOUBLE PRECISION float64                                              
DATE             time.Time                        
TIME             time.Time                
TIMESTAMPTZ      time.Time
TIMESTAMP        time.Time
INTERVAL         time.Time
JSON             []byte                                               
JSONB            []byte                                               
UUID             string
  • func CreateTableFoo(db *sql.DB) (err error){}
  • type Foo struct{}
  • func (foo *Foo) CreateFoo(db *sql.DB) (result Foo, err error){}
  • func (foo *Foo) RetrieveFoo(db *sql.DB) (result Foo, err error){}
  • func (foo *Foo) RetrieveAllFoo(db *sql.DB) (foo []Foo, err error){}
  • func (foo *Foo) UpdateFoo(db *sql.DB) (result Foo, err error){}
  • func (foo *Foo) DeleteFoo(db *sql.DB) (err error){}
  • func DeleteAllFoo(db *sql.DB) (err error){}

Лексический анализатор

Лексический анализ  —  это процесс обнаружения лексем в потоке символов. Это так же просто, как поиск слов, разделенных пробелами, или более распространенное распознавание определенных ключевых слов и идентификаторов. В Go есть пакеты для сканирования и лексического анализа. Я выбрал хорошо документированный пакет Lexmachine Тима Хендерсона.

Используя отличный пример Тима Dot lexer в качестве шаблона, мы можем построить лексер для нашего упрощенного подмножества SQL. Ключевые слова и литералы довольно просты: CREATE, TABLE, IF, NOT, EXISTS и наш список статических типов данных. 

Опять же, мы должны обнаружить опцию IF NOT EXISTS, чтобы устранить двусмысленность table_name. Литералы — это просто (),;.

Идентификаторы немного сложнее. Они включают в себя типы CHAR(n), VARCHAR(n), FLOAT(n), NUMERIC(p,s), ID(ID), table_name, column_name. Lexmachine использует регулярные выражения для идентификаторов. Вот наш код:

VARCHAR(n):   [vV][aA][rR][cC][hH][aA][rR]\([0-9]+\)
CHAR(n):      [cC][hH][aA][rR]\([0-9]+\)
FLOAT(n):     [fF][lL][oO][aA][tT]\([0-9]+\)
NUMERIC(p,s): [nN][uU][mM][eE][rR][iI][cC]\([0-9]+,[0-9]+\)
ID(ID):       ([a-z]|[A-Z]|_|#|@)([a-z]|[A-Z]|[0-9]|_|#|@|$)*
              \(([a-z]|[A-Z]|_|#|@)([a-z]|[A-Z]|[0-9]|_|#|@|$)*\)
ID:           ([a-z]|[A-Z]|_|#|@)([a-z]|[A-Z]|[0-9]|_|#|@|$)*

Обратите внимание, что Lexmachine пока не поддерживает такие режимы, как (?i), что объясняет приведенное выше регулярное выражение. ID требуется, чтобы разрешить ссылки, которые в противном случае были бы ошибками из-за скобок. ID может использоваться как для table_name, так и для column_name. Лексер пропустит любой пробел. Вот и всё, он готов.

metaapi/metasql/lexer.go:

// Основано на https://hackthology.com/writing-a-lexer-in-go-with-lexmachine.html 
package metasqlimport (
    "strings"lex "github.com/timtadh/lexmachine"
    "github.com/timtadh/lexmachine/machines"
)var Literals []string       
var Keywords []string       
var Tokens []string         
var TokenIds map[string]int 
var Lexer *lex.Lexer        // Вызывается при инициализации. Создаёт лексер и списки токенов.
func init() {
    initTokens()
    var err error
    Lexer, err = initLexer()
    if err != nil {
        panic(err)
    }
}
func initTokens() {
    Tokens = []string{
        "VARCHARID",
        "CHARID",
        "FLOATID",
        "NUMERICID",
        "REFID",
        "ID",
    }
    Keywords = []string{
        "CREATE",
        "TABLE",
        "IF",
        "NOT",
        "EXISTS",
        "BOOLEAN",
        "BOOL",
        "TEXT",
        "SMALLINT",
        "INTEGER",
        "BIGINT",
        "INT",
        "SMALLSERIAL",
        "BIGSERIAL",
        "SERIAL",
        "REAL",
        "FLOAT8",
        "DECIMAL",
        "NUMERIC",
        "DOUBLE",
        "PRECISION",
        "DATE",
        "TIMESTAMPTZ",
        "TIMESTAMP",
        "TIME",
        "INTERVAL",
        "JSONB",
        "JSON",
        "UUID",
    }
    Literals = []string{
        "(",
        ")",
        ",",
        ";",
    }
    Tokens = append(Tokens, Keywords...)
    Tokens = append(Tokens, Literals...)
    TokenIds = make(map[string]int)
    for i, tok := range Tokens {
        TokenIds[tok] = i
    }
}
// Создаёт объект лексера и компилирует недетерминизированный конечный автомат.
func initLexer() (*lex.Lexer, error) {
    lexer := lex.NewLexer()for _, lit := range Literals {
        r := "\\" + strings.Join(strings.Split(lit, ""), "\\")
        lexer.Add([]byte(r), token(lit))
    }
    for _, name := range Keywords {
        lexer.Add([]byte(strings.ToLower(name)), token(name))
    }
    lexer.Add([]byte(`[vV][aA][rR][cC][hH][aA][rR]\([0-9]+\)`), token("VARCHARID"))
    lexer.Add([]byte(`[cC][hH][aA][rR]\([0-9]+\)`), token("CHARID"))
    lexer.Add([]byte(`[fF][lL][oO][aA][tT]\([0-9]+\)`), token("FLOATID"))
    lexer.Add([]byte(`[nN][uU][mM][eE][rR][iI][cC]\([0-9]+,[0-9]+\)`), token("NUMERICID"))
    lexer.Add([]byte(`([a-z]|[A-Z]|_|#|@)([a-z]|[A-Z]|[0-9]|_|#|@|$)*\(([a-z]|[A-Z]|_|#|@)([a-z]|[A-Z]|[0-9]|_|#|@|$)*\)`), token("REFID"))
    lexer.Add([]byte(`([a-z]|[A-Z]|_|#|@)([a-z]|[A-Z]|[0-9]|_|#|@|$)*`), token("ID"))
    lexer.Add([]byte("( |\t|\n|\r)+"), skip)    err := lexer.Compile()
    if err != nil {
        return nil, err
    }
    return lexer, nil
}
// lex.Action - функция, которая пропускает совпадения.
func skip(*lex.Scanner, *machines.Match) (interface{}, error) {
    return nil, nil
}
// lex.Action конструирует токен данного типа по имени типа.
func token(name string) lex.Action {
    return func(s *lex.Scanner, m *machines.Match) (interface{}, error) {
        return s.Token(TokenIds[name], string(m.Bytes), m), nil
    }
}

Парсер

Синтаксический анализ  —  это процесс обнаружения частей определенной грамматики в потоке лексем, созданных лексером.
Создадим простой синтаксический анализатор для нашей ограниченной грамматики SQL. Для сложной грамматики мы склонялись бы к реальному анализатору, основанному на рекурсивном спуске или сгенерированному генератором синтаксических анализаторов. Наша задача достаточно проста, чтобы конечная машина состояний справилась с ней. 

Простая машина состояний следует шаблону: если я нахожусь в состоянии CurState и вижу какой-то вход, то перемещаюсь в следующее состояние и выполняю какое-то действие. И так до достижения некоторого терминального состояния, обычно EOF. В нашем случае вводом будет тип токена, а действием — некоторая функция, которую мы вызываем для сохранения табличных данных или их игнорирования. Ниже таблица состояний. Вы заметите, что большая ее часть связана с обнаружением различных типов данных:

Cur                 Next
State Input         State Action
---------------------------------------
0    Error          0     error_state
1    CREATE         2     create_table
2    TABLE          3     nop
2    ID             2     some_stuff
3    IF             4     nop
4    NOT            5     nop
5    EXISTS         3     nop
3    ID             6     table_name
6    (              7     nop
7    ID             8     column_name
7    UUID           8     column_name
8    BOOLEAN        9     data_type
8    BOOL           9     data_type
8    CHARID         9     data_type
8    VARCHARID      9     data_type
8    TEXT           9     data_type
8    SMALLINT       9     data_type
8    INT            9     data_type
8    INTEGER        9     data_type
8    BIGINT         9     data_type
8    SMALLSERIAL    9     data_type
8    SERIAL         9     data_type
8    BIGSERIAL      9     data_type
8    FLOATID        9     data_type
8    REAL           9     data_type
8    FLOAT8         9     data_type
8    DECIMAL        9     data_type
8    NUMERIC        9     data_type
8    NUMERICID      9     data_type
8    DOUBLE         10    nop
10   PRECISION      9     data_type
8    DATE           9     data_type
8    TIME           9     data_type
8    TIMESTAMPTZ    9     data_type
8    TIMESTAMP      9     data_type
8    INTERVAL       9     data_type
8    JSON           9     data_type
8    JSONB          9     data_type
8    UUID           9     data_type
9    ,              7     nop
9    )              11    nop
9    REFID          9     some_stuff
9    NOT            9     some_stuff
9    ID             9     some_stuff
11   ;              1     end_table
11   ID             11    some_stuff

В дополнение к пониманию и применению нашей грамматики синтаксический анализатор должен также захватывать все данные, необходимые для генерации кода. В реальном компиляторе для этого можно построить абстрактное синтаксическое дерево, которое затем анализируется генератором кода. В нашем случае мы просто построим список таблиц и их имена столбцов и типы, используемые для генератора. Наша машина состояний сама по себе является просто картой Go, принимающей строку в качестве входных данных и возвращающей структуру следующего состояния и действия (метода) для запуска. Строка карты содержит как текущее состояние, так и входной токен, соединенные вместе, чтобы сформировать одну строку для карты. "CurState, InToken": {NextState, FunctionToCall}.

metaapi/metasql/parse.go:

package metasqlimport (
    "errors"
    "fmt"
    "log"    lex "github.com/timtadh/lexmachine"
)
type Column struct {
    Name string
    Type string
}
type Table struct {
    Name    string
    Query   string
    Columns []Column
}
type StateMachine struct {
    FName    string
    CurState int
    Tables   []Table
}
type NextAction struct {
    State int
    Fn    func(*StateMachine, *lex.Token)
}
func getColumn(sm *StateMachine) *Column {
    if len(sm.Tables) > 0 {
        table := &(sm.Tables[len(sm.Tables)-1])
        if len(table.Columns) > 0 {
            return &(table.Columns[len(table.Columns)-1])
        } else {
            return nil
        }
    } else {
        return nil
    }}
func InitState(fname string) *StateMachine {
    sm := new(StateMachine)
    sm.FName = fname
    sm.CurState = 1
    return sm
}
func error_state(sm *StateMachine, token *lex.Token) {
    //состояние не найдено
    log.Fatal("Error in SQL Syntax!")
}
func nop(sm *StateMachine, token *lex.Token) {
    //nop
}
func create_table(sm *StateMachine, token *lex.Token) {
    sm.Tables = append(sm.Tables, Table{})
}
func table_name(sm *StateMachine, token *lex.Token) {
    if len(sm.Tables) > 0 {
        sm.Tables[len(sm.Tables)-1].Name = string(token.Lexeme)
    }
}
func column_name(sm *StateMachine, token *lex.Token) {
    if len(sm.Tables) > 0 {
        table := &(sm.Tables[len(sm.Tables)-1])
        table.Columns = append(table.Columns, Column{})
        table.Columns[len(table.Columns)-1].Name = string(token.Lexeme)
    }
}
func data_type(sm *StateMachine, token *lex.Token) {
    column := getColumn(sm)
    column.Type = Tokens[token.Type]
}
func some_stuff(sm *StateMachine, token *lex.Token) {
    //nop
}
func end_table(sm *StateMachine, token *lex.Token) {
    //nop
}
func appendQuery(sm *StateMachine, st string) {
    if len(sm.Tables) > 0 {
        (&(sm.Tables[len(sm.Tables)-1])).Query += st + " "
    }
}
func printQuery(sm *StateMachine) {
    if len(sm.Tables) > 0 {
        fmt.Println("query: ", (&(sm.Tables[len(sm.Tables)-1])).Query, " <<")
    }
}
func ProcessState(sm *StateMachine, token *lex.Token) (err error) {    //Машина состояний, формат:
    //"CurState, InToken": {NextState, FunctionToCall}    stateMap := map[string]NextAction{
        "Error":         {0, error_state},
        "1,CREATE":      {2, create_table},
        "2,TABLE":       {3, nop},
        "2,ID":          {2, some_stuff},
        "3,IF":          {4, nop},
        "4,NOT":         {5, nop},
        "5,EXISTS":      {3, nop},
        "3,ID":          {6, table_name},
        "6,(":           {7, nop},
        "7,ID":          {8, column_name},
        "7,UUID":        {8, column_name},
        "8,BOOLEAN":     {9, data_type},
        "8,BOOL":        {9, data_type},
        "8,CHARID":      {9, data_type},
        "8,VARCHARID":   {9, data_type},
        "8,TEXT":        {9, data_type},
        "8,SMALLINT":    {9, data_type},
        "8,INT":         {9, data_type},
        "8,INTEGER":     {9, data_type},
        "8,BIGINT":      {9, data_type},
        "8,SMALLSERIAL": {9, data_type},
        "8,SERIAL":      {9, data_type},
        "8,BIGSERIAL":   {9, data_type},
        "8,FLOATID":     {9, data_type},
        "8,REAL":        {9, data_type},
        "8,FLOAT8":      {9, data_type},
        "8,DECIMAL":     {9, data_type},
        "8,NUMERIC":     {9, data_type},
        "8,NUMERICID":   {9, data_type},
        "8,DOUBLE":      {10, nop},
        "10,PRECISION":  {9, data_type},
        "8,DATE":        {9, data_type},
        "8,TIME":        {9, data_type},
        "8,TIMESTAMPTZ": {9, data_type},
        "8,TIMESTAMP":   {9, data_type},
        "8,INTERVAL":    {9, data_type},
        "8,JSON":        {9, data_type},
        "8,JSONB":       {9, data_type},
        "8,UUID":        {9, data_type},
        "9,,":           {7, nop},
        "9,)":           {11, nop},
        "9,REFID":       {9, some_stuff},
        "9,NOT":         {9, some_stuff},
        "9,ID":          {9, some_stuff},
        "11,;":          {1, end_table},
        "11,ID":         {11, some_stuff},
    }
mapStr := fmt.Sprintf("%d,%s", sm.CurState, Tokens[token.Type])
    nextState := stateMap[mapStr]
    //отображение нулей во все поля структуры, если они не найдены
    if nextState.State == 0 {
        nextState = stateMap["Error"]
        printQuery(sm)
        err = errors.New("Syntax Error: " + Tokens[token.Type])
        return
    }
    sm.CurState = nextState.State
    nextState.Fn(sm, token)
    appendQuery(sm, string(token.Lexeme))
    return nil
}

Генератор

Генератор отвечает за генерацию кода нашего “компилятора”. Он принимает внутреннее представление таблиц SQL, созданное на предыдущих стадиях, и генерирует CRUD API. Если все пойдет хорошо, полученный код должен быть готов к компиляции и запуску в другом проекте.

Подход к созданию генератора такой: мы начинаем с некоторого известного и работающего кода, представляющего нашу цель  —  API. Мы переименуем этот код в файл txt, подаваемый на вход генератору в качестве шаблона, и медленно преобразуем его до полного соответствия шаблону. Мы напишем соответствующие методы приемника в generate.go и пройдём по шаблону. При запуске генератора необходимо воссоздать исходную цель. Этот процесс позволяет действительно легко сравнивать генерируемый код рядом с исходной целью и исправлять любые ошибки.

В моём случае целевым API будет todo CRUD API из проекта govueintro. todo.go, которые я буду конвертировать, чтобы использовать автоматически сгенерированный todo_generated.go из метаапи. crud.txt ниже начинали свою жизнь как todo_generated.go, и я итеративно преобразовал его в crud.txt. Я заменил разделы методами приемника в generate.go. crud.txt сейчас выглядит уродливо, но поверьте мне, итеративный процесс преобразования прост. generate.go просто использует шаблоны go, чтобы сложить определенные табличные данные в универсальный crud.txt

metaapi/metasql/generate.go:

package metasqlimport (
    "errors"
    "io/ioutil"
    "os"
    "strconv"
    "strings"
    "text/template"
)
// Generate предполагает, что первичный идентификатор находится в первом столбце (индекс 0)
func Generate(sm *StateMachine, txtFile string) error {
    if sm.FName == "" {
        return (errors.New("No file name"))
    }
    dot := strings.Index(sm.FName, ".")
    var prefix string
    if dot > 0 {
        prefix = sm.FName[:dot]
    } else {
        prefix = sm.FName
    }
    dat, err := ioutil.ReadFile("./" + txtFile)
    if err != nil {
        return err
    }    tt := template.Must(template.New(prefix).Parse(string(dat)))
    dest := prefix + "_generated.go"
    file, err := os.Create(dest)
    if err != nil {
        return err
    }
    tt.Execute(file, sm)
    file.Close()
    return nil
}
//======== строковые помощники
// должны использовать: https://github.com/blakeembrey/pluralize
func singularize(s string) string {
    if strings.HasSuffix(strings.ToLower(s), "s") {
        return strings.TrimSuffix(strings.ToLower(s), "s")
    } else {
        return strings.ToLower(s)
    }
}
func capitalize(s string) string {
    return strings.Title(s)
}
func lowerize(s string) string {
    return strings.ToLower(s)
}
//преобразует submitted_at в SubmittedAt и не только
func camelize(s string) string {
    return     strings.ReplaceAll(strings.Title(strings.ReplaceAll(strings.ToLower(s), "_", " ")), " ", "")
}
func comma(i int, length int) string {
    if i < (length - 1) {
        return ","
    } else {
        return ""
    }
}
//======== template methodsfunc (sm *StateMachine) Package() string {
    return os.Getenv("GOPACKAGE")
}
// Написание для расширения
func (sm *StateMachine) Import() string {var s string
    var includeTime boolincludeTime = false
    for _, table := range sm.Tables {
        for _, column := range table.Columns {
            switch column.Type {
            case "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL":
                includeTime = true
            default:
            }
        }
    }
    s += "import (\n\t\"database/sql\"\n"
    if includeTime {
        s += "\t\"time\"\n"
    }
    s += ")"
    return s
}
func (table Table) SingName() string {
    return singularize(table.Name)
}
func (table Table) CapName() string {
    return capitalize(lowerize(table.Name))
}
func (table Table) CapSingName() string {
    return capitalize(singularize(table.Name))
}
func (table Table) DropTableStatement() string {
    var s string
    s += "(\"DROP TABLE IF EXISTS " + table.Name + "\")"
    return s
}
func (table Table) CreateTableStatement() string {
    var s string
    s += "(`" + table.Query + "`)"
    return s
}
func (table Table) StructFields() string {var typeMap = map[string]string{
        "BOOLEAN":     "bool",
        "BOOL":        "bool",
        "CHARID":      "string",
        "VARCHARID":   "string",
        "TEXT":        "string",
        "SMALLINT":    "int16",
        "INT":         "int32",
        "INTEGER":     "int32",
        "BIGINT":      "int64",
        "SMALLSERIAL": "int16",
        "SERIAL":      "int32",
        "BIGSERIAL":   "int64",
        "FLOATID":     "float64",
        "REAL":        "float32",
        "FLOAT8":      "float32",
        "DECIMAL":     "float64",
        "NUMERIC":     "float64",
        "NUMERICID":   "float64",
        "PRECISION":   "float64",
        "DATE":        "time.Time",
        "TIME":        "time.Time",
        "TIMESTAMPTZ": "time.Time",
        "TIMESTAMP":   "time.Time",
        "INTERVAL":    "time.Time",
        "JSON":        "[]byte",
        "JSONB":       "[]byte",
        "UUID":        "string",
    }
    var s string    for _, column := range table.Columns {
        s += "\t" + camelize(column.Name)
        s += " " + typeMap[column.Type]
        s += "`xml:\"" + camelize(column.Name) + "\" json:\"" +      lowerize(camelize(column.Name)) + "\"`"
        s += "\n"
    }
    return s
}
func (table Table) Star() string {
    var s string
    for i, column := range table.Columns {
        s += " " + column.Name
        s += comma(i, len(table.Columns))
    }
    return s
}
func (table Table) ScanAll() string {var s string
    s += ".Scan("
    for i, column := range table.Columns {
        s += " &result." + camelize(column.Name)
        s += comma(i, len(table.Columns))
    }
    s += ")"
    return s
}
func (table Table) CreateStatement() string {
    var s string
    s += "(\"INSERT INTO " + table.Name + " ("for i, column := range table.Columns {
        if i == 0 {
            continue
        }
        s += " " + column.Name
        s += comma(i, len(table.Columns))
    }
    s += ") VALUES ("
    index := 1
    for i, _ := range table.Columns {
        if i == 0 {
            continue
        }
        s += "$"
        s += strconv.Itoa(index)
        s += comma(i, len(table.Columns))
        index++
    }
    s += ") RETURNING"
    for i, column := range table.Columns {
        s += " " + column.Name
        s += comma(i, len(table.Columns))
    }
    s += "\")"
    return s
}
func (table Table) CreateQuery() string {
    var s string
    s += "("
    for i, column := range table.Columns {
        if i == 0 {
            continue
        }
        s += " " + table.SingName() + "." + camelize(column.Name)
        s += comma(i, len(table.Columns))
    }
    s += ")"
    s += table.ScanAll()
    return s
}
func (table Table) RetrieveStatement() string {
    var s string
    s += "(\"SELECT" + table.Star() + " FROM " + table.Name + " WHERE ("    index := 1
    for i, column := range table.Columns {
        if i == 0 {
            s += column.Name + " = $" + strconv.Itoa(index)
            s += ")\", " + table.SingName() + "." + camelize(column.Name) + ")"
        }
        break
    }
    s += table.ScanAll()
    return s
}
func (table Table) RetrieveAllStatement() string {
    var s string
    s += "(\"SELECT" + table.Star() + " FROM " + table.Name + " ORDER BY "    for i, column := range table.Columns {
        if i == 0 {
            s += column.Name
        }
        break
    }
    s += " DESC\")"
    return s
}
func (table Table) UpdateStatement() string {
    var s string
    s += "(\"UPDATE " + table.Name + " SET"    index := 2
    for i, column := range table.Columns {
        if i == 0 {
            continue
        }
        s += " " + column.Name + " = $" + strconv.Itoa(index)
        index++
        s += comma(i, len(table.Columns))
    }
    s += " WHERE ("
    index = 1
    for i, column := range table.Columns {
        if i == 0 {
            s += column.Name + " = $" + strconv.Itoa(index)
            s += ") RETURNING"
        }
        break
    }
    for i, column := range table.Columns {
        s += " " + column.Name
        s += comma(i, len(table.Columns))
    }
    s += "\")"
    return s
}
func (table Table) UpdateQuery() string {    var s string
    s += "("
    for i, column := range table.Columns {
        s += " " + table.SingName() + "." + camelize(column.Name)
        s += comma(i, len(table.Columns))
    }
    s += ")"
    s += table.ScanAll()
    return s
}
func (table Table) DeleteStatement() string {
    var s string
    s += "(\"DELETE FROM " + table.Name + " WHERE ("    index := 1
    for i, column := range table.Columns {
        if i == 0 {
            s += column.Name + " = $" + strconv.Itoa(index)
        }
        break
    }
    s += ")\")"
    return s
}
func (table Table) DeleteQuery() string {    var s string
    for i, column := range table.Columns {
        if i == 0 {
            s += "(" + table.SingName() + "." + camelize(column.Name) + ")"
        }
        break
    }
    return s
}
func (table Table) DeleteAllStatement() string {
    var s string
    s += "(\"DELETE FROM " + table.Name + "\")"
    return s
}

metaapi/metasql/crud.txt

//Сгенерировано с помощью MetaApi https://github.com/exyzzy/metaapi
package {{ .Package }}{{ .Import }}{{ range $index, $table := .Tables }}
// CREATE TABLE
func CreateTable{{ $table.CapName }}(db *sql.DB) (err error) {
    _, err = db.Exec{{ $table.DropTableStatement }}
    if err != nil {
        return
    }
    _, err = db.Exec{{ $table.CreateTableStatement }}
    return
}
// Структура
type {{ $table.CapSingName }} struct {
{{ $table.StructFields }}
}
// Create
func ({{ $table.SingName }} *{{ $table.CapSingName }}) Create{{ $table.CapSingName }}(db *sql.DB) (result {{ $table.CapSingName }}, err error) {
    stmt, err := db.Prepare{{ $table.CreateStatement }}
    if err != nil {
        return
    }
    defer stmt.Close()
    err = stmt.QueryRow{{ $table.CreateQuery }}
    return
}
// Извлечение
func ({{ $table.SingName }} *{{ $table.CapSingName }}) Retrieve{{ $table.CapSingName }}(db *sql.DB) (result {{ $table.CapSingName }}, err error) {
    result = {{ $table.CapSingName }}{}
    err = db.QueryRow{{ $table.RetrieveStatement }}
    return
}// Извлечение всего
func ({{ $table.SingName }} *{{ $table.CapSingName }}) RetrieveAll{{ $table.CapName }}(db *sql.DB) ({{ $table.Name }} []{{ $table.CapSingName }}, err error) {
    rows, err := db.Query{{ $table.RetrieveAllStatement }}
    if err != nil {
        return
    }
    for rows.Next() {
        result := {{ $table.CapSingName }}{}
        if err = rows{{ $table.ScanAll }}; err != nil {
            return
        }
        {{ $table.Name }} = append({{ $table.Name }}, result)
    }
    rows.Close()
    return
}//Update
func ({{ $table.SingName }} *{{ $table.CapSingName }}) Update{{ $table.CapSingName }}(db *sql.DB) (result {{ $table.CapSingName }}, err error) {
    stmt, err := db.Prepare{{ $table.UpdateStatement }}
    if err != nil {
        return
    }
    defer stmt.Close()err = stmt.QueryRow{{ $table.UpdateQuery }}
    return
}//Delete
func ({{ $table.SingName }} *{{ $table.CapSingName }}) Delete{{ $table.CapSingName }}(db *sql.DB) (err error) {
    stmt, err := db.Prepare{{ $table.DeleteStatement }}
    if err != nil {
        return
    }
    defer stmt.Close()_, err = stmt.Exec{{ $table.DeleteQuery }}
    return
}
//DeleteAll
func DeleteAll{{ $table.CapSingName }}s(db *sql.DB) (err error) {
    stmt, err := db.Prepare{{ $table.DeleteAllStatement}}
    if err != nil {
        return
    }
    defer stmt.Close()_, err = stmt.Exec()
    return
}
{{ end }}

Короткий main держит все вместе и использует флаги go для передачи имен файлов.

metaapi/main.go:

package mainimport (
    "flag"
    "fmt"
    "io/ioutil"
    "log"
    "strings"    "github.com/exyzzy/metaapi/metasql"    lex "github.com/timtadh/lexmachine"
)
// Включаем печать отладки
var DEBUG = falsefunc main() {
    sqlPtr := flag.String("sql", "", ".sql input file to parse")
    txtPtr := flag.String("txt", "crud.txt", "go template as .txt file")
    flag.Parse()
    sqlFile := strings.ToLower(*sqlPtr)
    txtFile := strings.ToLower(*txtPtr)    if (sqlFile == "") || (!strings.HasSuffix(sqlFile, ".sql")) {
        log.Fatal("No .sql File")
    }
    if (txtFile == "") || (!strings.HasSuffix(txtFile, ".txt")) {
        log.Fatal("No .txt File")
    }    dat, err := ioutil.ReadFile("./" + sqlFile)
    if err != nil {
        log.Fatal(err)
    }    s, err := metasql.Lexer.Scanner([]byte(dat))
    if err != nil {
        log.Fatal(err)
    }    sm := metasql.InitState(sqlFile)
    for tok, err, eof := s.Next(); !eof; tok, err, eof = s.Next() {
        if err != nil {
            log.Fatal(err)
        }
        token := tok.(*lex.Token)
        if DEBUG {
            fmt.Printf("%-10v | %-12v | %v:%v-%v:%v\n",
                metasql.Tokens[token.Type],
                string(token.Lexeme),
                token.StartLine,
                token.StartColumn,
                token.EndLine,
                token.EndColumn)
        }
        err = metasql.ProcessState(sm, token)
        if err != nil {
            log.Fatal(err)
        }
    }
    err = metasql.Generate(sm, txtFile)
    if err != nil {
        log.Fatal(err)
    }
    if DEBUG {
        fmt.Printf("Table Capture:\n%+v\n", sm)
    }
}

Компилятор работает?

Чтобы использовать его, пройдите шаги ниже:

  • Клонируйте и установите проект metaapi.
  • Создайте каталог для нового проекта.
  • Скопируйте crud.txt, ваш sql.todo и опционально файл todo.go в новый проект.
  • Выполните go.generate или вручную запустите metaapi.

Для своего проекта я использовал такой код:

metasql/todo.go:

//go:generate metaapi -sql=todo.sql -txt=crud.txt
package metasql
//Посмотрите: todo_generated.go

metasql/todo.sql:

create table todos (
  id           integer generated always as identity primary key,
  updated_at   timestamptz,
  done         boolean,
  title        text
);

Давайте запустим его и посмотрим, что получится:

go get github.com/exyzzy/metaapi
go install $GOPATH/src/github.com/exyzzy/metaapi
mkdir myproj
cd myproj
cp $GOPATH/src/github.com/exyzzy/metaapi/metasql/crud.txt .
cp $GOPATH/src/github.com/exyzzy/metaapi/metasql/todo.sql .
cp $GOPATH/src/github.com/exyzzy/metaapi/metasql/todo.go .
go generate

todo_generated.go создан автоматически:

//Auto generated with MetaApi https://github.com/exyzzy/metaapi
package metasqlimport (
    "database/sql"
    "time"
)
//Create Table
func CreateTableTodos(db *sql.DB) (err error) {
    _, err = db.Exec("DROP TABLE IF EXISTS todos")
    if err != nil {
        return
    }
    _, err = db.Exec(`create table todos ( id integer generated always as identity primary key , updated_at timestamptz , done boolean , title text ) ; `)
    return
}//Структура
type Todo struct {
    Id int32`xml:"Id" json:"id"`
    UpdatedAt time.Time`xml:"UpdatedAt" json:"updatedat"`
    Done bool`xml:"Done" json:"done"`
    Title string`xml:"Title" json:"title"`}//Create
func (todo *Todo) CreateTodo(db *sql.DB) (result Todo, err error) {
    stmt, err := db.Prepare("INSERT INTO todos ( updated_at, done, title) VALUES ($1,$2,$3) RETURNING id, updated_at, done, title")
    if err != nil {
        return
    }
    defer stmt.Close()
    err = stmt.QueryRow( todo.UpdatedAt, todo.Done, todo.Title).Scan( &result.Id, &result.UpdatedAt, &result.Done, &result.Title)
    return
}
//Извлечение
func (todo *Todo) RetrieveTodo(db *sql.DB) (result Todo, err error) {
    result = Todo{}
    err = db.QueryRow("SELECT id, updated_at, done, title FROM todos WHERE (id = $1)", todo.Id).Scan( &result.Id, &result.UpdatedAt, &result.Done, &result.Title)
    return
}
//Извлечение всего
func (todo *Todo) RetrieveAllTodos(db *sql.DB) (todos []Todo, err error) {
    rows, err := db.Query("SELECT id, updated_at, done, title FROM todos ORDER BY id DESC")
    if err != nil {
        return
    }
    for rows.Next() {
        result := Todo{}
        if err = rows.Scan( &result.Id, &result.UpdatedAt, &result.Done, &result.Title); err != nil {
            return
        }
        todos = append(todos, result)
    }
    rows.Close()
    return
}
//Update
func (todo *Todo) UpdateTodo(db *sql.DB) (result Todo, err error) {
    stmt, err := db.Prepare("UPDATE todos SET updated_at = $2, done = $3, title = $4 WHERE (id = $1) RETURNING id, updated_at, done, title")
    if err != nil {
        return
    }
    defer stmt.Close()    err = stmt.QueryRow( todo.Id, todo.UpdatedAt, todo.Done, todo.Title).Scan( &result.Id, &result.UpdatedAt, &result.Done, &result.Title)
    return
}

//Delete
func (todo *Todo) DeleteTodo(db *sql.DB) (err error) {
    stmt, err := db.Prepare("DELETE FROM todos WHERE (id = $1)")
    if err != nil {
        return
    }
    defer stmt.Close()    _, err = stmt.Exec(todo.Id)
    return
}

//DeleteAll
func DeleteAllTodos(db *sql.DB) (err error) {
    stmt, err := db.Prepare("DELETE FROM todos")
    if err != nil {
        return
    }
    defer stmt.Close()    _, err = stmt.Exec()
    return
}

Миссия выполнена. Теперь у нас есть инструмент, который может генерировать базовый CRUD API для произвольной таблицы SQL. Мы также можем расширить или настроить лексер, парсер, генератор и шаблон для создания новых файлов. Не нравится мой API? Клонируйте репозиторий, измените шаблон и сделайте свой собственный CRUD!

Читайте также:


Перевод статьи Eric Lang: Metaprogram in Go

Предыдущая статьяКак преодолеть синдром самозванца: 6 советов разработчикам
Следующая статьяPython + Selenium: как получить координаты по адресам