Автоматическое тестирование в Go

Дальнейшие приключения с метапрограммированием в Go. В этом мы опираемся на наш инструмент метапрограммирования Golang.

Это продолжение последней статьи Метапрограмма в Go, возможно, начну с нее, если вы еще не читали. В этой статье мы сосредоточимся на автоматическом создании тестов для нашего CRUD API. Напомним, что мы уже автоматически делаем сами api на основе определений таблиц PostgreSQL. Попутно мы также создадим пару утилит, которые сделают наш инструмент метапрограммирования более полезным.

Охваченные азартом от автоматического создания сотен строк компилируемого, работающего, безошибочного * (* не совсем, продолжайте читать) кода из нескольких строк определения таблицы SQL, мы, как молотки, ищем гвоздь. Что еще мы можем метапрограммировать? Оказывается, много, но на все нужно время. Когда начать?

Поставим четыре цели по улучшению метаапи.

  1. Автоматически создавать тесты для нашего сгенерированного метаапи API
  2. Используйте внутренние шаблоны для генерации кода по умолчанию
  3. Поддержка генерации кода вне метаапи
  4. Автоматически создать начальный проект metaapi

Обо всем этом поговорим подробнее.

Автоматические тесты

Одной вещи не хватает во многих проектах, так это тестирования кода. Но вот вопрос - стоит ли создавать тесты для автоматически сгенерированного кода? Вы можете предположить, что если генератор работает правильно, то он генерирует код без ошибок. Верно?

Конечно, вы не можете предполагать, что сгенерированный код не содержит ошибок по той простой причине, что сгенерированный код является продуктом генератора, а сам генератор все еще очень подвержен человеческим ошибкам. Более того, разработчик генератора как обязан предоставлять автоматические тесты, как если бы он писал немета (нормальные?) Программы. Учтите эти моменты:

  • Автоматические тесты становятся контрактом и документом о том, в какой степени сгенерированный код был протестирован.
  • Пользователь может изменять / расширять сгенерированный код с течением времени, и при изменении кода потребуются тесты.
  • Контекст (другой код), в котором находится сгенерированный код, может реализовывать его способами, которые автор генератора никогда не рассматривал.
  • Тесты необходимы для статистики покрытия.
  • Автоматические тесты - это проверка самого генератора, и поэтому они должны быть реализованы как процесс построения генератора (мое плохое, и на самом деле я обнаружил проблемы с исходным кодом, сгенерированным метаапи, для некоторых типов sql, которые я не часто использую) .
  • Глазурь на торте: зачем заставлять пользователей писать тесты вручную, если вы можете их генерировать автоматически?

Вооружившись этими аргументами, давайте проведем автоматические тесты. К счастью, наша программа metaapi дает нам почти все необходимое в качестве трамплина, чтобы упростить эту задачу. Вот как это должно быть - логично, что если вы можете метапрограммировать что-то x.go, вы также должны иметь возможность метапрограммировать что-то x_test.go без особых дополнительных усилий. Мы можем оставить все исходные лексический анализатор и парсер нетронутыми. Нам просто нужно будет создать новый шаблон для нашего тестового файла, несколько новых методов приемника в generate.go и интегрировать эти вещи.

В качестве напоминания давайте сначала рассмотрим наш автоматически сгенерированный api метаапи, с которым мы будем тестировать, на основе простого определения таблицы todo sql.

newtodo / todo.sql:

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

newtodo / todo_generated_api.go:

//Auto generated with MetaApi https://github.com/exyzzy/metaapi
package main
import (
  "database/sql"
  _ "github.com/lib/pq"
  "time"
)
//Create Table
func CreateTableTodos(db *sql.DB) (err error) {
  _, err = db.Exec("DROP TABLE IF EXISTS todos CASCADE")
  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
}
//Drop Table
func DropTableTodos(db *sql.DB) (err error) {
  _, err = db.Exec("DROP TABLE IF EXISTS todos CASCADE")
  return
}
//Struct
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
}
//Retrieve
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
}
//RetrieveAll
func 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
}

Мы подойдем к созданию файла тестовых функций api так же, как мы создали генератор API, то есть мы начнем с заведомо исправного тестового файла go и постепенно преобразуем его в шаблон. Итак, задача первая - создать рабочий тестовый файл для сгенерированного нами выше CRUD API. Давайте сделаем это в первую очередь, и помните, что наша цель - автоматически сгенерировать этот же тестовый файл с помощью metaapi, когда мы закончим.

newtodo / api_test.go:

