Автоматическое тестирование в Go
Дальнейшие приключения с метапрограммированием в Go. В этом мы опираемся на наш инструмент метапрограммирования Golang.
Это продолжение последней статьи Метапрограмма в Go, возможно, начну с нее, если вы еще не читали. В этой статье мы сосредоточимся на автоматическом создании тестов для нашего CRUD API. Напомним, что мы уже автоматически делаем сами api на основе определений таблиц PostgreSQL. Попутно мы также создадим пару утилит, которые сделают наш инструмент метапрограммирования более полезным.
Охваченные азартом от автоматического создания сотен строк компилируемого, работающего, безошибочного * (* не совсем, продолжайте читать) кода из нескольких строк определения таблицы SQL, мы, как молотки, ищем гвоздь. Что еще мы можем метапрограммировать? Оказывается, много, но на все нужно время. Когда начать?
Поставим четыре цели по улучшению метаапи.
- Автоматически создавать тесты для нашего сгенерированного метаапи API
- Используйте внутренние шаблоны для генерации кода по умолчанию
- Поддержка генерации кода вне метаапи
- Автоматически создать начальный проект 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") } }
В этом тестовом файле есть несколько моментов, на которые следует обратить внимание.
В начале тестов создается новая тестовая база данных. Для этого есть ряд причин:
- Для предотвращения загрязнения производственной базы данных
- Чтобы узнать идентификаторы серийных первичных ключей тестовых данных, не сохраняя их
- Чтобы протестировать создание табличных функций
Тестовые данные 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) );
С помощью всего лишь нескольких небольших модификаций мы можем изменить наши тесты, чтобы разрешить целочисленные внешние ключи. Я не буду показывать здесь изменения кода, но вы можете увидеть их в репозиториях. Наша стратегия будет заключаться в следующем:
- Добавьте bool в структуру Column для отслеживания полей, которые являются внешними ключами (generate.go)
- Добавьте функцию для установки этого поля при анализе синтаксиса
REFERENCES table(field)
sql (parse.go) - Измените наши генераторы целочисленных тестовых данных, чтобы они использовали известные идентификаторы, если они являются внешними ключами (generator.go)
- Измените наш тестовый шаблон, чтобы удалить все таблицы в конце тестирования в обратном порядке.
Эти изменения гарантируют, что наши ссылочные таблицы будут рядом, когда они потребуются внешним ключам, и мы развернем все удаления в правильном порядке. Если вы создадите новый проект с помощью 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.
Вы можете найти весь исходный код для них по адресу:
Повеселись.