package main
import (
  "database/sql"
  "encoding/json"
  "fmt"
  "log"
  "os"
  "reflect"
  "strings"
  "testing"
  "time"
)
var testDb *sql.DB
var configdb map[string]interface{}
const testDbName = "testtodo"
// ======= helpers
//assumes a configlocaldb.json file as:
//{
//    "Host": "localhost",
//    "Port": "5432",
//    "User": "dbname",
//    "Pass": "dbname",
//    "Name": "dbname",
//    "SSLMode": "disable"
//}
func loadConfig() {
  fmt.Println("  loadConfig")
  file, err := os.Open("configlocaldb.json")
  if err != nil {
    log.Fatalln("Cannot open configlocaldb file", err)
  }
  decoder := json.NewDecoder(file)
  err = decoder.Decode(&configdb)
  if err != nil {
    log.Fatalln("Cannot get local configurationdb from file", err)
  }
}
func createDb(db *sql.DB, dbName string, owner string) (err error) {
  ss := fmt.Sprintf("CREATE DATABASE %s OWNER %s", dbName, owner)
  fmt.Println("  " + ss)
  _, err = db.Exec(ss)
  return
}
func setTzDb(db *sql.DB) (err error) {
  ss := fmt.Sprintf("SET TIME ZONE UTC")
  fmt.Println("  " + ss)
  _, err = db.Exec(ss)
  return
}
func dropDb(db *sql.DB, dbName string) (err error) {
  ss := fmt.Sprintf("DROP DATABASE %s", dbName)
  fmt.Println("  " + ss)
  _, err = db.Exec(ss)
  return
}
func rowExists(db *sql.DB, query string, args ...interface{}) (exists bool, err error) {
  query = fmt.Sprintf("SELECT EXISTS (%s)", query)
  fmt.Println("  " + query)
  err = db.QueryRow(query, args...).Scan(&exists)
  return
}
func tableExists(db *sql.DB, table string) (valid bool, err error) {
  valid, err = rowExists(db, "SELECT 1 FROM pg_tables WHERE tablename = $1", table)
  return
}
func initTestDb() (err error) {
  loadConfig()
  psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s "+
    "sslmode=%s", configdb["Host"], configdb["Port"], configdb["User"], configdb["Pass"], configdb["SSLMode"])
  testDb, err = sql.Open("postgres", psqlInfo)
  return
}
func TestMain(m *testing.M) {
  //test setup
  err := initTestDb()
  if err != nil {
    log.Panicf("cannot initTestDb " + err.Error())
  }
  err = createDb(testDb, testDbName, configdb["User"].(string))
  if err != nil {
    log.Panicf("cannot CreateDb " + err.Error())
  }
  err = setTzDb(testDb)
  if err != nil {
    log.Panicf("cannot setTzDb " + err.Error())
  }
  //run tests
  exitVal := m.Run()
  //test teardown
  err = dropDb(testDb, testDbName)
  if err != nil {
    log.Panicf("cannot DropDb " + err.Error())
  }
  os.Exit(exitVal)
}
type compareType func(interface{}, interface{}) bool
func noCompare(result, expect interface{}) bool {
  fmt.Printf("noCompare: %v, %v -  %T, %T \n", result, expect, result, expect)
  return (true)
}
func defaultCompare(result, expect interface{}) bool {
  fmt.Printf("defaultCompare: %v, %v -  %T, %T \n", result, expect, result, expect)
  return (result == expect)
}
func jsonCompare(result, expect interface{}) bool {
  fmt.Printf("jsonCompare: %v, %v -  %T, %T \n", result, expect, result, expect)
  //json fields can be any order after db return, 
  //so read into   map[string]interface and look up
  resultMap := make(map[string]interface{})
  err := json.Unmarshal([]byte(result.(string)), &resultMap)
  if err != nil {
    log.Panic(err)
  }
  expectMap := make(map[string]interface{})
  err = json.Unmarshal([]byte(expect.(string)), &expectMap)
  if err != nil {
    log.Panic(err)
  }
for k, v := range expectMap {
    if v != resultMap[k] {
      fmt.Printf("Key: %v, Result: %v, Expect: %v", k, resultMap[k], v)
      return false
    }
  }
  return true
}
func stringNotEqual(result, expect []byte) bool {
  return (strings.TrimSpace(string(result)) != strings.TrimSpace(string(expect)))
}
func stringCompare(result, expect interface{}) bool {
resultJson, err := json.Marshal(result)
  if err != nil {
    log.Panic(err)
  }
  expectJson, err := json.Marshal(expect)
  if err != nil {
    log.Panic(err)
  }
  fmt.Printf("stringCompare: %v, %v -  %T, %T \n", string(resultJson), string(expectJson), result, expect)
  return (strings.TrimSpace(string(resultJson)) == strings.TrimSpace(string(expectJson)))
}
//iterate through each field of struct and apply the 
//compare function to each field based on compareType map
func equalField(result, expect interface{}, compMap map[string]compareType) error {
  u := reflect.ValueOf(expect)
  v := reflect.ValueOf(result)
  typeOfS := u.Type()
  for i := 0; i < u.NumField(); i++ {
  if !(compMap[typeOfS.Field(i).Name])(v.Field(i).Interface(), u.Field(i).Interface()) {
      return fmt.Errorf("Field: %s, Result: %v, Expect: %v", typeOfS.Field(i).Name, v.Field(i).Interface(), u.Field(i).Interface())
    }
  }
  return nil
}
//table specific
const todostableName = "todos"
//test data - note: double brackets in test data need space 
//between otherwise are interpreted as template action
var testTodo = [2]Todo{ {1, time.Now().UTC().Truncate(time.Microsecond), false, "TaoLVzKbOmA7o6XG"}, {2, time.Now().UTC().Truncate(time.Microsecond), false, "mkty9P5syMWIFQHs"} }
var updateTodo = Todo{1, time.Now().UTC().Truncate(time.Microsecond), false, "gtJYE5QGUfrlzMzw"}
//compare functions
var compareTodos = map[string]compareType{
  "Id":        defaultCompare,
  "UpdatedAt": stringCompare,
  "Done":      defaultCompare,
  "Title":     defaultCompare,
}
// ======= tests: Todo
func reverseTodos(todos []Todo) (result []Todo) {
  for i := len(todos) - 1; i >= 0; i-- {
    result = append(result, todos[i])
  }
  return
}
func TestCreateTableTodos(t *testing.T) {
  fmt.Println("==CreateTableTodos")
  err := CreateTableTodos(testDb)
  if err != nil {
    t.Errorf("cannot CreateTableTodos " + err.Error())
  } else {
    fmt.Println("  Done: CreateTableTodos")
  }
  exists, err := tableExists(testDb, todostableName)
  if err != nil {
    t.Errorf("cannot tableExists " + err.Error())
  }
  if !exists {
    t.Errorf("tableExists(todos) returned wrong status code: got %v want %v", exists, true)
  } else {
    fmt.Println("  Done: tableExists")
  }
}
func TestCreateTodo(t *testing.T) {
  fmt.Println("==CreateTodo")
  result, err := testTodo[0].CreateTodo(testDb)
  if err != nil {
    t.Errorf("cannot CreateTodo " + err.Error())
  } else {
    fmt.Println("  Done: CreateTodo")
  }
  err = equalField(result, testTodo[0], compareTodos)
  if err != nil {
    t.Errorf("api returned unexpected result. " + err.Error())
  }
}
func TestRetrieveTodo(t *testing.T) {
  fmt.Println("==RetrieveTodo")
  result, err := testTodo[0].RetrieveTodo(testDb)
  if err != nil {
    t.Errorf("cannot RetrieveTodo " + err.Error())
  } else {
    fmt.Println("  Done: RetrieveTodo")
  }
  err = equalField(result, testTodo[0], compareTodos)
  if err != nil {
    t.Errorf("api returned unexpected result. " + err.Error())
  }
}
func TestRetrieveAllTodos(t *testing.T) {
  fmt.Println("==RetrieveAllTodos")
  _, err := testTodo[1].CreateTodo(testDb)
  if err != nil {
    t.Errorf("cannot CreateTodo " + err.Error())
  } else {
    fmt.Println("  Done: CreateTodo")
  }
  result, err := RetrieveAllTodos(testDb)
  if err != nil {
    t.Errorf("cannot RetrieveAllTodos " + err.Error())
  } else {
    fmt.Println("  Done: RetrieveAllTodos")
  }
//reverse because api is DESC, [:] is slice of all array elements
  expect := reverseTodos(testTodo[:])
  for i, _ := range expect {
    err = equalField(result[i], expect[i], compareTodos)
    if err != nil {
      t.Errorf("api returned unexpected result. " + err.Error())
    }
  }
}
func TestUpdateTodo(t *testing.T) {
  fmt.Println("==UpdateTodo")
  result, err := updateTodo.UpdateTodo(testDb)
  if err != nil {
    t.Errorf("cannot UpdateTodo " + err.Error())
  } else {
    fmt.Println("  Done: UpdateTodo")
  }
  err = equalField(result, updateTodo, compareTodos)
  if err != nil {
    t.Errorf("api returned unexpected result. " + err.Error())
  }
}
func TestDeleteTodo(t *testing.T) {
  fmt.Println("==DeleteTodo")
  err := testTodo[0].DeleteTodo(testDb)
  if err != nil {
    t.Errorf("cannot DeleteTodo " + err.Error())
  } else {
    fmt.Println("  Done: DeleteTodo")
  }
  _, err = testTodo[0].RetrieveTodo(testDb)
  if err == nil {
    t.Errorf("api returned unexpected result: got Row want NoRow")
  } else {
    if err == sql.ErrNoRows {
      fmt.Println("  Done: RetrieveTodo with no result")
    } else {
      t.Errorf("cannot RetrieveTodo " + err.Error())
    }
  }
}
func TestDeleteAllTodos(t *testing.T) {
  fmt.Println("==DeleteAllTodos")
  err := DeleteAllTodos(testDb)
  if err != nil {
    t.Errorf("cannot DeleteAllTodos " + err.Error())
  } else {
    fmt.Println("  Done: DeleteAllTodos")
  }
  result, err := RetrieveAllTodos(testDb)
  if err != nil {
    t.Errorf("cannot RetrieveAllTodos " + err.Error())
  }
  if len(result) > 0 {
    t.Errorf("api returned unexpected result: got Row want NoRow")
  } else {
    fmt.Println("  Done: RetrieveAllTodos with no result")
  }
}

В этом тестовом файле есть несколько моментов, на которые следует обратить внимание.

В начале тестов создается новая тестовая база данных. Для этого есть ряд причин:

  1. Для предотвращения загрязнения производственной базы данных
  2. Чтобы узнать идентификаторы серийных первичных ключей тестовых данных, не сохраняя их
  3. Чтобы протестировать создание табличных функций

Тестовые данные JSON можно интерпретировать как действие шаблона Go. Если вы создаете тестовые данные JSON, которые представляют собой, например, массив структур, и используете разделители действий шаблона go по умолчанию, вы можете получить символьный шаблон {{}, {}, .. {}}, который компилятор go будет интерпретировать как шаблонное действие. Исправить легко, просто поставьте пробел между двойными скобками: {{}, {}, .. {}}. Это становится немного сложнее в шаблоне txt, так как будут места, где мы хотим, чтобы двойные скобки интерпретировались как действия шаблона рядом с местами, которые мы не делаем, и разница между ними состоит в одном пробеле - это напрашивается об ошибках. Лучшее решение - изменить разделители, которые используются для идентификации действий шаблона. Мы можем сделать это, используя Template.Delims () в generate.go, чтобы изменить разделители с {{}} на ‹

Данные, поступающие в PostgreSQL, могут быть возвращены модифицированными: это может испортить ваши тесты, если вы этого не планируете. Вот несколько примеров. Этот интересный вопрос, где временное разрешение различается между Go (наносекунда) и Postgres (микросекунда). Кажется, это проблема Linux, но не OS X, но может повлиять на ваши локальные тесты. Решение состоит в том, чтобы сократить время до наименьшего (общего) разрешения, чтобы тесты времени прошли:

time.Now().UTC().Truncate(time.Microsecond)

Другой - форматирование времени, при котором pq будет использовать структуру time.Time для отметки времени и даты, чтобы они могли войти в PG с часовым поясом и вернуться без него. Решение состоит в том, чтобы упорядочить их в строки и сравнить строки.

Еще одна причина заключается в том, что ключи / поля структуры JSON могут быть в любом порядке, когда они возвращаются из PG, это просто нормально для JSON. Поэтому нам нужно перебрать каждый ключ и найти его в целевом объекте для сравнения.

Из-за этих проблем тестовый файл использует карту для каждой таблицы, которая позволяет тестовой функции вызывать уникальную функцию сравнения для каждого поля. Мы сгенерируем эту карту в generate.go, у которого во время компиляции будет доступ к данным таблицы sql.

//compare functions
var compareTodos = map[string]compareType{
  "Id":        defaultCompare,
  "UpdatedAt": stringCompare,
  "Done":      defaultCompare,
  "Title":     defaultCompare,
}

Если мы запустим go test, похоже, что он действительно работает против нашего api, поэтому теперь мы скопируем наш тестовый файл go как api_test.txt и начнем его шаблонизировать, а также добавим новые методы приемника, где это необходимо для generate.go. Ниже представлена ​​полностью шаблонная версия нашего тестового файла.

metaapi / metasql / api_test.txt:

//Auto generated with MetaApi https://github.com/exyzzy/metaapi
package << .Package >>
import (
  "database/sql"
  "encoding/json"
  "fmt"
  "log"
  "os"
  "reflect"
  "strings"
  "testing"
  "time"
)
var testDb *sql.DB
var configdb map[string]interface{}
const testDbName = "test<< .FilePrefix >>"
// ======= helpers
//assumes a configlocaldb.json file as:
//{
//    "Host": "localhost",
//    "Port": "5432",
//    "User": "dbname",
//    "Pass": "dbname",
//    "Name": "dbname",
//    "SSLMode": "disable"
//}
func loadConfig() {
  fmt.Println("  loadConfig")
  file, err := os.Open("configlocaldb.json")
  if err != nil {
    log.Panicln("Cannot open configlocaldb file", err.Error())
  }
  decoder := json.NewDecoder(file)
  err = decoder.Decode(&configdb)
  if err != nil {
    log.Panicln("Cannot get local configurationdb from file", err.Error())
  }
}
func createDb(db *sql.DB, dbName string, owner string) (err error) {
  ss := fmt.Sprintf("CREATE DATABASE %s OWNER %s", dbName, owner)
  fmt.Println("  " + ss)
  _, err = db.Exec(ss)
  return
}
func setTzDb(db *sql.DB) (err error) {
  ss := fmt.Sprintf("SET TIME ZONE UTC")
  fmt.Println("  " + ss)
  _, err = db.Exec(ss)
  return
}
func dropDb(db *sql.DB, dbName string) (err error) {
  ss := fmt.Sprintf("DROP DATABASE %s", dbName)
  fmt.Println("  " + ss)
  _, err = db.Exec(ss)
  return
}
func rowExists(db *sql.DB, query string, args ...interface{}) (exists bool, err error) {
  query = fmt.Sprintf("SELECT EXISTS (%s)", query)
  fmt.Println("  " + query)
  err = db.QueryRow(query, args...).Scan(&exists)
  return
}
func tableExists(db *sql.DB, table string) (valid bool, err error) {
  valid, err = rowExists(db, "SELECT 1 FROM pg_tables WHERE tablename = $1", table)
  return
}
func initTestDb() (err error) {
  loadConfig()
  psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s "+
    "sslmode=%s", configdb["Host"], configdb["Port"], configdb["User"], configdb["Pass"], configdb["SSLMode"])
  testDb, err = sql.Open("postgres", psqlInfo)
  return
}
func TestMain(m *testing.M) {
  //test setup
  err := initTestDb()
  if err != nil {
    log.Panicln("cannot initTestDb ", err.Error())
  }
  err = createDb(testDb, testDbName, configdb["User"].(string))
  if err != nil {
    log.Panicln("cannot CreateDb ", err.Error())
  }
  err = setTzDb(testDb)
  if err != nil {
    log.Panicln("cannot setTzDb ", err.Error())
  }
  //run tests
  exitVal := m.Run()
  //test teardown
  err = dropDb(testDb, testDbName)
  if err != nil {
    log.Panicln("cannot DropDb ", err.Error())
  }
  os.Exit(exitVal)
}
type compareType func(interface{}, interface{}) bool
func noCompare(result, expect interface{}) bool {
  fmt.Printf("noCompare: %v, %v -  %T, %T \n", result, expect, result, expect)
  return (true)
}
func defaultCompare(result, expect interface{}) bool {
  fmt.Printf("defaultCompare: %v, %v -  %T, %T \n", result, expect, result, expect)
  return (result == expect)
}
func jsonCompare(result, expect interface{}) bool {
  fmt.Printf("jsonCompare: %v, %v -  %T, %T \n", result, expect, result, expect)
  //json fields can be any order after db return, 
  //so read into map[string]interface and look up
  resultMap := make(map[string]interface{})
  err := json.Unmarshal([]byte(result.(string)), &resultMap)
  if err != nil {
    log.Panic(err)
  }
  expectMap := make(map[string]interface{})
  err = json.Unmarshal([]byte(expect.(string)), &expectMap)
  if err != nil {
    log.Panic(err)
  }
  for k, v := range expectMap {
    if v != resultMap[k] {
      fmt.Printf("Key: %v, Result: %v, Expect: %v", k, resultMap[k], v)
      return false
    }
  }
  return true
}
func stringCompare(result, expect interface{}) bool {
  resultJson, err := json.Marshal(result)
  if err != nil {
    log.Panic(err)
  }
  expectJson, err := json.Marshal(expect)
  if err != nil {
    log.Panic(err)
  }
  fmt.Printf("stringCompare: %v, %v -  %T, %T \n", string(resultJson), string(expectJson), result, expect)
  return (strings.TrimSpace(string(resultJson)) == strings.TrimSpace(string(expectJson)))
}
//iterate through each field of struct and apply the 
//compare function to each field based on compareType map
func equalField(result, expect interface{}, compMap map[string]compareType) error {
  u := reflect.ValueOf(expect)
  v := reflect.ValueOf(result)
  typeOfS := u.Type()
  for i := 0; i < u.NumField(); i++ {
  if !(compMap[typeOfS.Field(i).Name])(v.Field(i).Interface(), u.Field(i).Interface()) {
      return fmt.Errorf("Field: %s, Result: %v, Expect: %v", typeOfS.Field(i).Name, v.Field(i).Interface(), u.Field(i).Interface())
    }
  }
  return nil
}
//table specific
<< range $index, $table := .Tables >>
const << $table.Name >>tableName = "<< $table.Name >>"
var test<< $table.CapSingName >> = [2]<< $table.CapSingName >>{ << $table.TestData 1 >>, << $table.TestData 2 >> }
var update<< $table.CapSingName >> = << $table.CapSingName >><< $table.TestData 1 >>
//compare functions
var compare<< $table.CapName >> = map[string]compareType{
<< $table.CompareMapFields >>
}
// ======= tests: << $table.CapSingName >>
func reverse<< $table.CapName >>(<< $table.Name >> []<< $table.CapSingName >>) (result []<< $table.CapSingName >>) {
for i := len(<< $table.Name >>) - 1; i >= 0; i-- {
    result = append(result, << $table.Name >>[i])
  }
  return
}
func TestCreateTable<< $table.CapName >>(t *testing.T) {
  fmt.Println("==CreateTable<< $table.CapName >>")
  err := CreateTable<< $table.CapName >>(testDb)
  if err != nil {
    t.Errorf("cannot CreateTable<< $table.CapName >> " + err.Error())
  } else {
    fmt.Println("  Done: CreateTable<< $table.CapName >>")
  }
  exists, err := tableExists(testDb, << $table.Name >>tableName)
  if err != nil {
    t.Errorf("cannot tableExists " + err.Error())
  }
  if !exists {
    t.Errorf("tableExists(<< $table.Name >>) returned wrong status code: got %v want %v", exists, true)
  } else {
    fmt.Println("  Done: tableExists")
  }
}
func TestCreate<< $table.CapSingName >>(t *testing.T) {
  fmt.Println("==Create<< $table.CapSingName >>")
  result, err := test<< $table.CapSingName >>[0].Create<< $table.CapSingName >>(testDb)
  if err != nil {
    t.Errorf("cannot Create<< $table.CapSingName >> " + err.Error())
  } else {
    fmt.Println("  Done: Create<< $table.CapSingName >>")
  }
  err = equalField(result, test<< $table.CapSingName >>[0], compare<< $table.CapName >>)
  if err != nil {
    t.Errorf("api returned unexpected result. " + err.Error())
  }
}
func TestRetrieve<< $table.CapSingName >>(t *testing.T) {
  fmt.Println("==Retrieve<< $table.CapSingName >>")
  result, err := test<< $table.CapSingName >>[0].Retrieve<< $table.CapSingName >>(testDb)
  if err != nil {
    t.Errorf("cannot Retrieve<< $table.CapSingName >> " + err.Error())
  } else {
    fmt.Println("  Done: Retrieve<< $table.CapSingName >>")
  }
  err = equalField(result, test<< $table.CapSingName >>[0], compare<< $table.CapName >>)
  if err != nil {
    t.Errorf("api returned unexpected result. " + err.Error())
  }
}
func TestRetrieveAll<< $table.CapName >>(t *testing.T) {
  fmt.Println("==RetrieveAll<< $table.CapName >>")
  _, err := test<< $table.CapSingName >>[1].Create<< $table.CapSingName >>(testDb)
  if err != nil {
    t.Errorf("cannot Create<< $table.CapSingName >> " + err.Error())
  } else {
    fmt.Println("  Done: Create<< $table.CapSingName >>")
  }
  result, err := RetrieveAll<< $table.CapName >>(testDb)
  if err != nil {
    t.Errorf("cannot RetrieveAll<< $table.CapName >> " + err.Error())
  } else {
    fmt.Println("  Done: RetrieveAll<< $table.CapName >>")
  }
//reverse because api is DESC, [:] is slice of all array elements
  expect := reverse<< $table.CapName >>(test<< $table.CapSingName >>[:])
  for i, _ := range expect {
    err = equalField(result[i], expect[i], compare<< $table.CapName >>)
    if err != nil {
      t.Errorf("api returned unexpected result. " + err.Error())
    }
  }
}
func TestUpdate<< $table.CapSingName >>(t *testing.T) {
  fmt.Println("==Update<< $table.CapSingName >>")
  result, err := update<< $table.CapSingName >>.Update<< $table.CapSingName >>(testDb)
  if err != nil {
    t.Errorf("cannot Update<< $table.CapSingName >> " + err.Error())
  } else {
    fmt.Println("  Done: Update<< $table.CapSingName >>")
  }
  err = equalField(result, update<< $table.CapSingName >>, compare<< $table.CapName >>)
  if err != nil {
    t.Errorf("api returned unexpected result. " + err.Error())
  }
}
func TestDelete<< $table.CapSingName >>(t *testing.T) {
  fmt.Println("==Delete<< $table.CapSingName >>")
  err := test<< $table.CapSingName >>[0].Delete<< $table.CapSingName >>(testDb)
  if err != nil {
    t.Errorf("cannot Delete<< $table.CapSingName >> " + err.Error())
  } else {
    fmt.Println("  Done: Delete<< $table.CapSingName >>")
  }
  _, err = test<< $table.CapSingName >>[0].Retrieve<< $table.CapSingName >>(testDb)
  if err == nil {
    t.Errorf("api returned unexpected result: got Row want NoRow")
  } else {
    if err == sql.ErrNoRows {
      fmt.Println("  Done: Retrieve<< $table.CapSingName >> with no result")
    } else {
      t.Errorf("cannot Retrieve<< $table.CapSingName >> " + err.Error())
    }
  }
}
func TestDeleteAll<< $table.CapName >>(t *testing.T) {
  fmt.Println("==DeleteAll<< $table.CapName >>")
  err := DeleteAll<< $table.CapName >>(testDb)
  if err != nil {
    t.Errorf("cannot DeleteAll<< $table.CapName >> " + err.Error())
  } else {
    fmt.Println("  Done: DeleteAll<< $table.CapName >>")
  }
  result, err := RetrieveAll<< $table.CapName >>(testDb)
  if err != nil {
    t.Errorf("cannot RetrieveAll<< $table.CapName >> " + err.Error())
  }
  if len(result) > 0 {
    t.Errorf("api returned unexpected result: got Row want NoRow")
  } else {
    fmt.Println("  Done: RetrieveAll<< $table.CapName >> with no result")
  }
}
<< end >>

Вы заметите, что ничего не изменится, пока мы не дойдем до этой строки << range $index, $table := .Tables >>. До этой строки это в основном вспомогательные функции, которые одинаковы для всех таблиц. После этой строки мы создаем специфичные для таблиц функции, и generate.go требуется доступ к данным таблицы, которые мы проанализировали и проанализировали из файла .sql.

Непосредственно под этой линией есть пара других интересных частей, на которые стоит обратить внимание. << $table.TestData 1 >> - это место, где мы создаем тестовые данные, которые управляются полями структуры go, которые, в свою очередь, соответствуют полям .sql в таблице. Мы передаем идентификатор первичного ключа, который, как мы знаем, сгенерирует PostgreSQL. << $table.CompareMapFields >> - это место, где мы генерируем карту функций сравнения, о которой говорилось выше, чтобы мы могли использовать уникальные функции сравнения в зависимости от типа поля таблицы.

Я сказал это в первой статье, но стоит запомнить процесс. Наш шаблонный текстовый файл, такой как api_test.txt выше, оказывается довольно нечитаемым. Однако это не проблема, поскольку мы не разрабатываем его как текстовый файл. Сначала мы пишем красивую полностью готовую версию, компилируем ее, выполняем итерации, очищаем, рефакторируем, тестируем. Затем, когда мы все закончили с этим, мы превращаем его в уродливый текстовый файл, чтобы мы могли позже восстановить его с параметрами. Основная часть работы выполняется при создании исходного файла go и добавлении методов-получателя в generate.go, преобразование файла go в шаблонный текстовый файл происходит быстро, и мы можем легко повторить его много раз, если базовый код go значительно изменится.

<< range $index, $table := .Tables >>
const << $table.Name >>tableName = "<< $table.Name >>"
var test<< $table.CapSingName >> = [2]<< $table.CapSingName >>{ << $table.TestData 1 >>, << $table.TestData 2 >> }
var update<< $table.CapSingName >> = << $table.CapSingName >><< $table.TestData 1 >>
//compare functions
var compare<< $table.CapName >> = map[string]compareType{
<< $table.CompareMapFields >>
}

Единственное, что нам нужно добавить в наш исходный файл generate.go, - это возможность создавать тестовые данные для любого типа данных sql. Наша простая стратегия тестирования на данный момент - сохранить данные в базе данных PostgreSQL, а затем убедиться, что то же самое происходит снова. С нашими 27 различными типами данных sql, которые поддерживает metaapi, это означает множество функций генерации тестов. Требуются только три функции сравнения: defaultCompare, stringCompare и jsonCompare - вы можете найти их в api_test.txt выше.

добавлено в metapi / metasql / generate.go:

..abridged
//TEST SPECIFIC
type GenerateFunc func(int, int) string
type testFuncs struct {
  GenerateData GenerateFunc
  CompareData  string
}
var dataMap = map[string]testFuncs{
  "BOOLEAN":     {boolTestData, "defaultCompare"},
  "BOOL":        {boolTestData, "defaultCompare"},
  "CHARID":      {stringTestData, "defaultCompare"}, 
  "VARCHARID":   {stringTestData, "defaultCompare"},
  "TEXT":        {stringTestData, "defaultCompare"},
  "SMALLINT":    {int16TestData, "defaultCompare"},
  "INT":         {int32TestData, "defaultCompare"},
  "INTEGER":     {int32TestData, "defaultCompare"},
  "BIGINT":      {int32TestData, "defaultCompare"},
  "SMALLSERIAL": {serialTestData, "defaultCompare"},
  "SERIAL":      {serialTestData, "defaultCompare"},
  "BIGSERIAL":   {serialTestData, "defaultCompare"},
  "FLOATID":     {float64TestData, "defaultCompare"}, 
  "REAL":        {float32TestData, "defaultCompare"},
  "FLOAT8":      {float32TestData, "defaultCompare"},
  "DECIMAL":     {float64TestData, "defaultCompare"},
  "NUMERIC":     {float64TestData, "defaultCompare"},
  "NUMERICID":   {float64TestData, "defaultCompare"}, 
  "PRECISION":   {float64TestData, "defaultCompare"}, 
  "DATE":        {dateTestData, "stringCompare"},     
  "TIME":        {timeTestData, "stringCompare"},
  "TIMESTAMPTZ": {timestampTestData, "stringCompare"},
  "TIMESTAMP":   {timestampTestData, "stringCompare"},
  "INTERVAL":    {durationTestData, "defaultCompare"},
  "JSON":        {jsonTestData, "jsonCompare"}, 
  "JSONB":       {jsonbTestData, "jsonCompare"},
  "UUID":        {uuidTestData, "defaultCompare"},
}
func (table Table) CompareMapFields() string {
  var s string
  for _, column := range table.Columns {
    s += "\t\"" + camelize(column.Name) + "\": "
    s += dataMap[column.Type].CompareData + ",\n"
  }
  return s
}
func (table Table) TestData(dataid int) string {
  rand.Seed(time.Now().UnixNano())
  var s string
  s = "{"
  for columnid, column := range table.Columns {
    s += " " + dataMap[column.Type].GenerateData(dataid, columnid)
    s += comma(columnid, len(table.Columns))
  }
  s += "}"
  return s
}
func boolTestData(dataid int, columnid int) string {
  return (strconv.FormatBool(rand.Intn(2) != 0))
}
func stringTestData(dataid int, columnid int) string {
  return ("\"" + randString(16) + "\"")
}
func int16TestData(dataid int, columnid int) string {
  if columnid == 0 { //assume serial
    return (strconv.FormatInt(int64(dataid), 10))
  } else {
    return (strconv.FormatInt(int64(rand.Intn(32767)), 10))
  }
}
func int32TestData(dataid int, columnid int) string {
  if columnid == 0 { //assume serial
    return (strconv.FormatInt(int64(dataid), 10))
  } else {
    return (strconv.FormatInt(int64(rand.Int31()), 10))
  }
}
func int64TestData(dataid int, columnid int) string {
  if columnid == 0 { //assume serial
    return (strconv.FormatInt(int64(dataid), 10))
  } else {
    return (strconv.FormatInt(rand.Int63(), 10))
  }
}
func serialTestData(dataid int, columnid int) string {
  return strconv.Itoa(dataid)
}
func float64TestData(dataid int, columnid int) string {
  return (strconv.FormatFloat(rand.NormFloat64(), 'f', -1, 64))
}
func float32TestData(dataid int, columnid int) string {
  return (strconv.FormatFloat(float64(rand.Float32()), 'f', -1, 32))
}
func timeTestData(dataid int, columnid int) string {
  return "time.Date(0000, time.January, 1, time.Now().UTC().Hour(), time.Now().UTC().Minute(), time.Now().UTC().Second(), time.Now().UTC().Nanosecond(), time.UTC)"
}
func timestampTestData(dataid int, columnid int) string {
  return "time.Now().UTC().Truncate(time.Microsecond)"
}
func durationTestData(dataid int, columnid int) string {
  return "\"12:34:45\""
}
func dateTestData(dataid int, columnid int) string {
  return "time.Now().UTC().Truncate(time.Hour * 24)"
}
func jsonTestData(dataid int, columnid int) string {
  return randJson()
}
func jsonbTestData(dataid int, columnid int) string {
  return randJson()
}
func uuidTestData(dataid int, columnid int) string {
  return "\"" + randUUID() + "\""
}
func randString(length int) string {
  const charset = "abcdefghijklmnopqrstuvwxyz" +
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
  b := make([]byte, length)
  for i := range b {
    b[i] = charset[rand.Intn(len(charset))]
  }
  return string(b)
}
func randJson() string {
  return "\"{\\\"name\\\": \\\"" + randString(16) + "\\\", \\\"age\\\": " + strconv.FormatInt(int64(rand.Int31()), 10) + ", \\\"city\\\": \\\"" + randString(20) + "\\\"}\""
}
func randUUID() (uuid string) {
  u := new([16]byte)
  _, err := rand.Read(u[:])
  if err != nil {
    log.Panicln("Cannot generate UUID", err.Error())
  }
  u[8] = (u[8] | 0x40) & 0x7F
  u[6] = (u[6] & 0xF) | (0x4 << 4)
  uuid = fmt.Sprintf("%x-%x-%x-%x-%x", u[0:4], u[4:6], u[6:8], u[8:10], u[10:])
  return
}

Теперь, когда мы запускаем нашу новую версию metaapi в операторе go generate с использованием определения таблицы todo.sql, он создаст todo_api.go и todo_api_test.go

newtodo / todo.go:

//go:generate  metaapi -sql=todo.sql  -txt=api.txt
//go:generate  metaapi -sql=todo.sql  -txt=api_test.txt
package main

Разумеется, мы восстановили наш исходный файл api и тестовый файл. Это действительно работает для любого типа sql? Давайте попробуем файл sql, который определяет несколько таблиц, включая все 27 типов:

metaapi / examples / alltypes.sql:

create table todos (
  id           integer generated always as identity primary key,
  updated_at   timestamptz,
  done         boolean,
  title        text
);
create table allbools (
  id         serial primary key,
  abool      boolean,
  abool2     bool
);
create table allchars (
  id         serial primary key,
  achar      char(16),
  avarchar   varchar(16),
  atext      text
);
create table allints (
  id         serial primary key,
  asmallint  smallint,
  aint       int,
  aint2      integer,
  asmallser  smallserial,
  aser       serial,
  abigser    bigserial
);
create table allfloats (
  id         serial primary key,
  afloat     float(53),
  areal      real,
  afloat8    float8,
  adecimal   decimal,
  anumeric   numeric,
  anumeric2  numeric(36,18),
  adouble    double precision
);
create table alltimes (
  id         serial primary key,
  adate      date,
  atime      time,
  ats        timestamp,
  atsz       timestamptz,
  ainterval  interval
);
create table alljsons (
  id         serial primary key,
  ajson      json,
  ajsonb     jsonb
);
create table uuids (
  id         serial primary key,
  auuid      uuid
);

Ага, работает. Ну ладно, вроде как. Я обманул char (), float () и numeric (), выбрав параметры, которые, как я знал, будут работать, эти типы не будут обрабатывать любые произвольные параметры без улучшенных функций генерации тестовых данных. Реализация правильных функций генерации тестовых данных для них по-прежнему остается задачей.

Еще одна область, которая требует дополнительной работы, - это общая стратегия тестирования для работы с внешними ключами. Это не влияет на генерацию API CRUD, но влияет на тестирование. Прямо сейчас metaapi проверяет одну таблицу за раз, поэтому внешние ключи не пройдут тесты. Давай исправим это. Например:

метаапи / примеры / todoref.sql

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

С помощью всего лишь нескольких небольших модификаций мы можем изменить наши тесты, чтобы разрешить целочисленные внешние ключи. Я не буду показывать здесь изменения кода, но вы можете увидеть их в репозиториях. Наша стратегия будет заключаться в следующем:

  1. Добавьте bool в структуру Column для отслеживания полей, которые являются внешними ключами (generate.go)
  2. Добавьте функцию для установки этого поля при анализе синтаксиса REFERENCES table(field) sql (parse.go)
  3. Измените наши генераторы целочисленных тестовых данных, чтобы они использовали известные идентификаторы, если они являются внешними ключами (generator.go)
  4. Измените наш тестовый шаблон, чтобы удалить все таблицы в конце тестирования в обратном порядке.

Эти изменения гарантируют, что наши ссылочные таблицы будут рядом, когда они потребуются внешним ключам, и мы развернем все удаления в правильном порядке. Если вы создадите новый проект с помощью todoref.sql выше, сгенерируете код api / test и запустите тесты, вы увидите, что все тесты пройдены.

Внутренние шаблоны

На данный момент у нас есть metaapi, создающий api, а также тесты для этого api, однако мы требуем, чтобы пользователь скопировал наши шаблоны (api.txt и api_test.txt) в свой локальный проект, чтобы запустить go generate. Для пользователя было бы намного лучше, если бы мы могли получить доступ к ним внутри metaapi, чтобы пользователю не приходилось ими управлять. Оказывается, для этого есть приложение! - ну на самом деле инструмент командной строки под названием go-bindata, и хотя его не трогали пару лет, он все еще работает как шарм. Мы будем использовать go-bindata для сжатия и перевода наших исходных файлов в ресурсы, к которым мы можем получить доступ непосредственно в metaapi. Сначала мы устанавливаем go-bindata go get -u github.com/jteeuwen/go-bindata/..., затем создаем новый каталог с именем data, копируем в него наши шаблоны txt и запускаем go bindata в этом каталоге.

cd metaapi/data
go-bindata .

Это создаст новый файл metaapi / data / bindata.go, в котором наши файлы сжаты в ресурсы, и несколько хороших методов для взаимодействия с ними - все самодостаточные, больше ничего не нужно. Единственный важный для нас метод - это Asset (). Мы просто меняем наш ioutil.Readfile (), меняем имя пакета с main на data, и все готово, читая наши собственные ресурсы.

  //for external templates, instead of data.Asset, use:
  // dat, err := ioutil.ReadFile("./" + txtFile)
  // if err != nil {
  //  return err
  // }
  dat, err := data.Asset(txtFile)
  if err != nil {
   return err
  }

Не забывайте, что всякий раз, когда вы меняете свои ресурсы, в нашем случае api.txt и api_test.txt, вы должны снова запустить go-bindata для них и скомпилировать приложение. Все активы хранятся на bindata.go. Это замечательно, поскольку означает, что пользователям не нужны инструменты go-bindata, а разработчикам нужны, если они изменяют ресурсы.

Внешнее поколение

Теперь, когда мы перешли на внутренний доступ к нашим шаблонам api, у нас больше нет удобного способа для пользователей легко настраивать и расширять шаблоны и генератор. Давайте добавим это обратно и сделаем немного лучше. Прямо сейчас metaapi выполняет лексирование, синтаксический анализ и генерацию кода. Как вариант, мы бы хотели, чтобы metaapi выполнял лексирование и синтаксический анализ, а затем передавал результаты анализа (структура sm) произвольной программе, над которой мы работаем. Это улучшит наш рабочий процесс, если мы хотим использовать наши собственные шаблоны и генератор, поскольку нам не нужно перекомпилировать метаапи, когда мы вносим изменения в генератор.

Есть несколько подходов, которые мы можем использовать для этого. Сначала вы можете попытаться просто передать json-форму данных sm из metaapi в свой проект, например:

//go:generate  metaapi -sql=todo.sql -txt=api.txt | myproj
//this does not work

Проблема здесь в том, что go generate - это не оболочка, а просто необработанная команда, поэтому это не работает - конвейер - это функция оболочки. Вместо этого мы могли бы попытаться выполнить команду оболочки в go generate, но втягивание оболочки в нее не переносимо. Вы можете использовать несколько строк команд go generate, поэтому сохранение в промежуточный файл statemachine.json из metaapi в одной команде, а затем чтение в myproj в другой будет работать ... но какое уродливое решение. Наконец, пакет net / rpc golang можно использовать с несколькими новыми строками кода с обеих сторон, а затем передавать данные sm через вызов процедуры. Я попробовал это, и это сработало, но, похоже, немного переборщило с раскручиванием сервера rpc по запросу.

Давайте вместо этого исправим go generate, мы можем сделать это с помощью небольшой служебной программы, которую мы назовем «pipe». Его работа будет заключаться в выполнении любого количества команд с аргументами, передающими вывод одной на ввод следующей. Это просто, чисто и эффективно.

pipe / main.go:

//pipe: use in go generate statements to pipe commands
//commands are executed left to right
//stdout from preceding is piped into stdin of next
//commands are separated by ::
//format is: pipe cmd0 arg0 arg1.. :: cmd1 arg0 arg1.. :: cmdn..
//use pipe -v (verbose) to print output from each command
package main
import (
  "fmt"
  "io"
  "io/ioutil"
  "log"
  "os"
  "os/exec"
)
func main() {
  if len(os.Args) == 1 {
    fmt.Println(" valid usage is:")
    fmt.Println("  //go generate pipe cmd0 arg0 arg1.. :: cmd1 arg0 arg1.. :: cmdn..")
    fmt.Println("  //go generate pipe -v cmd0 arg0 arg1.. :: cmd1 arg0 arg1.. :: cmdn..")
    os.Exit(1)
  }
  //parse command string into nxm slice of strings
  verbose := false
  startindex := 0
  if os.Args[1] == "-v" { //verbose
    verbose = true
    startindex = 1
  }
  var cmda [][]string
  for i := startindex; i < len(os.Args); i++ {
    if (i == startindex) || (os.Args[i] == "::") {
      cmda = append(cmda, []string{})
    } else {
      cmda[len(cmda)-1] = append(cmda[len(cmda)-1], os.Args[i])
    }
  }
  var out []byte
  //execute all commands hooking up outputs to inputs
  for i := 0; i < len(cmda); i++ {
    fmt.Println("Command: ", cmda[i][0], cmda[i][1:])
    cmd := exec.Command(cmda[i][0], cmda[i][1:]...)
    stderr, err := cmd.StderrPipe()
    if err != nil {
      log.Fatal(err)
    }
    stdout, err := cmd.StdoutPipe()
    if err != nil {
      log.Fatal(err)
    }
    if i > 0 {
      stdin, err := cmd.StdinPipe()
      if err != nil {
        log.Fatal(err)
      }
      go func() {
        defer stdin.Close()
        io.WriteString(stdin, string(out))
      }()
    }
    if err := cmd.Start(); err != nil {
      log.Fatal(err)
    }
    newout, _ := ioutil.ReadAll(stdout)
    if verbose {
      fmt.Println(string(newout))
    }
    errtxt, _ := ioutil.ReadAll(stderr)
    if string(errtxt) != "" {
      fmt.Printf("%s\n", errtxt)
    }
    if err := cmd.Wait(); err != nil {
      log.Fatal(err)
    }
    out = newout
  }
}

Вы можете установить эту утилиту таким образом, обратите внимание, что она предназначена для запуска как необработанная команда в go generate, а не как команда оболочки, оболочка будет мешать ей.

go get github.com/exyzzy/pipe
go install $GOPATH/src/github.com/exyzzy/pipe

Чтобы использовать канал, вы просто включаете его в качестве первого шага в свой оператор go generate с другими вашими приложениями и любыми флагами, которые они используют, разделенными ::

//go generate pipe app1 -flag=app1flag :: app2 -flag=app2flag

Итак, теперь app1 будет выполнен, и любой стандартный вывод, который он имеет, будет передан в стандартный ввод app2, и вы можете объединить столько приложений, сколько захотите. Pipe также имеет параметр -v, чтобы использовать подробный режим, в котором он будет распечатывать приложения и аргументы, которые он выполняет.

Нам также необходимо внести некоторые изменения в метаапи, которые предоставляют возможность передавать json-версию данных Statemachine через stdout новому генератору в нашем проекте, который будет принимать их через stdin.

metaapi / main.go:

pipePtr := flag.Bool("pipe", false, "use piped generation")
flag.Parse()

..some code..
if !*pipePtr {
  err := metasql.Generate(*sm, txtFile)
  if err != nil {
    log.Panic(err)
  }
} else {
  //send to stdio
  psm, err := json.Marshal(sm)
  if err != nil {
    log.Panic(err)
    return
  }
  fmt.Println(string(psm))
}

Это позволяет нам теперь получать данные sm в нашем новом проекте и компилировать там генератор.

newtodo / main.go:

package main
import (
  "flag"
  "log"
  "os"
  "strings"
)
func main() {
  txtPtr := flag.String("txt", "api.txt", "go template as .txt file")
  pipePtr := flag.Bool("pipe", false, "use piped generation")
  flag.Parse()
  if *pipePtr { //set up for more code
    txtFile := strings.ToLower(*txtPtr)
    if (txtFile == "") || (!strings.HasSuffix(txtFile, ".txt")) {
      log.Panic("No .txt File")
    }
    sm, err := ReadSM()
    if err != nil {
      log.Panic(err)
    }
    err = Generate(*sm, txtFile)
    if err != nil {
      log.Panic(err)
    }
    os.Exit(0)
  }
    //more stuff...
}

newtodo / todo.go

//go:generate  pipe metaapi -pipe=true -sql=todo.sql  :: newtodo -pipe=true -txt=api.txt
//go:generate  pipe metaapi -pipe=true -sql=todo.sql  :: newtodo -pipe=true -txt=api_test.txt
//Note requires:
//      https://github.com/exyzzy/metaapi
//      https://github.com/exyzzy/pipe
package main
//before first go test:
//createuser -P -d newtodo <pass: newtodo>
//createdb newtodo

Теперь, в этом варианте использования metaapi, мы переместили generate.go и два файла шаблона: api.txt и api_test.txt в наш текущий проект newtodo. Это позволяет нам изменять шаблоны txt и generate.go в нашем текущем проекте и использовать их с метаапи без перекомпиляции метаапи. Это хороший вариант, если вы разрабатываете проект с пользовательскими шаблонами.

Автоматическое создание проекта

Сейчас у нас много чего происходит с metaapi и pipe. Даже с хорошей документацией нашим пользователям может быть сложно настроить и использовать, поэтому давайте упростим задачу, создав небольшую утилиту командной строки, которая автоматически создает начальные проекты metaapi. Мы будем использовать те же методы, которые уже использовали выше, для создания внутренних файлов ресурсов с использованием go-bindata, а затем использовать их с шаблонами go для параметризации наших шаблонов. Наша цель - создать в одной строке CLI проект для внутреннего или внешнего проекта metaapi.

Что мы сделаем здесь, так это скопируем все соответствующие файлы для нашего проекта в файлы .txt, которые будут шаблонизированы по мере необходимости. Затем мы будем использовать go-bindata, чтобы превратить их во внутренние активы. Наконец, мы получим к ним доступ в нашем main.go и применим их в качестве шаблонов для создания файлов нашего проекта. Это простой шаблон, который можно использовать для автоматического создания целевых файлов из шаблонных файлов txt.

metaproj / main.go:

package main
import (
  "errors"
  "flag"
  "fmt"
  "io/ioutil"
  "log"
  "os"
  "path/filepath"
  "strings"
  "text/template"
  "github.com/exyzzy/metaproj/data"
)
//This struct passed into all templates for generation
//also use receiver methods such as Project, below
type ProjData struct {
  ProjName string
  SqlFile  string
  ProjType string
}
// Create a metaapi base project from a .sql table definition, example:
//  metaproj -proj=newtodo -sql=todo.sql -type=external  (or, for internal:  metaproj -proj=newtodo -sql=todo.sql )
//  cd newtodo
//  go generate
func main() {
  projPtr := flag.String("proj", "myproj", "project to create")
  sqlPtr := flag.String("sql", "", ".sql input file to parse")
  typePtr := flag.String("type", "internal", "project type to create")
  var p ProjData
  flag.Parse()
  if flag.NFlag() == 0 {
    fmt.Println(" valid usage is:")
    fmt.Println("  metproj -proj=yourproj -sql=yoursql.sql")
    fmt.Println("  metproj -proj=yourproj -sql=yoursql.sql -type=external")
    os.Exit(1)
  }
  p.ProjName = strings.ToLower(*projPtr)
  p.SqlFile = strings.ToLower(*sqlPtr)
  p.ProjType = strings.ToLower(*typePtr)
  if (p.SqlFile != "") && (!strings.HasSuffix(p.SqlFile, ".sql")) {
    log.Panic("Invalid .sql File")
  }
  err := createProj(&p)
  if err != nil {
    log.Panic(err)
  }
}
func createProj(pp *ProjData) error {
  if (pp.ProjName == "") || (pp.SqlFile == "") {
    log.Panic(errors.New("Must have projName and sqlFile"))
  }
var projTypes = map[string]int{"internal": 0, "external": 1}
  pIndex, ok := projTypes[pp.ProjType]
  if !ok {
    log.Panic(errors.New(fmt.Sprintf("Invald project type, use: %v", keys(projTypes))))
  }
err := os.MkdirAll(pp.ProjName, os.FileMode(0755))
  if err != nil {
    return err
  }
type FileList struct {
    Name       string
    IsGenerate bool //if false just copy the file with no template actions applied
  }
var files = [][]FileList{{{"sqlname.txt", true}, {"configlocaldb.txt", true}},
    {{"sqlname2.txt", true}, {"configlocaldb.txt", true}, {"generate.txt", true}, {"main.txt", true}, {"api.txt", false}, {"api_test.txt", false}}}
  for _, f := range files[pIndex] {
    dat, err := data.Asset(f.Name)
    if err != nil {
      return err
    }
    if f.IsGenerate {
      err = generateFile(dat, pp, getDest(pp, f.Name))
      if err != nil {
        return err
      }
    } else {
      err = writeFile(dat, getDest(pp, f.Name))
    }
  }
  err = copyFile(pp, pp.SqlFile)
  return err
}
func keys(ms map[string]int) []string {
  kys := make([]string, len(ms))
  i := 0
  for k := range ms {
    kys[i] = k
    i++
  }
  return kys
}
func getDest(pp *ProjData, name string) string {
  var dest string
  switch name {
  case "sqlname.txt", "sqlname2.txt":
    dest = prefix(pp.SqlFile) + ".go"
  case "configlocaldb.txt":
    dest = "configlocaldb.json"
  case "generate.txt":
    dest = "generate.go"
  case "main.txt":
    dest = "main.go"
  case "api.txt":
    dest = "api.txt"
  case "api_test.txt":
    dest = "api_test.txt"
  }
  return filepath.Join(pp.ProjName, dest)
}
func prefix(name string) string {
  dot := strings.Index(name, ".")
  if dot > 0 {
    return name[:dot]
  } else {
    return name
  }
}
func generateFile(templatesrc []byte, data interface{}, dest string) error {
  tt := template.Must(template.New("file").Parse(string(templatesrc)))
  file, err := os.Create(dest)
  if err != nil {
    return err
  }
  err = tt.Execute(file, data)
  file.Close()
  return err
}
func copyFile(pp *ProjData, namesrc string) error {
  dat, err := ioutil.ReadFile("./" + namesrc)
  if err != nil {
    return err
  }
  err = ioutil.WriteFile(filepath.Join(pp.ProjName, namesrc), dat, 0644)
  if err != nil {
    return err
  }
  return nil
}
func writeFile(templatesrc []byte, dest string) error {
  err := ioutil.WriteFile(dest, templatesrc, 0644)
  if err != nil {
    return err
  }
  return nil
}
func (pp *ProjData) Package() string {
  proj := os.Getenv("GOPACKAGE")
  if proj == "" {
    proj = "main"
  }
  return proj
}

Обратите внимание, что мы используем вложенный массив имен файлов, чтобы определить, какие файлы используются для внутренних и внешних проектов metaapi. Завершающее логическое значение истинно, если применяются действия (параметры) шаблона, и ложно, если это прямая копия файла:

var files = [][]FileList{{{"sqlname.txt", true}, 
     {"configlocaldb.txt", true}},
    {{"sqlname2.txt", true}, {"configlocaldb.txt", true}, 
     {"generate.txt", true}, {"main.txt", true}, {"api.txt", false}, 
     {"api_test.txt", false}}}

Кроме того, мы вернемся к разделителям go по умолчанию в metaproj для наших шаблонов, так как один из шаблонов txt-файлов, который мы будем создавать, - это сам файл generate.go, который включает строку tt := template.Must(template.New(“file”).Delims(“<<”, “>>”).Parse(string(templatesrc))). Обратите внимание, что оператор Delim всегда будет выполняться как (неудачный) шаблон. действие, поскольку оно включает в себя разделители действий шаблона открытия и закрытия <<>>. Таким образом, нам нужны разные разделители в metaproj и в metaapi.

Посмотрите репозиторий github, чтобы увидеть полный набор файлов, так как есть папка data / txt и созданный из них bindata.go.

Теперь у нас есть простая утилита командной строки для создания начального проекта метаапи в двух разных вариантах на основе определения таблицы sql.

metaproj -sql=mysqltables -proj=myprojectname

Вы можете добавить необязательный -type=external, чтобы он создавал проект metaapi, использующий канал, чтобы вы могли настраивать шаблоны api txt и generate.go

Хорошо, давайте соберем все вместе и сейчас протестируем как для внутренних, так и для внешних проектов metaapi.

Сначала создайте базу данных проекта PostgreSQL, чтобы тесты работали:

createuser -P -d myproj <pass: myproj>
createdb myproj

Теперь создайте внутренний проект метаапи по умолчанию и протестируйте его:

go get github.com/exyzzy/metaapi
go install $GOPATH/src/github.com/exyzzy/metaapi
go get github.com/exyzzy/metaproj
go install $GOPATH/src/github.com/exyzzy/metaproj
cp $GOPATH/src/github.com/exyzzy/metaapi/examples/alltypes.sql .
# or your own postgreSQL table definition
rm -rf myproj
#clean out the old directory if needed
metaproj -sql=alltypes.sql -proj=myproj 
cd myproj
go generate
go test

Проект будет выглядеть так:

myproj/
    alltypes.go
    alltypes.sql
    alltypes_generated_api.go
    alltypes_generated_api_test.go
    configlocaldb.json

Наконец, давайте создадим внешний проект metaapi, в котором вы хотите разрабатывать собственные шаблоны:

go get github.com/exyzzy/metaapi
go install $GOPATH/src/github.com/exyzzy/metaapi
go get github.com/exyzzy/metaproj
go install $GOPATH/src/github.com/exyzzy/metaproj
go get github.com/exyzzy/pipe
go install $GOPATH/src/github.com/exyzzy/pipe
cp $GOPATH/src/github.com/exyzzy/metaapi/examples/alltypes.sql .
rm -rf myproj
#clean out the old directory if needed
metaproj -sql=alltypes.sql -proj=myproj -type=external 
# or your own postgreSQL table definition
cd myproj
go install
go generate
go test

Проект будет выглядеть так:

myproj/
    alltypes.go
    alltypes.sql
    alltypes_generated_api.go
    alltypes_generated_api_test.go
    configlocaldb.json
    api.txt
    api_test.txt
    generate.go
    main.go

Оба они должны:

  • Сгенерируйте исходный проект
  • Создайте API CRUD для таблиц sql
  • Создание базовых тестов для API CRUD
  • Пройти все тесты

Мы успешно расширили наш оригинальный инструмент metaapi до рабочей лошадки, которая может создавать CRUD api для большинства типов PostgreSQL и создавать соответствующие тестовые функции api. Мы создали служебную программу pipe, позволяющую объединить несколько приложений в цепочку вызовов go generate. Мы создали еще одну утилиту, metaproj, которая представляет собой общий шаблон для создания любого нового проекта из параметризованных базовых файлов. Это должно дать нам огромное преимущество при создании проектов go на основе определений таблиц PostgreSQL.

Вы можете найти весь исходный код для них по адресу:

Повеселись.