Compare commits

...

3 Commits

Author SHA1 Message Date
0cc0b5f310 feat: 增加 orm 库 2023-08-20 13:51:00 +08:00
30c596f8ef feat: table 的输出 2023-08-20 13:50:39 +08:00
d4cfa79dfc feat: 增加单词单复数的处理 2023-08-20 13:48:07 +08:00
65 changed files with 13363 additions and 0 deletions

3
gdb/orm/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
**/.idea/*
cover.out
**db

157
gdb/orm/binder.go Normal file
View File

@ -0,0 +1,157 @@
//
// binder.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
"database/sql/driver"
"fmt"
"reflect"
"unsafe"
)
// makeNewPointersOf creates a map of [field name] -> pointer to fill it
// recursively. it will go down until reaches a driver.Valuer implementation, it will stop there.
func (b *binder) makeNewPointersOf(v reflect.Value) interface{} {
m := map[string]interface{}{}
actualV := v
for actualV.Type().Kind() == reflect.Ptr {
actualV = actualV.Elem()
}
if actualV.Type().Kind() == reflect.Struct {
for i := 0; i < actualV.NumField(); i++ {
f := actualV.Field(i)
if (f.Type().Kind() == reflect.Struct || f.Type().Kind() == reflect.Ptr) && !f.Type().Implements(reflect.TypeOf((*driver.Valuer)(nil)).Elem()) {
f = reflect.NewAt(actualV.Type().Field(i).Type, unsafe.Pointer(actualV.Field(i).UnsafeAddr()))
fm := b.makeNewPointersOf(f).(map[string]interface{})
for k, p := range fm {
m[k] = p
}
} else {
var fm *field
fm = b.s.getField(actualV.Type().Field(i))
if fm == nil {
fm = fieldMetadata(actualV.Type().Field(i), b.s.columnConstraints)[0]
}
m[fm.Name] = reflect.NewAt(actualV.Field(i).Type(), unsafe.Pointer(actualV.Field(i).UnsafeAddr())).Interface()
}
}
} else {
return v.Addr().Interface()
}
return m
}
// ptrsFor first allocates for all struct fields recursively until reaches a driver.Value impl
// then it will put them in a map with their correct field name as key, then loops over cts
// and for each one gets appropriate one from the map and adds it to pointer list.
func (b *binder) ptrsFor(v reflect.Value, cts []*sql.ColumnType) []interface{} {
ptrs := b.makeNewPointersOf(v)
var scanInto []interface{}
if reflect.TypeOf(ptrs).Kind() == reflect.Map {
nameToPtr := ptrs.(map[string]interface{})
for _, ct := range cts {
if nameToPtr[ct.Name()] != nil {
scanInto = append(scanInto, nameToPtr[ct.Name()])
}
}
} else {
scanInto = append(scanInto, ptrs)
}
return scanInto
}
type binder struct {
s *schema
}
func newBinder(s *schema) *binder {
return &binder{s: s}
}
// bind binds given rows to the given object at obj. obj should be a pointer
func (b *binder) bind(rows *sql.Rows, obj interface{}) error {
cts, err := rows.ColumnTypes()
if err != nil {
return err
}
t := reflect.TypeOf(obj)
v := reflect.ValueOf(obj)
if t.Kind() != reflect.Ptr {
return fmt.Errorf("obj should be a ptr")
}
// since passed input is always a pointer one deref is necessary
t = t.Elem()
v = v.Elem()
if t.Kind() == reflect.Slice {
// getting slice elemnt type -> slice[t]
t = t.Elem()
for rows.Next() {
var rowValue reflect.Value
// Since reflect.SetupConnections returns a pointer to the type, we need to unwrap it to get actual
rowValue = reflect.New(t).Elem()
// till we reach a not pointer type continue newing the underlying type.
for rowValue.IsZero() && rowValue.Type().Kind() == reflect.Ptr {
rowValue = reflect.New(rowValue.Type().Elem()).Elem()
}
newCts := make([]*sql.ColumnType, len(cts))
copy(newCts, cts)
ptrs := b.ptrsFor(rowValue, newCts)
err = rows.Scan(ptrs...)
if err != nil {
return err
}
for rowValue.Type() != t {
tmp := reflect.New(rowValue.Type())
tmp.Elem().Set(rowValue)
rowValue = tmp
}
v = reflect.Append(v, rowValue)
}
} else {
for rows.Next() {
ptrs := b.ptrsFor(v, cts)
err = rows.Scan(ptrs...)
if err != nil {
return err
}
}
}
// v is either struct or slice
reflect.ValueOf(obj).Elem().Set(v)
return nil
}
func bindToMap(rows *sql.Rows) ([]map[string]interface{}, error) {
cts, err := rows.ColumnTypes()
if err != nil {
return nil, err
}
var ms []map[string]interface{}
for rows.Next() {
var ptrs []interface{}
for _, ct := range cts {
ptrs = append(ptrs, reflect.New(ct.ScanType()).Interface())
}
err = rows.Scan(ptrs...)
if err != nil {
return nil, err
}
m := map[string]interface{}{}
for i, ptr := range ptrs {
m[cts[i].Name()] = reflect.ValueOf(ptr).Elem().Interface()
}
ms = append(ms, m)
}
return ms, nil
}

91
gdb/orm/binder_test.go Normal file
View File

@ -0,0 +1,91 @@
//
// binder_test.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
)
type User struct {
ID int64
Name string
Timestamps
}
func (u User) ConfigureEntity(e *EntityConfigurator) {
e.Table("users")
}
type Address struct {
ID int
Path string
}
func TestBind(t *testing.T) {
t.Run("single result", func(t *testing.T) {
db, mock, err := sqlmock.New()
assert.NoError(t, err)
mock.
ExpectQuery("SELECT .* FROM users").
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at", "deleted_at"}).
AddRow(1, "amirreza", sql.NullTime{Time: time.Now(), Valid: true}, sql.NullTime{Time: time.Now(), Valid: true}, sql.NullTime{}))
rows, err := db.Query(`SELECT * FROM users`)
assert.NoError(t, err)
u := &User{}
md := schemaOfHeavyReflectionStuff(u)
err = newBinder(md).bind(rows, u)
assert.NoError(t, err)
assert.Equal(t, "amirreza", u.Name)
})
t.Run("multi result", func(t *testing.T) {
db, mock, err := sqlmock.New()
assert.NoError(t, err)
mock.
ExpectQuery("SELECT .* FROM users").
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "amirreza").AddRow(2, "milad"))
rows, err := db.Query(`SELECT * FROM users`)
assert.NoError(t, err)
md := schemaOfHeavyReflectionStuff(&User{})
var users []*User
err = newBinder(md).bind(rows, &users)
assert.NoError(t, err)
assert.Equal(t, "amirreza", users[0].Name)
assert.Equal(t, "milad", users[1].Name)
})
}
func TestBindMap(t *testing.T) {
db, mock, err := sqlmock.New()
assert.NoError(t, err)
mock.
ExpectQuery("SELECT .* FROM users").
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at", "deleted_at"}).
AddRow(1, "amirreza", sql.NullTime{Time: time.Now(), Valid: true}, sql.NullTime{Time: time.Now(), Valid: true}, sql.NullTime{}))
rows, err := db.Query(`SELECT * FROM users`)
assert.NoError(t, err)
ms, err := bindToMap(rows)
assert.NoError(t, err)
assert.NotEmpty(t, ms)
assert.Len(t, ms, 1)
}

192
gdb/orm/configurators.go Normal file
View File

@ -0,0 +1,192 @@
//
// configurators.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
"git.hexq.cn/tiglog/golib/helper"
)
type EntityConfigurator struct {
connection string
table string
this Entity
relations map[string]interface{}
resolveRelations []func()
columnConstraints []*FieldConfigurator
}
func newEntityConfigurator() *EntityConfigurator {
return &EntityConfigurator{}
}
func (ec *EntityConfigurator) Table(name string) *EntityConfigurator {
ec.table = name
return ec
}
func (ec *EntityConfigurator) Connection(name string) *EntityConfigurator {
ec.connection = name
return ec
}
func (ec *EntityConfigurator) HasMany(property Entity, config HasManyConfig) *EntityConfigurator {
if ec.relations == nil {
ec.relations = map[string]interface{}{}
}
ec.resolveRelations = append(ec.resolveRelations, func() {
if config.PropertyForeignKey != "" && config.PropertyTable != "" {
ec.relations[config.PropertyTable] = config
return
}
configurator := newEntityConfigurator()
property.ConfigureEntity(configurator)
if config.PropertyTable == "" {
config.PropertyTable = configurator.table
}
if config.PropertyForeignKey == "" {
config.PropertyForeignKey = helper.NewPluralizeClient().Singular(ec.table) + "_id"
}
ec.relations[configurator.table] = config
return
})
return ec
}
func (ec *EntityConfigurator) HasOne(property Entity, config HasOneConfig) *EntityConfigurator {
if ec.relations == nil {
ec.relations = map[string]interface{}{}
}
ec.resolveRelations = append(ec.resolveRelations, func() {
if config.PropertyForeignKey != "" && config.PropertyTable != "" {
ec.relations[config.PropertyTable] = config
return
}
configurator := newEntityConfigurator()
property.ConfigureEntity(configurator)
if config.PropertyTable == "" {
config.PropertyTable = configurator.table
}
if config.PropertyForeignKey == "" {
config.PropertyForeignKey = helper.NewPluralizeClient().Singular(ec.table) + "_id"
}
ec.relations[configurator.table] = config
return
})
return ec
}
func (ec *EntityConfigurator) BelongsTo(owner Entity, config BelongsToConfig) *EntityConfigurator {
if ec.relations == nil {
ec.relations = map[string]interface{}{}
}
ec.resolveRelations = append(ec.resolveRelations, func() {
if config.ForeignColumnName != "" && config.LocalForeignKey != "" && config.OwnerTable != "" {
ec.relations[config.OwnerTable] = config
return
}
ownerConfigurator := newEntityConfigurator()
owner.ConfigureEntity(ownerConfigurator)
if config.OwnerTable == "" {
config.OwnerTable = ownerConfigurator.table
}
if config.LocalForeignKey == "" {
config.LocalForeignKey = helper.NewPluralizeClient().Singular(ownerConfigurator.table) + "_id"
}
if config.ForeignColumnName == "" {
config.ForeignColumnName = "id"
}
ec.relations[ownerConfigurator.table] = config
})
return ec
}
func (ec *EntityConfigurator) BelongsToMany(owner Entity, config BelongsToManyConfig) *EntityConfigurator {
if ec.relations == nil {
ec.relations = map[string]interface{}{}
}
ec.resolveRelations = append(ec.resolveRelations, func() {
ownerConfigurator := newEntityConfigurator()
owner.ConfigureEntity(ownerConfigurator)
if config.OwnerLookupColumn == "" {
var pkName string
for _, field := range genericFieldsOf(owner) {
if field.IsPK {
pkName = field.Name
}
}
config.OwnerLookupColumn = pkName
}
if config.OwnerTable == "" {
config.OwnerTable = ownerConfigurator.table
}
if config.IntermediateTable == "" {
panic("cannot infer intermediate table yet")
}
if config.IntermediatePropertyID == "" {
config.IntermediatePropertyID = helper.NewPluralizeClient().Singular(ownerConfigurator.table) + "_id"
}
if config.IntermediateOwnerID == "" {
config.IntermediateOwnerID = helper.NewPluralizeClient().Singular(ec.table) + "_id"
}
ec.relations[ownerConfigurator.table] = config
})
return ec
}
type FieldConfigurator struct {
fieldName string
nullable sql.NullBool
primaryKey bool
column string
isCreatedAt bool
isUpdatedAt bool
isDeletedAt bool
}
func (ec *EntityConfigurator) Field(name string) *FieldConfigurator {
cc := &FieldConfigurator{fieldName: name}
ec.columnConstraints = append(ec.columnConstraints, cc)
return cc
}
func (fc *FieldConfigurator) IsPrimaryKey() *FieldConfigurator {
fc.primaryKey = true
return fc
}
func (fc *FieldConfigurator) IsCreatedAt() *FieldConfigurator {
fc.isCreatedAt = true
return fc
}
func (fc *FieldConfigurator) IsUpdatedAt() *FieldConfigurator {
fc.isUpdatedAt = true
return fc
}
func (fc *FieldConfigurator) IsDeletedAt() *FieldConfigurator {
fc.isDeletedAt = true
return fc
}
func (fc *FieldConfigurator) ColumnName(name string) *FieldConfigurator {
fc.column = name
return fc
}

160
gdb/orm/connection.go Normal file
View File

@ -0,0 +1,160 @@
//
// connection.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
"fmt"
"git.hexq.cn/tiglog/golib/helper/table"
)
type connection struct {
Name string
Dialect *Dialect
DB *sql.DB
Schemas map[string]*schema
DBSchema map[string][]columnSpec
DatabaseValidations bool
}
func (c *connection) inferedTables() []string {
var tables []string
for t, s := range c.Schemas {
tables = append(tables, t)
for _, relC := range s.relations {
if belongsToManyConfig, is := relC.(BelongsToManyConfig); is {
tables = append(tables, belongsToManyConfig.IntermediateTable)
}
}
}
return tables
}
func (c *connection) validateAllTablesArePresent() error {
for _, inferedTable := range c.inferedTables() {
if _, exists := c.DBSchema[inferedTable]; !exists {
return fmt.Errorf("orm infered %s but it's not found in your database, your database is out of sync", inferedTable)
}
}
return nil
}
func (c *connection) validateTablesSchemas() error {
// check for entity tables: there should not be any struct field that does not have a coresponding column
for table, sc := range c.Schemas {
if columns, exists := c.DBSchema[table]; exists {
for _, f := range sc.fields {
found := false
for _, c := range columns {
if c.Name == f.Name {
found = true
}
}
if !found {
return fmt.Errorf("column %s not found while it was inferred", f.Name)
}
}
} else {
return fmt.Errorf("tables are out of sync, %s was inferred but not present in database", table)
}
}
// check for relation tables: for HasMany,HasOne relations check if OWNER pk column is in PROPERTY,
// for BelongsToMany check intermediate table has 2 pk for two entities
for table, sc := range c.Schemas {
for _, rel := range sc.relations {
switch rel.(type) {
case BelongsToConfig:
columns := c.DBSchema[table]
var found bool
for _, col := range columns {
if col.Name == rel.(BelongsToConfig).LocalForeignKey {
found = true
}
}
if !found {
return fmt.Errorf("cannot find local foreign key %s for relation", rel.(BelongsToConfig).LocalForeignKey)
}
case BelongsToManyConfig:
columns := c.DBSchema[rel.(BelongsToManyConfig).IntermediateTable]
var foundOwner bool
var foundProperty bool
for _, col := range columns {
if col.Name == rel.(BelongsToManyConfig).IntermediateOwnerID {
foundOwner = true
}
if col.Name == rel.(BelongsToManyConfig).IntermediatePropertyID {
foundProperty = true
}
}
if !foundOwner || !foundProperty {
return fmt.Errorf("table schema for %s is not correct one of foreign keys is not present", rel.(BelongsToManyConfig).IntermediateTable)
}
}
}
}
return nil
}
func (c *connection) Schematic() {
fmt.Printf("SQL Dialect: %s\n", c.Dialect.DriverName)
for t, schema := range c.Schemas {
fmt.Printf("t: %s\n", t)
w := table.NewWriter()
w.AppendHeader(table.Row{"SQL Name", "Type", "Is Primary Key", "Is Virtual"})
for _, field := range schema.fields {
w.AppendRow(table.Row{field.Name, field.Type, field.IsPK, field.Virtual})
}
fmt.Println(w.Render())
for _, rel := range schema.relations {
switch rel.(type) {
case HasOneConfig:
fmt.Printf("%s 1-1 %s => %+v\n", t, rel.(HasOneConfig).PropertyTable, rel)
case HasManyConfig:
fmt.Printf("%s 1-N %s => %+v\n", t, rel.(HasManyConfig).PropertyTable, rel)
case BelongsToConfig:
fmt.Printf("%s N-1 %s => %+v\n", t, rel.(BelongsToConfig).OwnerTable, rel)
case BelongsToManyConfig:
fmt.Printf("%s N-N %s => %+v\n", t, rel.(BelongsToManyConfig).IntermediateTable, rel)
}
}
fmt.Println("")
}
}
func (c *connection) getSchema(t string) *schema {
return c.Schemas[t]
}
func (c *connection) setSchema(e Entity, s *schema) {
var configurator EntityConfigurator
e.ConfigureEntity(&configurator)
c.Schemas[configurator.table] = s
}
func GetConnection(name string) *connection {
return globalConnections[name]
}
func (c *connection) exec(q string, args ...any) (sql.Result, error) {
return c.DB.Exec(q, args...)
}
func (c *connection) query(q string, args ...any) (*sql.Rows, error) {
return c.DB.Query(q, args...)
}
func (c *connection) queryRow(q string, args ...any) *sql.Row {
return c.DB.QueryRow(q, args...)
}

109
gdb/orm/dialect.go Normal file
View File

@ -0,0 +1,109 @@
//
// dialect.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
"fmt"
)
type Dialect struct {
DriverName string
PlaceholderChar string
IncludeIndexInPlaceholder bool
AddTableNameInSelectColumns bool
PlaceHolderGenerator func(n int) []string
QueryListTables string
QueryTableSchema string
}
func getListOfTables(query string) func(db *sql.DB) ([]string, error) {
return func(db *sql.DB) ([]string, error) {
rows, err := db.Query(query)
if err != nil {
return nil, err
}
var tables []string
for rows.Next() {
var table string
err = rows.Scan(&table)
if err != nil {
return nil, err
}
tables = append(tables, table)
}
return tables, nil
}
}
type columnSpec struct {
//0|id|INTEGER|0||1
Name string
Type string
Nullable bool
DefaultValue sql.NullString
IsPrimaryKey bool
}
func getTableSchema(query string) func(db *sql.DB, query string) ([]columnSpec, error) {
return func(db *sql.DB, table string) ([]columnSpec, error) {
rows, err := db.Query(fmt.Sprintf(query, table))
if err != nil {
return nil, err
}
var output []columnSpec
for rows.Next() {
var cs columnSpec
var nullable string
var pk int
err = rows.Scan(&cs.Name, &cs.Type, &nullable, &cs.DefaultValue, &pk)
if err != nil {
return nil, err
}
cs.Nullable = nullable == "notnull"
cs.IsPrimaryKey = pk == 1
output = append(output, cs)
}
return output, nil
}
}
var Dialects = &struct {
MySQL *Dialect
PostgreSQL *Dialect
SQLite3 *Dialect
}{
MySQL: &Dialect{
DriverName: "mysql",
PlaceholderChar: "?",
IncludeIndexInPlaceholder: false,
AddTableNameInSelectColumns: true,
PlaceHolderGenerator: questionMarks,
QueryListTables: "SHOW TABLES",
QueryTableSchema: "DESCRIBE %s",
},
PostgreSQL: &Dialect{
DriverName: "postgres",
PlaceholderChar: "$",
IncludeIndexInPlaceholder: true,
AddTableNameInSelectColumns: true,
PlaceHolderGenerator: postgresPlaceholder,
QueryListTables: `\dt`,
QueryTableSchema: `\d %s`,
},
SQLite3: &Dialect{
DriverName: "sqlite3",
PlaceholderChar: "?",
IncludeIndexInPlaceholder: false,
AddTableNameInSelectColumns: false,
PlaceHolderGenerator: questionMarks,
QueryListTables: "SELECT name FROM sqlite_schema WHERE type='table'",
QueryTableSchema: `SELECT name,type,"notnull","dflt_value","pk" FROM PRAGMA_TABLE_INFO('%s')`,
},
}

75
gdb/orm/field.go Normal file
View File

@ -0,0 +1,75 @@
//
// field.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql/driver"
"reflect"
"strings"
"git.hexq.cn/tiglog/golib/helper"
)
type field struct {
Name string
IsPK bool
Virtual bool
IsCreatedAt bool
IsUpdatedAt bool
IsDeletedAt bool
Nullable bool
Default any
Type reflect.Type
}
func getFieldConfiguratorFor(fieldConfigurators []*FieldConfigurator, name string) *FieldConfigurator {
for _, fc := range fieldConfigurators {
if fc.fieldName == name {
return fc
}
}
return &FieldConfigurator{}
}
func fieldMetadata(ft reflect.StructField, fieldConfigurators []*FieldConfigurator) []*field {
var fms []*field
fc := getFieldConfiguratorFor(fieldConfigurators, ft.Name)
baseFm := &field{}
baseFm.Type = ft.Type
fms = append(fms, baseFm)
if fc.column != "" {
baseFm.Name = fc.column
} else {
baseFm.Name = helper.SnakeString(ft.Name)
}
if strings.ToLower(ft.Name) == "id" || fc.primaryKey {
baseFm.IsPK = true
}
if strings.ToLower(ft.Name) == "createdat" || fc.isCreatedAt {
baseFm.IsCreatedAt = true
}
if strings.ToLower(ft.Name) == "updatedat" || fc.isUpdatedAt {
baseFm.IsUpdatedAt = true
}
if strings.ToLower(ft.Name) == "deletedat" || fc.isDeletedAt {
baseFm.IsDeletedAt = true
}
if ft.Type.Kind() == reflect.Struct || ft.Type.Kind() == reflect.Ptr {
t := ft.Type
if ft.Type.Kind() == reflect.Ptr {
t = ft.Type.Elem()
}
if !t.Implements(reflect.TypeOf((*driver.Valuer)(nil)).Elem()) {
for i := 0; i < t.NumField(); i++ {
fms = append(fms, fieldMetadata(t.Field(i), fieldConfigurators)...)
}
fms = fms[1:]
}
}
return fms
}

654
gdb/orm/orm.go Normal file
View File

@ -0,0 +1,654 @@
//
// orm.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
"fmt"
"reflect"
"time"
// Drivers
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
)
var globalConnections = map[string]*connection{}
// Schematic prints all information ORM inferred from your entities in startup, remember to pass
// your entities in Entities when you call SetupConnections if you want their data inferred
// otherwise Schematic does not print correct data since GoLobby ORM also
// incrementally cache your entities metadata and schema.
func Schematic() {
for conn, connObj := range globalConnections {
fmt.Printf("----------------%s---------------\n", conn)
connObj.Schematic()
fmt.Println("-----------------------------------")
}
}
type ConnectionConfig struct {
// Name of your database connection, it's up to you to name them anything
// just remember that having a connection name is mandatory if
// you have multiple connections
Name string
// If you already have an active database connection configured pass it in this value and
// do not pass Driver and DSN fields.
DB *sql.DB
// Which dialect of sql to generate queries for, you don't need it most of the times when you are using
// traditional databases such as mysql, sqlite3, postgres.
Dialect *Dialect
// List of entities that you want to use for this connection, remember that you can ignore this field
// and GoLobby ORM will build our metadata cache incrementally but you will lose schematic
// information that we can provide you and also potentialy validations that we
// can do with the database
Entities []Entity
// Database validations, check if all tables exists and also table schemas contains all necessary columns.
// Check if all infered tables exist in your database
DatabaseValidations bool
}
// SetupConnections declares a new connections for ORM.
func SetupConnections(configs ...ConnectionConfig) error {
for _, c := range configs {
if err := setupConnection(c); err != nil {
return err
}
}
for _, conn := range globalConnections {
if !conn.DatabaseValidations {
continue
}
tables, err := getListOfTables(conn.Dialect.QueryListTables)(conn.DB)
if err != nil {
return err
}
for _, table := range tables {
if conn.DatabaseValidations {
spec, err := getTableSchema(conn.Dialect.QueryTableSchema)(conn.DB, table)
if err != nil {
return err
}
conn.DBSchema[table] = spec
} else {
conn.DBSchema[table] = nil
}
}
// check tables existence
if conn.DatabaseValidations {
err := conn.validateAllTablesArePresent()
if err != nil {
return err
}
}
if conn.DatabaseValidations {
err = conn.validateTablesSchemas()
if err != nil {
return err
}
}
}
return nil
}
func setupConnection(config ConnectionConfig) error {
schemas := map[string]*schema{}
if config.Name == "" {
config.Name = "default"
}
for _, entity := range config.Entities {
s := schemaOfHeavyReflectionStuff(entity)
var configurator EntityConfigurator
entity.ConfigureEntity(&configurator)
schemas[configurator.table] = s
}
s := &connection{
Name: config.Name,
DB: config.DB,
Dialect: config.Dialect,
Schemas: schemas,
DBSchema: make(map[string][]columnSpec),
DatabaseValidations: config.DatabaseValidations,
}
globalConnections[fmt.Sprintf("%s", config.Name)] = s
return nil
}
// Entity defines the interface that each of your structs that
// you want to use as database entities should have,
// it's a simple one and its ConfigureEntity.
type Entity interface {
// ConfigureEntity should be defined for all of your database entities
// and it can define Table, DB and also relations of your Entity.
ConfigureEntity(e *EntityConfigurator)
}
// InsertAll given entities into database based on their ConfigureEntity
// we can find table and also DB name.
func InsertAll(objs ...Entity) error {
if len(objs) == 0 {
return nil
}
s := getSchemaFor(objs[0])
cols := s.Columns(false)
var values [][]interface{}
for _, obj := range objs {
createdAtF := s.createdAt()
if createdAtF != nil {
genericSet(obj, createdAtF.Name, sql.NullTime{Time: time.Now(), Valid: true})
}
updatedAtF := s.updatedAt()
if updatedAtF != nil {
genericSet(obj, updatedAtF.Name, sql.NullTime{Time: time.Now(), Valid: true})
}
values = append(values, genericValuesOf(obj, false))
}
is := insertStmt{
PlaceHolderGenerator: s.getDialect().PlaceHolderGenerator,
Table: s.getTable(),
Columns: cols,
Values: values,
}
q, args := is.ToSql()
_, err := s.getConnection().exec(q, args...)
if err != nil {
return err
}
return nil
}
// Insert given entity into database based on their ConfigureEntity
// we can find table and also DB name.
func Insert(o Entity) error {
s := getSchemaFor(o)
cols := s.Columns(false)
var values [][]interface{}
createdAtF := s.createdAt()
if createdAtF != nil {
genericSet(o, createdAtF.Name, sql.NullTime{Time: time.Now(), Valid: true})
}
updatedAtF := s.updatedAt()
if updatedAtF != nil {
genericSet(o, updatedAtF.Name, sql.NullTime{Time: time.Now(), Valid: true})
}
values = append(values, genericValuesOf(o, false))
is := insertStmt{
PlaceHolderGenerator: s.getDialect().PlaceHolderGenerator,
Table: s.getTable(),
Columns: cols,
Values: values,
}
if s.getDialect().DriverName == "postgres" {
is.Returning = s.pkName()
}
q, args := is.ToSql()
res, err := s.getConnection().exec(q, args...)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
if s.pkName() != "" {
// intermediate tables usually have no single pk column.
s.setPK(o, id)
}
return nil
}
func isZero(val interface{}) bool {
switch val.(type) {
case int64:
return val.(int64) == 0
case int:
return val.(int) == 0
case string:
return val.(string) == ""
default:
return reflect.ValueOf(val).Elem().IsZero()
}
}
// Save saves given entity, if primary key is set
// we will make an update query and if
// primary key is zero value we will
// insert it.
func Save(obj Entity) error {
if isZero(getSchemaFor(obj).getPK(obj)) {
return Insert(obj)
} else {
return Update(obj)
}
}
// Find finds the Entity you want based on generic type and primary key you passed.
func Find[T Entity](id interface{}) (T, error) {
var q string
out := new(T)
md := getSchemaFor(*out)
q, args, err := NewQueryBuilder[T](md).
SetDialect(md.getDialect()).
Table(md.Table).
Select(md.Columns(true)...).
Where(md.pkName(), id).
ToSql()
if err != nil {
return *out, err
}
err = bind[T](out, q, args)
if err != nil {
return *out, err
}
return *out, nil
}
func toKeyValues(obj Entity, withPK bool) []any {
var tuples []any
vs := genericValuesOf(obj, withPK)
cols := getSchemaFor(obj).Columns(withPK)
for i, col := range cols {
tuples = append(tuples, col, vs[i])
}
return tuples
}
// Update given Entity in database.
func Update(obj Entity) error {
s := getSchemaFor(obj)
q, args, err := NewQueryBuilder[Entity](s).
SetDialect(s.getDialect()).
Set(toKeyValues(obj, false)...).
Where(s.pkName(), genericGetPKValue(obj)).Table(s.Table).ToSql()
if err != nil {
return err
}
_, err = s.getConnection().exec(q, args...)
return err
}
// Delete given Entity from database
func Delete(obj Entity) error {
s := getSchemaFor(obj)
genericSet(obj, "deleted_at", sql.NullTime{Time: time.Now(), Valid: true})
query, args, err := NewQueryBuilder[Entity](s).SetDialect(s.getDialect()).Table(s.Table).Where(s.pkName(), genericGetPKValue(obj)).SetDelete().ToSql()
if err != nil {
return err
}
_, err = s.getConnection().exec(query, args...)
return err
}
func bind[T Entity](output interface{}, q string, args []interface{}) error {
outputMD := getSchemaFor(*new(T))
rows, err := outputMD.getConnection().query(q, args...)
if err != nil {
return err
}
return newBinder(outputMD).bind(rows, output)
}
// HasManyConfig contains all information we need for querying HasMany relationships.
// We can infer both fields if you have them in standard way but you
// can specify them if you want custom ones.
type HasManyConfig struct {
// PropertyTable is table of the property of HasMany relationship,
// consider `Comment` in Post and Comment relationship,
// each Post HasMany Comment, so PropertyTable is
// `comments`.
PropertyTable string
// PropertyForeignKey is the foreign key field name in the property table,
// for example in Post HasMany Comment, if comment has `post_id` field,
// it's the PropertyForeignKey field.
PropertyForeignKey string
}
// HasMany configures a QueryBuilder for a HasMany relationship
// this relationship will be defined for owner argument
// that has many of PROPERTY generic type for example
// HasMany[Comment](&Post{})
// is for Post HasMany Comment relationship.
func HasMany[PROPERTY Entity](owner Entity) *QueryBuilder[PROPERTY] {
outSchema := getSchemaFor(*new(PROPERTY))
q := NewQueryBuilder[PROPERTY](outSchema)
// getting config from our cache
c, ok := getSchemaFor(owner).relations[outSchema.Table].(HasManyConfig)
if !ok {
q.err = fmt.Errorf("wrong config passed for HasMany")
}
s := getSchemaFor(owner)
return q.
SetDialect(s.getDialect()).
Table(c.PropertyTable).
Select(outSchema.Columns(true)...).
Where(c.PropertyForeignKey, genericGetPKValue(owner))
}
// HasOneConfig contains all information we need for a HasOne relationship,
// it's similar to HasManyConfig.
type HasOneConfig struct {
// PropertyTable is table of the property of HasOne relationship,
// consider `HeaderPicture` in Post and HeaderPicture relationship,
// each Post HasOne HeaderPicture, so PropertyTable is
// `header_pictures`.
PropertyTable string
// PropertyForeignKey is the foreign key field name in the property table,
// forexample in Post HasOne HeaderPicture, if header_picture has `post_id` field,
// it's the PropertyForeignKey field.
PropertyForeignKey string
}
// HasOne configures a QueryBuilder for a HasOne relationship
// this relationship will be defined for owner argument
// that has one of PROPERTY generic type for example
// HasOne[HeaderPicture](&Post{})
// is for Post HasOne HeaderPicture relationship.
func HasOne[PROPERTY Entity](owner Entity) *QueryBuilder[PROPERTY] {
property := getSchemaFor(*new(PROPERTY))
q := NewQueryBuilder[PROPERTY](property)
c, ok := getSchemaFor(owner).relations[property.Table].(HasOneConfig)
if !ok {
q.err = fmt.Errorf("wrong config passed for HasOne")
}
// settings default config Values
return q.
SetDialect(property.getDialect()).
Table(c.PropertyTable).
Select(property.Columns(true)...).
Where(c.PropertyForeignKey, genericGetPKValue(owner))
}
// BelongsToConfig contains all information we need for a BelongsTo relationship
// BelongsTo is a relationship between a Comment and it's Post,
// A Comment BelongsTo Post.
type BelongsToConfig struct {
// OwnerTable is the table that contains owner of a BelongsTo
// relationship.
OwnerTable string
// LocalForeignKey is name of the field that links property
// to its owner in BelongsTo relation. for example when
// a Comment BelongsTo Post, LocalForeignKey is
// post_id of Comment.
LocalForeignKey string
// ForeignColumnName is name of the field that LocalForeignKey
// field value will point to it, for example when
// a Comment BelongsTo Post, ForeignColumnName is
// id of Post.
ForeignColumnName string
}
// BelongsTo configures a QueryBuilder for a BelongsTo relationship between
// OWNER type parameter and property argument, so
// property BelongsTo OWNER.
func BelongsTo[OWNER Entity](property Entity) *QueryBuilder[OWNER] {
owner := getSchemaFor(*new(OWNER))
q := NewQueryBuilder[OWNER](owner)
c, ok := getSchemaFor(property).relations[owner.Table].(BelongsToConfig)
if !ok {
q.err = fmt.Errorf("wrong config passed for BelongsTo")
}
ownerIDidx := 0
for idx, field := range owner.fields {
if field.Name == c.LocalForeignKey {
ownerIDidx = idx
}
}
ownerID := genericValuesOf(property, true)[ownerIDidx]
return q.
SetDialect(owner.getDialect()).
Table(c.OwnerTable).Select(owner.Columns(true)...).
Where(c.ForeignColumnName, ownerID)
}
// BelongsToManyConfig contains information that we
// need for creating many to many queries.
type BelongsToManyConfig struct {
// IntermediateTable is the name of the middle table
// in a BelongsToMany (Many to Many) relationship.
// for example when we have Post BelongsToMany
// Category, this table will be post_categories
// table, remember that this field cannot be
// inferred.
IntermediateTable string
// IntermediatePropertyID is the name of the field name
// of property foreign key in intermediate table,
// for example when we have Post BelongsToMany
// Category, in post_categories table, it would
// be post_id.
IntermediatePropertyID string
// IntermediateOwnerID is the name of the field name
// of property foreign key in intermediate table,
// for example when we have Post BelongsToMany
// Category, in post_categories table, it would
// be category_id.
IntermediateOwnerID string
// Table name of the owner in BelongsToMany relation,
// for example in Post BelongsToMany Category
// Owner table is name of Category table
// for example `categories`.
OwnerTable string
// OwnerLookupColumn is name of the field in the owner
// table that is used in query, for example in Post BelongsToMany Category
// Owner lookup field would be Category primary key which is id.
OwnerLookupColumn string
}
// BelongsToMany configures a QueryBuilder for a BelongsToMany relationship
func BelongsToMany[OWNER Entity](property Entity) *QueryBuilder[OWNER] {
out := *new(OWNER)
outSchema := getSchemaFor(out)
q := NewQueryBuilder[OWNER](outSchema)
c, ok := getSchemaFor(property).relations[outSchema.Table].(BelongsToManyConfig)
if !ok {
q.err = fmt.Errorf("wrong config passed for HasMany")
}
return q.
Select(outSchema.Columns(true)...).
Table(outSchema.Table).
WhereIn(c.OwnerLookupColumn, Raw(fmt.Sprintf(`SELECT %s FROM %s WHERE %s = ?`,
c.IntermediatePropertyID,
c.IntermediateTable, c.IntermediateOwnerID), genericGetPKValue(property)))
}
// Add adds `items` to `to` using relations defined between items and to in ConfigureEntity method of `to`.
func Add(to Entity, items ...Entity) error {
if len(items) == 0 {
return nil
}
rels := getSchemaFor(to).relations
tname := getSchemaFor(items[0]).Table
c, ok := rels[tname]
if !ok {
return fmt.Errorf("no config found for given to and item...")
}
switch c.(type) {
case HasManyConfig:
return addProperty(to, items...)
case HasOneConfig:
return addProperty(to, items[0])
case BelongsToManyConfig:
return addM2M(to, items...)
default:
return fmt.Errorf("cannot add for relation: %T", rels[getSchemaFor(items[0]).Table])
}
}
func addM2M(to Entity, items ...Entity) error {
//TODO: Optimize this
rels := getSchemaFor(to).relations
tname := getSchemaFor(items[0]).Table
c := rels[tname].(BelongsToManyConfig)
var values [][]interface{}
ownerPk := genericGetPKValue(to)
for _, item := range items {
pk := genericGetPKValue(item)
if isZero(pk) {
err := Insert(item)
if err != nil {
return err
}
pk = genericGetPKValue(item)
}
values = append(values, []interface{}{ownerPk, pk})
}
i := insertStmt{
PlaceHolderGenerator: getSchemaFor(to).getDialect().PlaceHolderGenerator,
Table: c.IntermediateTable,
Columns: []string{c.IntermediateOwnerID, c.IntermediatePropertyID},
Values: values,
}
q, args := i.ToSql()
_, err := getConnectionFor(items[0]).DB.Exec(q, args...)
if err != nil {
return err
}
return err
}
// addHasMany(Post, comments)
func addProperty(to Entity, items ...Entity) error {
var lastTable string
for _, obj := range items {
s := getSchemaFor(obj)
if lastTable == "" {
lastTable = s.Table
} else {
if lastTable != s.Table {
return fmt.Errorf("cannot batch insert for two different tables: %s and %s", s.Table, lastTable)
}
}
}
i := insertStmt{
PlaceHolderGenerator: getSchemaFor(to).getDialect().PlaceHolderGenerator,
Table: getSchemaFor(items[0]).getTable(),
}
ownerPKIdx := -1
ownerPKName := getSchemaFor(items[0]).relations[getSchemaFor(to).Table].(BelongsToConfig).LocalForeignKey
for idx, col := range getSchemaFor(items[0]).Columns(false) {
if col == ownerPKName {
ownerPKIdx = idx
}
}
ownerPK := genericGetPKValue(to)
if ownerPKIdx != -1 {
cols := getSchemaFor(items[0]).Columns(false)
i.Columns = append(i.Columns, cols...)
// Owner PK is present in the items struct
for _, item := range items {
vals := genericValuesOf(item, false)
if cols[ownerPKIdx] != getSchemaFor(items[0]).relations[getSchemaFor(to).Table].(BelongsToConfig).LocalForeignKey {
return fmt.Errorf("owner pk idx is not correct")
}
vals[ownerPKIdx] = ownerPK
i.Values = append(i.Values, vals)
}
} else {
ownerPKIdx = 0
cols := getSchemaFor(items[0]).Columns(false)
cols = append(cols[:ownerPKIdx+1], cols[ownerPKIdx:]...)
cols[ownerPKIdx] = getSchemaFor(items[0]).relations[getSchemaFor(to).Table].(BelongsToConfig).LocalForeignKey
i.Columns = append(i.Columns, cols...)
for _, item := range items {
vals := genericValuesOf(item, false)
if cols[ownerPKIdx] != getSchemaFor(items[0]).relations[getSchemaFor(to).Table].(BelongsToConfig).LocalForeignKey {
return fmt.Errorf("owner pk idx is not correct")
}
vals = append(vals[:ownerPKIdx+1], vals[ownerPKIdx:]...)
vals[ownerPKIdx] = ownerPK
i.Values = append(i.Values, vals)
}
}
q, args := i.ToSql()
_, err := getConnectionFor(items[0]).DB.Exec(q, args...)
if err != nil {
return err
}
return err
}
// Query creates a new QueryBuilder for given type parameter, sets dialect and table as well.
func Query[E Entity]() *QueryBuilder[E] {
s := getSchemaFor(*new(E))
q := NewQueryBuilder[E](s)
q.SetDialect(s.getDialect()).Table(s.Table)
return q
}
// ExecRaw executes given query string and arguments on given type parameter database connection.
func ExecRaw[E Entity](q string, args ...interface{}) (int64, int64, error) {
e := new(E)
res, err := getSchemaFor(*e).getSQLDB().Exec(q, args...)
if err != nil {
return 0, 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, 0, err
}
affected, err := res.RowsAffected()
if err != nil {
return 0, 0, err
}
return id, affected, nil
}
// QueryRaw queries given query string and arguments on given type parameter database connection.
func QueryRaw[OUTPUT Entity](q string, args ...interface{}) ([]OUTPUT, error) {
o := new(OUTPUT)
rows, err := getSchemaFor(*o).getSQLDB().Query(q, args...)
if err != nil {
return nil, err
}
var output []OUTPUT
err = newBinder(getSchemaFor(*o)).bind(rows, &output)
if err != nil {
return nil, err
}
return output, nil
}

588
gdb/orm/orm_test.go Normal file
View File

@ -0,0 +1,588 @@
//
// orm_test.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm_test
import (
"database/sql"
"testing"
"git.hexq.cn/tiglog/golib/gdb/orm"
"github.com/stretchr/testify/assert"
)
type AuthorEmail struct {
ID int64
Email string
}
func (a AuthorEmail) ConfigureEntity(e *orm.EntityConfigurator) {
e.
Table("emails").
Connection("default").
BelongsTo(&Post{}, orm.BelongsToConfig{})
}
type HeaderPicture struct {
ID int64
PostID int64
Link string
}
func (h HeaderPicture) ConfigureEntity(e *orm.EntityConfigurator) {
e.Table("header_pictures").BelongsTo(&Post{}, orm.BelongsToConfig{})
}
type Post struct {
ID int64
BodyText string
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
DeletedAt sql.NullTime
}
func (p Post) ConfigureEntity(e *orm.EntityConfigurator) {
e.Field("BodyText").ColumnName("body")
e.Field("ID").ColumnName("id")
e.
Table("posts").
HasMany(Comment{}, orm.HasManyConfig{}).
HasOne(HeaderPicture{}, orm.HasOneConfig{}).
HasOne(AuthorEmail{}, orm.HasOneConfig{}).
BelongsToMany(Category{}, orm.BelongsToManyConfig{IntermediateTable: "post_categories"})
}
func (p *Post) Categories() ([]Category, error) {
return orm.BelongsToMany[Category](p).All()
}
func (p *Post) Comments() *orm.QueryBuilder[Comment] {
return orm.HasMany[Comment](p)
}
type Comment struct {
ID int64
PostID int64
Body string
}
func (c Comment) ConfigureEntity(e *orm.EntityConfigurator) {
e.Table("comments").BelongsTo(&Post{}, orm.BelongsToConfig{})
}
func (c *Comment) Post() (Post, error) {
return orm.BelongsTo[Post](c).Get()
}
type Category struct {
ID int64
Title string
}
func (c Category) ConfigureEntity(e *orm.EntityConfigurator) {
e.Table("categories").BelongsToMany(Post{}, orm.BelongsToManyConfig{IntermediateTable: "post_categories"})
}
func (c Category) Posts() ([]Post, error) {
return orm.BelongsToMany[Post](c).All()
}
// enough models let's test
// Entities is mandatory
// Errors should be carried
func setup() error {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
return err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, body text, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP)`)
if err != nil {
return err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS emails (id INTEGER PRIMARY KEY, post_id INTEGER, email text)`)
if err != nil {
return err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS header_pictures (id INTEGER PRIMARY KEY, post_id INTEGER, link text)`)
if err != nil {
return err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, post_id INTEGER, body text)`)
if err != nil {
return err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS categories (id INTEGER PRIMARY KEY, title text)`)
if err != nil {
return err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS post_categories (post_id INTEGER, category_id INTEGER, PRIMARY KEY(post_id, category_id))`)
if err != nil {
return err
}
return orm.SetupConnections(orm.ConnectionConfig{
Name: "default",
DB: db,
Dialect: orm.Dialects.SQLite3,
Entities: []orm.Entity{&Post{}, &Comment{}, &Category{}, &HeaderPicture{}},
DatabaseValidations: true,
})
}
func TestFind(t *testing.T) {
err := setup()
assert.NoError(t, err)
err = orm.InsertAll(&Post{
BodyText: "my body for insert",
})
assert.NoError(t, err)
post, err := orm.Find[Post](1)
assert.NoError(t, err)
assert.Equal(t, "my body for insert", post.BodyText)
assert.Equal(t, int64(1), post.ID)
}
func TestInsert(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "my body for insert",
}
err = orm.Insert(post)
assert.NoError(t, err)
assert.Equal(t, int64(1), post.ID)
var p Post
assert.NoError(t,
orm.GetConnection("default").DB.QueryRow(`SELECT id, body FROM posts where id = ?`, 1).Scan(&p.ID, &p.BodyText))
assert.Equal(t, "my body for insert", p.BodyText)
}
func TestInsertAll(t *testing.T) {
err := setup()
assert.NoError(t, err)
post1 := &Post{
BodyText: "Body1",
}
post2 := &Post{
BodyText: "Body2",
}
post3 := &Post{
BodyText: "Body3",
}
err = orm.InsertAll(post1, post2, post3)
assert.NoError(t, err)
var counter int
assert.NoError(t, orm.GetConnection("default").DB.QueryRow(`SELECT count(id) FROM posts`).Scan(&counter))
assert.Equal(t, 3, counter)
}
func TestUpdateORM(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "my body for insert",
}
err = orm.Insert(post)
assert.NoError(t, err)
assert.Equal(t, int64(1), post.ID)
post.BodyText += " update text"
assert.NoError(t, orm.Update(post))
var body string
assert.NoError(t,
orm.GetConnection("default").DB.QueryRow(`SELECT body FROM posts where id = ?`, post.ID).Scan(&body))
assert.Equal(t, "my body for insert update text", body)
}
func TestDeleteORM(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "my body for insert",
}
err = orm.Insert(post)
assert.NoError(t, err)
assert.Equal(t, int64(1), post.ID)
assert.NoError(t, orm.Delete(post))
var count int
assert.NoError(t,
orm.GetConnection("default").DB.QueryRow(`SELECT count(id) FROM posts where id = ?`, post.ID).Scan(&count))
assert.Equal(t, 0, count)
}
func TestAdd_HasMany(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "my body for insert",
}
err = orm.Insert(post)
assert.NoError(t, err)
assert.Equal(t, int64(1), post.ID)
err = orm.Add(post, []orm.Entity{
Comment{
Body: "comment 1",
},
Comment{
Body: "comment 2",
},
}...)
// orm.Query(qm.WhereBetween())
assert.NoError(t, err)
var count int
assert.NoError(t, orm.GetConnection("default").DB.QueryRow(`SELECT COUNT(id) FROM comments`).Scan(&count))
assert.Equal(t, 2, count)
comment, err := orm.Find[Comment](1)
assert.NoError(t, err)
assert.Equal(t, int64(1), comment.PostID)
}
func TestAdd_ManyToMany(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "my body for insert",
}
err = orm.Insert(post)
assert.NoError(t, err)
assert.Equal(t, int64(1), post.ID)
err = orm.Add(post, []orm.Entity{
&Category{
Title: "cat 1",
},
&Category{
Title: "cat 2",
},
}...)
assert.NoError(t, err)
var count int
assert.NoError(t, orm.GetConnection("default").DB.QueryRow(`SELECT COUNT(post_id) FROM post_categories`).Scan(&count))
assert.Equal(t, 2, count)
assert.NoError(t, orm.GetConnection("default").DB.QueryRow(`SELECT COUNT(id) FROM categories`).Scan(&count))
assert.Equal(t, 2, count)
categories, err := post.Categories()
assert.NoError(t, err)
assert.Equal(t, 2, len(categories))
assert.Equal(t, int64(1), categories[0].ID)
assert.Equal(t, int64(2), categories[1].ID)
}
func TestSave(t *testing.T) {
t.Run("save should insert", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "1",
}
assert.NoError(t, orm.Save(post))
assert.Equal(t, int64(1), post.ID)
})
t.Run("save should update", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "1",
}
assert.NoError(t, orm.Save(post))
assert.Equal(t, int64(1), post.ID)
post.BodyText += "2"
assert.NoError(t, orm.Save(post))
myPost, err := orm.Find[Post](1)
assert.NoError(t, err)
assert.EqualValues(t, post.BodyText, myPost.BodyText)
})
}
func TestHasMany(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "first post",
}
assert.NoError(t, orm.Save(post))
assert.Equal(t, int64(1), post.ID)
assert.NoError(t, orm.Save(&Comment{
PostID: post.ID,
Body: "comment 1",
}))
assert.NoError(t, orm.Save(&Comment{
PostID: post.ID,
Body: "comment 2",
}))
comments, err := orm.HasMany[Comment](post).All()
assert.NoError(t, err)
assert.Len(t, comments, 2)
assert.Equal(t, post.ID, comments[0].PostID)
assert.Equal(t, post.ID, comments[1].PostID)
}
func TestBelongsTo(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "first post",
}
assert.NoError(t, orm.Save(post))
assert.Equal(t, int64(1), post.ID)
comment := &Comment{
PostID: post.ID,
Body: "comment 1",
}
assert.NoError(t, orm.Save(comment))
post2, err := orm.BelongsTo[Post](comment).Get()
assert.NoError(t, err)
assert.Equal(t, post.BodyText, post2.BodyText)
}
func TestHasOne(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "first post",
}
assert.NoError(t, orm.Save(post))
assert.Equal(t, int64(1), post.ID)
headerPicture := &HeaderPicture{
PostID: post.ID,
Link: "google",
}
assert.NoError(t, orm.Save(headerPicture))
c1, err := orm.HasOne[HeaderPicture](post).Get()
assert.NoError(t, err)
assert.Equal(t, headerPicture.PostID, c1.PostID)
}
func TestBelongsToMany(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "first Post",
}
assert.NoError(t, orm.Save(post))
assert.Equal(t, int64(1), post.ID)
category := &Category{
Title: "first category",
}
assert.NoError(t, orm.Save(category))
assert.Equal(t, int64(1), category.ID)
_, _, err = orm.ExecRaw[Category](`INSERT INTO post_categories (post_id, category_id) VALUES (?,?)`, post.ID, category.ID)
assert.NoError(t, err)
categories, err := orm.BelongsToMany[Category](post).All()
assert.NoError(t, err)
assert.Len(t, categories, 1)
}
func TestSchematic(t *testing.T) {
err := setup()
assert.NoError(t, err)
orm.Schematic()
}
func TestAddProperty(t *testing.T) {
t.Run("having pk value", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "first post",
}
assert.NoError(t, orm.Save(post))
assert.EqualValues(t, 1, post.ID)
err = orm.Add(post, &Comment{PostID: post.ID, Body: "firstComment"})
assert.NoError(t, err)
var comment Comment
assert.NoError(t, orm.GetConnection("default").
DB.
QueryRow(`SELECT id, post_id, body FROM comments WHERE post_id=?`, post.ID).
Scan(&comment.ID, &comment.PostID, &comment.Body))
assert.EqualValues(t, post.ID, comment.PostID)
})
t.Run("not having PK value", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
post := &Post{
BodyText: "first post",
}
assert.NoError(t, orm.Save(post))
assert.EqualValues(t, 1, post.ID)
err = orm.Add(post, &AuthorEmail{Email: "myemail"})
assert.NoError(t, err)
emails, err := orm.QueryRaw[AuthorEmail](`SELECT id, email FROM emails WHERE post_id=?`, post.ID)
assert.NoError(t, err)
assert.Equal(t, []AuthorEmail{{ID: 1, Email: "myemail"}}, emails)
})
}
func TestQuery(t *testing.T) {
t.Run("querying single row", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
assert.NoError(t, orm.Save(&Post{BodyText: "body 1"}))
// post, err := orm.Query[Post]().Where("id", 1).First()
post, err := orm.Query[Post]().WherePK(1).First().Get()
assert.NoError(t, err)
assert.EqualValues(t, "body 1", post.BodyText)
assert.EqualValues(t, 1, post.ID)
})
t.Run("querying multiple rows", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
assert.NoError(t, orm.Save(&Post{BodyText: "body 1"}))
assert.NoError(t, orm.Save(&Post{BodyText: "body 2"}))
assert.NoError(t, orm.Save(&Post{BodyText: "body 3"}))
posts, err := orm.Query[Post]().All()
assert.NoError(t, err)
assert.Len(t, posts, 3)
assert.Equal(t, "body 1", posts[0].BodyText)
})
t.Run("updating a row using query interface", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
assert.NoError(t, orm.Save(&Post{BodyText: "body 1"}))
affected, err := orm.Query[Post]().Where("id", 1).Set("body", "body jadid").Update()
assert.NoError(t, err)
assert.EqualValues(t, 1, affected)
post, err := orm.Find[Post](1)
assert.NoError(t, err)
assert.Equal(t, "body jadid", post.BodyText)
})
t.Run("deleting a row using query interface", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
assert.NoError(t, orm.Save(&Post{BodyText: "body 1"}))
affected, err := orm.Query[Post]().WherePK(1).Delete()
assert.NoError(t, err)
assert.EqualValues(t, 1, affected)
count, err := orm.Query[Post]().WherePK(1).Count().Get()
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
})
t.Run("count", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
count, err := orm.Query[Post]().WherePK(1).Count().Get()
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
})
t.Run("latest", func(t *testing.T) {
err := setup()
assert.NoError(t, err)
assert.NoError(t, orm.Save(&Post{BodyText: "body 1"}))
assert.NoError(t, orm.Save(&Post{BodyText: "body 2"}))
post, err := orm.Query[Post]().Latest().Get()
assert.NoError(t, err)
assert.EqualValues(t, "body 2", post.BodyText)
})
}
func TestSetup(t *testing.T) {
t.Run("tables are out of sync", func(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
// _, err = db.Exec(`CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, body text, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP)`)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS emails (id INTEGER PRIMARY KEY, post_id INTEGER, email text)`)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS header_pictures (id INTEGER PRIMARY KEY, post_id INTEGER, link text)`)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, post_id INTEGER, body text)`)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS categories (id INTEGER PRIMARY KEY, title text)`)
// _, err = db.Exec(`CREATE TABLE IF NOT EXISTS post_categories (post_id INTEGER, category_id INTEGER, PRIMARY KEY(post_id, category_id))`)
err = orm.SetupConnections(orm.ConnectionConfig{
Name: "default",
DB: db,
Dialect: orm.Dialects.SQLite3,
Entities: []orm.Entity{&Post{}, &Comment{}, &Category{}, &HeaderPicture{}},
DatabaseValidations: true,
})
assert.Error(t, err)
})
t.Run("schemas are wrong", func(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, body text, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP)`)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS emails (id INTEGER PRIMARY KEY, post_id INTEGER, email text)`)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS header_pictures (id INTEGER PRIMARY KEY, post_id INTEGER, link text)`)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, body text)`) // missing post_id
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS categories (id INTEGER PRIMARY KEY, title text)`)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS post_categories (post_id INTEGER, category_id INTEGER, PRIMARY KEY(post_id, category_id))`)
err = orm.SetupConnections(orm.ConnectionConfig{
Name: "default",
DB: db,
Dialect: orm.Dialects.SQLite3,
Entities: []orm.Entity{&Post{}, &Comment{}, &Category{}, &HeaderPicture{}},
DatabaseValidations: true,
})
assert.Error(t, err)
})
}

811
gdb/orm/query.go Normal file
View File

@ -0,0 +1,811 @@
//
// query.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
"fmt"
"strings"
)
const (
queryTypeSELECT = iota + 1
queryTypeUPDATE
queryTypeDelete
)
// QueryBuilder is our query builder, almost all methods and functions in GoLobby ORM
// create or configure instance of QueryBuilder.
type QueryBuilder[OUTPUT any] struct {
typ int
schema *schema
// general parts
where *whereClause
table string
placeholderGenerator func(n int) []string
// select parts
orderBy *orderByClause
groupBy *GroupBy
selected *selected
subQuery *struct {
q string
args []interface{}
placeholderGenerator func(n int) []string
}
joins []*Join
limit *Limit
offset *Offset
// update parts
sets [][2]interface{}
// execution parts
db *sql.DB
err error
}
// Finisher APIs
// execute is a finisher executes QueryBuilder query, remember to use this when you have an Update
// or Delete Query.
func (q *QueryBuilder[OUTPUT]) execute() (sql.Result, error) {
if q.err != nil {
return nil, q.err
}
if q.typ == queryTypeSELECT {
return nil, fmt.Errorf("query type is SELECT")
}
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
return q.schema.getConnection().exec(query, args...)
}
// Get limit results to 1, runs query generated by query builder, scans result into OUTPUT.
func (q *QueryBuilder[OUTPUT]) Get() (OUTPUT, error) {
if q.err != nil {
return *new(OUTPUT), q.err
}
queryString, args, err := q.ToSql()
if err != nil {
return *new(OUTPUT), err
}
rows, err := q.schema.getConnection().query(queryString, args...)
if err != nil {
return *new(OUTPUT), err
}
var output OUTPUT
err = newBinder(q.schema).bind(rows, &output)
if err != nil {
return *new(OUTPUT), err
}
return output, nil
}
// All is a finisher, create the Select query based on QueryBuilder and scan results into
// slice of type parameter E.
func (q *QueryBuilder[OUTPUT]) All() ([]OUTPUT, error) {
if q.err != nil {
return nil, q.err
}
q.SetSelect()
queryString, args, err := q.ToSql()
if err != nil {
return nil, err
}
rows, err := q.schema.getConnection().query(queryString, args...)
if err != nil {
return nil, err
}
var output []OUTPUT
err = newBinder(q.schema).bind(rows, &output)
if err != nil {
return nil, err
}
return output, nil
}
// Delete is a finisher, creates a delete query from query builder and executes it.
func (q *QueryBuilder[OUTPUT]) Delete() (rowsAffected int64, err error) {
if q.err != nil {
return 0, q.err
}
q.SetDelete()
res, err := q.execute()
if err != nil {
return 0, q.err
}
return res.RowsAffected()
}
// Update is a finisher, creates an Update query from QueryBuilder and executes in into database, returns
func (q *QueryBuilder[OUTPUT]) Update() (rowsAffected int64, err error) {
if q.err != nil {
return 0, q.err
}
q.SetUpdate()
res, err := q.execute()
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func copyQueryBuilder[T1 any, T2 any](q *QueryBuilder[T1], q2 *QueryBuilder[T2]) {
q2.db = q.db
q2.err = q.err
q2.groupBy = q.groupBy
q2.joins = q.joins
q2.limit = q.limit
q2.offset = q.offset
q2.orderBy = q.orderBy
q2.placeholderGenerator = q.placeholderGenerator
q2.schema = q.schema
q2.selected = q.selected
q2.sets = q.sets
q2.subQuery = q.subQuery
q2.table = q.table
q2.typ = q.typ
q2.where = q.where
}
// Count creates and execute a select query from QueryBuilder and set it's field list of selection
// to COUNT(id).
func (q *QueryBuilder[OUTPUT]) Count() *QueryBuilder[int] {
q.selected = &selected{Columns: []string{"COUNT(id)"}}
q.SetSelect()
qCount := NewQueryBuilder[int](q.schema)
copyQueryBuilder(q, qCount)
return qCount
}
// First returns first record of database using OrderBy primary key
// ascending order.
func (q *QueryBuilder[OUTPUT]) First() *QueryBuilder[OUTPUT] {
q.OrderBy(q.schema.pkName(), ASC).Limit(1)
return q
}
// Latest is like Get but it also do a OrderBy(primary key, DESC)
func (q *QueryBuilder[OUTPUT]) Latest() *QueryBuilder[OUTPUT] {
q.OrderBy(q.schema.pkName(), DESC).Limit(1)
return q
}
// WherePK adds a where clause to QueryBuilder and also gets primary key name
// from type parameter schema.
func (q *QueryBuilder[OUTPUT]) WherePK(value interface{}) *QueryBuilder[OUTPUT] {
return q.Where(q.schema.pkName(), value)
}
func (d *QueryBuilder[OUTPUT]) toSqlDelete() (string, []interface{}, error) {
base := fmt.Sprintf("DELETE FROM %s", d.table)
var args []interface{}
if d.where != nil {
d.where.PlaceHolderGenerator = d.placeholderGenerator
where, whereArgs, err := d.where.ToSql()
if err != nil {
return "", nil, err
}
base += " WHERE " + where
args = append(args, whereArgs...)
}
return base, args, nil
}
func pop(phs *[]string) string {
top := (*phs)[len(*phs)-1]
*phs = (*phs)[:len(*phs)-1]
return top
}
func (u *QueryBuilder[OUTPUT]) kvString() string {
phs := u.placeholderGenerator(len(u.sets))
var sets []string
for _, pair := range u.sets {
sets = append(sets, fmt.Sprintf("%s=%s", pair[0], pop(&phs)))
}
return strings.Join(sets, ",")
}
func (u *QueryBuilder[OUTPUT]) args() []interface{} {
var values []interface{}
for _, pair := range u.sets {
values = append(values, pair[1])
}
return values
}
func (u *QueryBuilder[OUTPUT]) toSqlUpdate() (string, []interface{}, error) {
if u.table == "" {
return "", nil, fmt.Errorf("table cannot be empty")
}
base := fmt.Sprintf("UPDATE %s SET %s", u.table, u.kvString())
args := u.args()
if u.where != nil {
u.where.PlaceHolderGenerator = u.placeholderGenerator
where, whereArgs, err := u.where.ToSql()
if err != nil {
return "", nil, err
}
args = append(args, whereArgs...)
base += " WHERE " + where
}
return base, args, nil
}
func (s *QueryBuilder[OUTPUT]) toSqlSelect() (string, []interface{}, error) {
if s.err != nil {
return "", nil, s.err
}
base := "SELECT"
var args []interface{}
// select
if s.selected == nil {
s.selected = &selected{
Columns: []string{"*"},
}
}
base += " " + s.selected.String()
// from
if s.table == "" && s.subQuery == nil {
return "", nil, fmt.Errorf("Table name cannot be empty")
} else if s.table != "" && s.subQuery != nil {
return "", nil, fmt.Errorf("cannot have both Table and subquery")
}
if s.table != "" {
base += " " + "FROM " + s.table
}
if s.subQuery != nil {
s.subQuery.placeholderGenerator = s.placeholderGenerator
base += " " + "FROM (" + s.subQuery.q + " )"
args = append(args, s.subQuery.args...)
}
// Joins
if s.joins != nil {
for _, join := range s.joins {
base += " " + join.String()
}
}
// whereClause
if s.where != nil {
s.where.PlaceHolderGenerator = s.placeholderGenerator
where, whereArgs, err := s.where.ToSql()
if err != nil {
return "", nil, err
}
base += " WHERE " + where
args = append(args, whereArgs...)
}
// orderByClause
if s.orderBy != nil {
base += " " + s.orderBy.String()
}
// GroupBy
if s.groupBy != nil {
base += " " + s.groupBy.String()
}
// Limit
if s.limit != nil {
base += " " + s.limit.String()
}
// Offset
if s.offset != nil {
base += " " + s.offset.String()
}
return base, args, nil
}
// ToSql creates sql query from QueryBuilder based on internal fields it would decide what kind
// of query to build.
func (q *QueryBuilder[OUTPUT]) ToSql() (string, []interface{}, error) {
if q.err != nil {
return "", nil, q.err
}
if q.typ == queryTypeSELECT {
return q.toSqlSelect()
} else if q.typ == queryTypeDelete {
return q.toSqlDelete()
} else if q.typ == queryTypeUPDATE {
return q.toSqlUpdate()
} else {
return "", nil, fmt.Errorf("no sql type matched")
}
}
type orderByOrder string
const (
ASC = "ASC"
DESC = "DESC"
)
type orderByClause struct {
Columns [][2]string
}
func (o orderByClause) String() string {
var tuples []string
for _, pair := range o.Columns {
tuples = append(tuples, fmt.Sprintf("%s %s", pair[0], pair[1]))
}
return fmt.Sprintf("ORDER BY %s", strings.Join(tuples, ","))
}
type GroupBy struct {
Columns []string
}
func (g GroupBy) String() string {
return fmt.Sprintf("GROUP BY %s", strings.Join(g.Columns, ","))
}
type joinType string
const (
JoinTypeInner = "INNER"
JoinTypeLeft = "LEFT"
JoinTypeRight = "RIGHT"
JoinTypeFull = "FULL OUTER"
JoinTypeSelf = "SELF"
)
type JoinOn struct {
Lhs string
Rhs string
}
func (j JoinOn) String() string {
return fmt.Sprintf("%s = %s", j.Lhs, j.Rhs)
}
type Join struct {
Type joinType
Table string
On JoinOn
}
func (j Join) String() string {
return fmt.Sprintf("%s JOIN %s ON %s", j.Type, j.Table, j.On.String())
}
type Limit struct {
N int
}
func (l Limit) String() string {
return fmt.Sprintf("LIMIT %d", l.N)
}
type Offset struct {
N int
}
func (o Offset) String() string {
return fmt.Sprintf("OFFSET %d", o.N)
}
type selected struct {
Columns []string
}
func (s selected) String() string {
return fmt.Sprintf("%s", strings.Join(s.Columns, ","))
}
// OrderBy adds an OrderBy section to QueryBuilder.
func (q *QueryBuilder[OUTPUT]) OrderBy(column string, how string) *QueryBuilder[OUTPUT] {
q.SetSelect()
if q.orderBy == nil {
q.orderBy = &orderByClause{}
}
q.orderBy.Columns = append(q.orderBy.Columns, [2]string{column, how})
return q
}
// LeftJoin adds a left join section to QueryBuilder.
func (q *QueryBuilder[OUTPUT]) LeftJoin(table string, onLhs string, onRhs string) *QueryBuilder[OUTPUT] {
q.SetSelect()
q.joins = append(q.joins, &Join{
Type: JoinTypeLeft,
Table: table,
On: JoinOn{
Lhs: onLhs,
Rhs: onRhs,
},
})
return q
}
// RightJoin adds a right join section to QueryBuilder.
func (q *QueryBuilder[OUTPUT]) RightJoin(table string, onLhs string, onRhs string) *QueryBuilder[OUTPUT] {
q.SetSelect()
q.joins = append(q.joins, &Join{
Type: JoinTypeRight,
Table: table,
On: JoinOn{
Lhs: onLhs,
Rhs: onRhs,
},
})
return q
}
// InnerJoin adds a inner join section to QueryBuilder.
func (q *QueryBuilder[OUTPUT]) InnerJoin(table string, onLhs string, onRhs string) *QueryBuilder[OUTPUT] {
q.SetSelect()
q.joins = append(q.joins, &Join{
Type: JoinTypeInner,
Table: table,
On: JoinOn{
Lhs: onLhs,
Rhs: onRhs,
},
})
return q
}
// Join adds a inner join section to QueryBuilder.
func (q *QueryBuilder[OUTPUT]) Join(table string, onLhs string, onRhs string) *QueryBuilder[OUTPUT] {
return q.InnerJoin(table, onLhs, onRhs)
}
// FullOuterJoin adds a full outer join section to QueryBuilder.
func (q *QueryBuilder[OUTPUT]) FullOuterJoin(table string, onLhs string, onRhs string) *QueryBuilder[OUTPUT] {
q.SetSelect()
q.joins = append(q.joins, &Join{
Type: JoinTypeFull,
Table: table,
On: JoinOn{
Lhs: onLhs,
Rhs: onRhs,
},
})
return q
}
// Where Adds a where clause to query, if already have where clause append to it
// as AndWhere.
func (q *QueryBuilder[OUTPUT]) Where(parts ...interface{}) *QueryBuilder[OUTPUT] {
if q.where != nil {
return q.addWhere("AND", parts...)
}
if len(parts) == 1 {
if r, isRaw := parts[0].(*raw); isRaw {
q.where = &whereClause{raw: r.sql, args: r.args, PlaceHolderGenerator: q.placeholderGenerator}
return q
} else {
q.err = fmt.Errorf("when you have one argument passed to where, it should be *raw")
return q
}
} else if len(parts) == 2 {
if strings.Index(parts[0].(string), " ") == -1 {
// Equal mode
q.where = &whereClause{cond: cond{Lhs: parts[0].(string), Op: Eq, Rhs: parts[1]}, PlaceHolderGenerator: q.placeholderGenerator}
}
return q
} else if len(parts) == 3 {
// operator mode
q.where = &whereClause{cond: cond{Lhs: parts[0].(string), Op: binaryOp(parts[1].(string)), Rhs: parts[2]}, PlaceHolderGenerator: q.placeholderGenerator}
return q
} else if len(parts) > 3 && parts[1].(string) == "IN" {
q.where = &whereClause{cond: cond{Lhs: parts[0].(string), Op: binaryOp(parts[1].(string)), Rhs: parts[2:]}, PlaceHolderGenerator: q.placeholderGenerator}
return q
} else {
q.err = fmt.Errorf("wrong number of arguments passed to Where")
return q
}
}
type binaryOp string
const (
Eq = "="
GT = ">"
LT = "<"
GE = ">="
LE = "<="
NE = "!="
Between = "BETWEEN"
Like = "LIKE"
In = "IN"
)
type cond struct {
PlaceHolderGenerator func(n int) []string
Lhs string
Op binaryOp
Rhs interface{}
}
func (b cond) ToSql() (string, []interface{}, error) {
var phs []string
if b.Op == In {
rhs, isInterfaceSlice := b.Rhs.([]interface{})
if isInterfaceSlice {
phs = b.PlaceHolderGenerator(len(rhs))
return fmt.Sprintf("%s IN (%s)", b.Lhs, strings.Join(phs, ",")), rhs, nil
} else if rawThing, isRaw := b.Rhs.(*raw); isRaw {
return fmt.Sprintf("%s IN (%s)", b.Lhs, rawThing.sql), rawThing.args, nil
} else {
return "", nil, fmt.Errorf("Right hand side of Cond when operator is IN should be either a interface{} slice or *raw")
}
} else {
phs = b.PlaceHolderGenerator(1)
return fmt.Sprintf("%s %s %s", b.Lhs, b.Op, pop(&phs)), []interface{}{b.Rhs}, nil
}
}
const (
nextType_AND = "AND"
nextType_OR = "OR"
)
type whereClause struct {
PlaceHolderGenerator func(n int) []string
nextTyp string
next *whereClause
cond
raw string
args []interface{}
}
func (w whereClause) ToSql() (string, []interface{}, error) {
var base string
var args []interface{}
var err error
if w.raw != "" {
base = w.raw
args = w.args
} else {
w.cond.PlaceHolderGenerator = w.PlaceHolderGenerator
base, args, err = w.cond.ToSql()
if err != nil {
return "", nil, err
}
}
if w.next == nil {
return base, args, nil
}
if w.next != nil {
next, nextArgs, err := w.next.ToSql()
if err != nil {
return "", nil, err
}
base += " " + w.nextTyp + " " + next
args = append(args, nextArgs...)
return base, args, nil
}
return base, args, nil
}
//func (q *QueryBuilder[OUTPUT]) WhereKeyValue(m map) {}
// WhereIn adds a where clause to QueryBuilder using In operator.
func (q *QueryBuilder[OUTPUT]) WhereIn(column string, values ...interface{}) *QueryBuilder[OUTPUT] {
return q.Where(append([]interface{}{column, In}, values...)...)
}
// AndWhere appends a where clause to query builder as And where clause.
func (q *QueryBuilder[OUTPUT]) AndWhere(parts ...interface{}) *QueryBuilder[OUTPUT] {
return q.addWhere(nextType_AND, parts...)
}
// OrWhere appends a where clause to query builder as Or where clause.
func (q *QueryBuilder[OUTPUT]) OrWhere(parts ...interface{}) *QueryBuilder[OUTPUT] {
return q.addWhere(nextType_OR, parts...)
}
func (q *QueryBuilder[OUTPUT]) addWhere(typ string, parts ...interface{}) *QueryBuilder[OUTPUT] {
w := q.where
for {
if w == nil {
break
} else if w.next == nil {
w.next = &whereClause{PlaceHolderGenerator: q.placeholderGenerator}
w.nextTyp = typ
w = w.next
break
} else {
w = w.next
}
}
if w == nil {
w = &whereClause{PlaceHolderGenerator: q.placeholderGenerator}
}
if len(parts) == 1 {
w.raw = parts[0].(*raw).sql
w.args = parts[0].(*raw).args
return q
} else if len(parts) == 2 {
// Equal mode
w.cond = cond{Lhs: parts[0].(string), Op: Eq, Rhs: parts[1]}
return q
} else if len(parts) == 3 {
// operator mode
w.cond = cond{Lhs: parts[0].(string), Op: binaryOp(parts[1].(string)), Rhs: parts[2]}
return q
} else {
panic("wrong number of arguments passed to Where")
}
}
// Offset adds offset section to query builder.
func (q *QueryBuilder[OUTPUT]) Offset(n int) *QueryBuilder[OUTPUT] {
q.SetSelect()
q.offset = &Offset{N: n}
return q
}
// Limit adds limit section to query builder.
func (q *QueryBuilder[OUTPUT]) Limit(n int) *QueryBuilder[OUTPUT] {
q.SetSelect()
q.limit = &Limit{N: n}
return q
}
// Table sets table of QueryBuilder.
func (q *QueryBuilder[OUTPUT]) Table(t string) *QueryBuilder[OUTPUT] {
q.table = t
return q
}
// SetSelect sets query type of QueryBuilder to Select.
func (q *QueryBuilder[OUTPUT]) SetSelect() *QueryBuilder[OUTPUT] {
q.typ = queryTypeSELECT
return q
}
// GroupBy adds a group by section to QueryBuilder.
func (q *QueryBuilder[OUTPUT]) GroupBy(columns ...string) *QueryBuilder[OUTPUT] {
q.SetSelect()
if q.groupBy == nil {
q.groupBy = &GroupBy{}
}
q.groupBy.Columns = append(q.groupBy.Columns, columns...)
return q
}
// Select adds columns to QueryBuilder select field list.
func (q *QueryBuilder[OUTPUT]) Select(columns ...string) *QueryBuilder[OUTPUT] {
q.SetSelect()
if q.selected == nil {
q.selected = &selected{}
}
q.selected.Columns = append(q.selected.Columns, columns...)
return q
}
// FromQuery sets subquery of QueryBuilder to be given subquery so
// when doing select instead of from table we do from(subquery).
func (q *QueryBuilder[OUTPUT]) FromQuery(subQuery *QueryBuilder[OUTPUT]) *QueryBuilder[OUTPUT] {
q.SetSelect()
subQuery.SetSelect()
subQuery.placeholderGenerator = q.placeholderGenerator
subQueryString, args, err := subQuery.ToSql()
q.err = err
q.subQuery = &struct {
q string
args []interface{}
placeholderGenerator func(n int) []string
}{
subQueryString, args, q.placeholderGenerator,
}
return q
}
func (q *QueryBuilder[OUTPUT]) SetUpdate() *QueryBuilder[OUTPUT] {
q.typ = queryTypeUPDATE
return q
}
func (q *QueryBuilder[OUTPUT]) Set(keyValues ...any) *QueryBuilder[OUTPUT] {
if len(keyValues)%2 != 0 {
q.err = fmt.Errorf("when using Set, passed argument count should be even: %w", q.err)
return q
}
q.SetUpdate()
for i := 0; i < len(keyValues); i++ {
if i != 0 && i%2 == 1 {
q.sets = append(q.sets, [2]any{keyValues[i-1], keyValues[i]})
}
}
return q
}
func (q *QueryBuilder[OUTPUT]) SetDialect(dialect *Dialect) *QueryBuilder[OUTPUT] {
q.placeholderGenerator = dialect.PlaceHolderGenerator
return q
}
func (q *QueryBuilder[OUTPUT]) SetDelete() *QueryBuilder[OUTPUT] {
q.typ = queryTypeDelete
return q
}
type raw struct {
sql string
args []interface{}
}
// Raw creates a Raw sql query chunk that you can add to several components of QueryBuilder like
// Wheres.
func Raw(sql string, args ...interface{}) *raw {
return &raw{sql: sql, args: args}
}
func NewQueryBuilder[OUTPUT any](s *schema) *QueryBuilder[OUTPUT] {
return &QueryBuilder[OUTPUT]{schema: s}
}
type insertStmt struct {
PlaceHolderGenerator func(n int) []string
Table string
Columns []string
Values [][]interface{}
Returning string
}
func (i insertStmt) flatValues() []interface{} {
var values []interface{}
for _, row := range i.Values {
values = append(values, row...)
}
return values
}
func (i insertStmt) getValuesStr() string {
phs := i.PlaceHolderGenerator(len(i.Values) * len(i.Values[0]))
var output []string
for _, valueRow := range i.Values {
output = append(output, fmt.Sprintf("(%s)", strings.Join(phs[:len(valueRow)], ",")))
phs = phs[len(valueRow):]
}
return strings.Join(output, ",")
}
func (i insertStmt) ToSql() (string, []interface{}) {
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES %s",
i.Table,
strings.Join(i.Columns, ","),
i.getValuesStr(),
)
if i.Returning != "" {
base += "RETURNING " + i.Returning
}
return base, i.flatValues()
}
func postgresPlaceholder(n int) []string {
output := []string{}
for i := 1; i < n+1; i++ {
output = append(output, fmt.Sprintf("$%d", i))
}
return output
}
func questionMarks(n int) []string {
output := []string{}
for i := 0; i < n; i++ {
output = append(output, "?")
}
return output
}

243
gdb/orm/query_test.go Normal file
View File

@ -0,0 +1,243 @@
//
// query_test.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"testing"
"github.com/stretchr/testify/assert"
)
type Dummy struct{}
func (d Dummy) ConfigureEntity(e *EntityConfigurator) {
// TODO implement me
panic("implement me")
}
func TestSelect(t *testing.T) {
t.Run("only select * from Table", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil)
s.Table("users").SetSelect()
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, "SELECT * FROM users", str)
})
t.Run("select with whereClause", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil)
s.Table("users").SetDialect(Dialects.MySQL).
Where("age", 10).
AndWhere("age", "<", 10).
Where("name", "Amirreza").
OrWhere("age", GT, 11).
SetSelect()
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.EqualValues(t, []interface{}{10, 10, "Amirreza", 11}, args)
assert.Equal(t, "SELECT * FROM users WHERE age = ? AND age < ? AND name = ? OR age > ?", str)
})
t.Run("select with order by", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).Table("users").OrderBy("created_at", ASC).OrderBy("updated_at", DESC)
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, "SELECT * FROM users ORDER BY created_at ASC,updated_at DESC", str)
})
t.Run("select with group by", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).Table("users").GroupBy("created_at", "updated_at")
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, "SELECT * FROM users GROUP BY created_at,updated_at", str)
})
t.Run("Select with limit", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).Table("users").Limit(10)
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, "SELECT * FROM users LIMIT 10", str)
})
t.Run("Select with offset", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).Table("users").Offset(10)
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, "SELECT * FROM users OFFSET 10", str)
})
t.Run("select with join", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).Table("users").Select("id", "name").RightJoin("addresses", "users.id", "addresses.user_id")
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, `SELECT id,name FROM users RIGHT JOIN addresses ON users.id = addresses.user_id`, str)
})
t.Run("select with multiple joins", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).Table("users").
Select("id", "name").
RightJoin("addresses", "users.id", "addresses.user_id").
LeftJoin("user_credits", "users.id", "user_credits.user_id")
sql, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, `SELECT id,name FROM users RIGHT JOIN addresses ON users.id = addresses.user_id LEFT JOIN user_credits ON users.id = user_credits.user_id`, sql)
})
t.Run("select with subquery", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).SetDialect(Dialects.MySQL)
s.FromQuery(NewQueryBuilder[Dummy](nil).Table("users").Where("age", "<", 10))
sql, args, err := s.ToSql()
assert.NoError(t, err)
assert.EqualValues(t, []interface{}{10}, args)
assert.Equal(t, `SELECT * FROM (SELECT * FROM users WHERE age < ? )`, sql)
})
t.Run("select with inner join", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).Table("users").Select("id", "name").InnerJoin("addresses", "users.id", "addresses.user_id")
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, `SELECT id,name FROM users INNER JOIN addresses ON users.id = addresses.user_id`, str)
})
t.Run("select with join", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).Table("users").Select("id", "name").Join("addresses", "users.id", "addresses.user_id")
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, `SELECT id,name FROM users INNER JOIN addresses ON users.id = addresses.user_id`, str)
})
t.Run("select with full outer join", func(t *testing.T) {
s := NewQueryBuilder[Dummy](nil).Table("users").Select("id", "name").FullOuterJoin("addresses", "users.id", "addresses.user_id")
str, args, err := s.ToSql()
assert.NoError(t, err)
assert.Empty(t, args)
assert.Equal(t, `SELECT id,name FROM users FULL OUTER JOIN addresses ON users.id = addresses.user_id`, str)
})
t.Run("raw where", func(t *testing.T) {
sql, args, err :=
NewQueryBuilder[Dummy](nil).
SetDialect(Dialects.MySQL).
Table("users").
Where(Raw("id = ?", 1)).
AndWhere(Raw("age < ?", 10)).
SetSelect().
ToSql()
assert.NoError(t, err)
assert.EqualValues(t, []interface{}{1, 10}, args)
assert.Equal(t, `SELECT * FROM users WHERE id = ? AND age < ?`, sql)
})
t.Run("no sql type matched", func(t *testing.T) {
sql, args, err := NewQueryBuilder[Dummy](nil).ToSql()
assert.Error(t, err)
assert.Empty(t, args)
assert.Empty(t, sql)
})
t.Run("raw where in", func(t *testing.T) {
sql, args, err :=
NewQueryBuilder[Dummy](nil).
SetDialect(Dialects.MySQL).
Table("users").
WhereIn("id", Raw("SELECT user_id FROM user_books WHERE book_id = ?", 10)).
SetSelect().
ToSql()
assert.NoError(t, err)
assert.EqualValues(t, []interface{}{10}, args)
assert.Equal(t, `SELECT * FROM users WHERE id IN (SELECT user_id FROM user_books WHERE book_id = ?)`, sql)
})
t.Run("where in", func(t *testing.T) {
sql, args, err :=
NewQueryBuilder[Dummy](nil).
SetDialect(Dialects.MySQL).
Table("users").
WhereIn("id", 1, 2, 3, 4, 5, 6).
SetSelect().
ToSql()
assert.NoError(t, err)
assert.EqualValues(t, []interface{}{1, 2, 3, 4, 5, 6}, args)
assert.Equal(t, `SELECT * FROM users WHERE id IN (?,?,?,?,?,?)`, sql)
})
}
func TestUpdate(t *testing.T) {
t.Run("update no whereClause", func(t *testing.T) {
u := NewQueryBuilder[Dummy](nil).Table("users").Set("name", "amirreza").SetDialect(Dialects.MySQL)
sql, args, err := u.ToSql()
assert.NoError(t, err)
assert.Equal(t, `UPDATE users SET name=?`, sql)
assert.Equal(t, []interface{}{"amirreza"}, args)
})
t.Run("update with whereClause", func(t *testing.T) {
u := NewQueryBuilder[Dummy](nil).Table("users").Set("name", "amirreza").Where("age", "<", 18).SetDialect(Dialects.MySQL)
sql, args, err := u.ToSql()
assert.NoError(t, err)
assert.Equal(t, `UPDATE users SET name=? WHERE age < ?`, sql)
assert.Equal(t, []interface{}{"amirreza", 18}, args)
})
}
func TestDelete(t *testing.T) {
t.Run("delete without whereClause", func(t *testing.T) {
d := NewQueryBuilder[Dummy](nil).Table("users").SetDelete()
sql, args, err := d.ToSql()
assert.NoError(t, err)
assert.Equal(t, `DELETE FROM users`, sql)
assert.Empty(t, args)
})
t.Run("delete with whereClause", func(t *testing.T) {
d := NewQueryBuilder[Dummy](nil).Table("users").SetDialect(Dialects.MySQL).Where("created_at", ">", "2012-01-10").SetDelete()
sql, args, err := d.ToSql()
assert.NoError(t, err)
assert.Equal(t, `DELETE FROM users WHERE created_at > ?`, sql)
assert.EqualValues(t, []interface{}{"2012-01-10"}, args)
})
}
func TestInsert(t *testing.T) {
t.Run("insert into multiple rows", func(t *testing.T) {
i := insertStmt{}
i.Table = "users"
i.PlaceHolderGenerator = Dialects.MySQL.PlaceHolderGenerator
i.Columns = []string{"name", "age"}
i.Values = append(i.Values, []interface{}{"amirreza", 11}, []interface{}{"parsa", 10})
s, args := i.ToSql()
assert.Equal(t, `INSERT INTO users (name,age) VALUES (?,?),(?,?)`, s)
assert.EqualValues(t, []interface{}{"amirreza", 11, "parsa", 10}, args)
})
t.Run("insert into single row", func(t *testing.T) {
i := insertStmt{}
i.Table = "users"
i.PlaceHolderGenerator = Dialects.MySQL.PlaceHolderGenerator
i.Columns = []string{"name", "age"}
i.Values = append(i.Values, []interface{}{"amirreza", 11})
s, args := i.ToSql()
assert.Equal(t, `INSERT INTO users (name,age) VALUES (?,?)`, s)
assert.Equal(t, []interface{}{"amirreza", 11}, args)
})
}
func TestPostgresPlaceholder(t *testing.T) {
t.Run("for 5 it should have 5", func(t *testing.T) {
phs := postgresPlaceholder(5)
assert.EqualValues(t, []string{"$1", "$2", "$3", "$4", "$5"}, phs)
})
}

306
gdb/orm/schema.go Normal file
View File

@ -0,0 +1,306 @@
//
// schema.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
"database/sql/driver"
"fmt"
"reflect"
)
func getConnectionFor(e Entity) *connection {
configurator := newEntityConfigurator()
e.ConfigureEntity(configurator)
if len(globalConnections) > 1 && (configurator.connection == "" || configurator.table == "") {
panic("need table and DB name when having more than 1 DB registered")
}
if len(globalConnections) == 1 {
for _, db := range globalConnections {
return db
}
}
if db, exists := globalConnections[fmt.Sprintf("%s", configurator.connection)]; exists {
return db
}
panic("no db found")
}
func getSchemaFor(e Entity) *schema {
configurator := newEntityConfigurator()
c := getConnectionFor(e)
e.ConfigureEntity(configurator)
s := c.getSchema(configurator.table)
if s == nil {
s = schemaOfHeavyReflectionStuff(e)
c.setSchema(e, s)
}
return s
}
type schema struct {
Connection string
Table string
fields []*field
relations map[string]interface{}
setPK func(o Entity, value interface{})
getPK func(o Entity) interface{}
columnConstraints []*FieldConfigurator
}
func (s *schema) getField(sf reflect.StructField) *field {
for _, f := range s.fields {
if sf.Name == f.Name {
return f
}
}
return nil
}
func (s *schema) getDialect() *Dialect {
return GetConnection(s.Connection).Dialect
}
func (s *schema) Columns(withPK bool) []string {
var cols []string
for _, field := range s.fields {
if field.Virtual {
continue
}
if !withPK && field.IsPK {
continue
}
if s.getDialect().AddTableNameInSelectColumns {
cols = append(cols, s.Table+"."+field.Name)
} else {
cols = append(cols, field.Name)
}
}
return cols
}
func (s *schema) pkName() string {
for _, field := range s.fields {
if field.IsPK {
return field.Name
}
}
return ""
}
func genericFieldsOf(obj Entity) []*field {
t := reflect.TypeOf(obj)
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() == reflect.Slice {
t = t.Elem()
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
}
var ec EntityConfigurator
obj.ConfigureEntity(&ec)
var fms []*field
for i := 0; i < t.NumField(); i++ {
ft := t.Field(i)
fm := fieldMetadata(ft, ec.columnConstraints)
fms = append(fms, fm...)
}
return fms
}
func valuesOfField(vf reflect.Value) []interface{} {
var values []interface{}
if vf.Type().Kind() == reflect.Struct || vf.Type().Kind() == reflect.Ptr {
t := vf.Type()
if vf.Type().Kind() == reflect.Ptr {
t = vf.Type().Elem()
}
if !t.Implements(reflect.TypeOf((*driver.Valuer)(nil)).Elem()) {
// go into
// it does not implement driver.Valuer interface
for i := 0; i < vf.NumField(); i++ {
vif := vf.Field(i)
values = append(values, valuesOfField(vif)...)
}
} else {
values = append(values, vf.Interface())
}
} else {
values = append(values, vf.Interface())
}
return values
}
func genericValuesOf(o Entity, withPK bool) []interface{} {
t := reflect.TypeOf(o)
v := reflect.ValueOf(o)
if t.Kind() == reflect.Ptr {
t = t.Elem()
v = v.Elem()
}
fields := getSchemaFor(o).fields
pkIdx := -1
for i, field := range fields {
if field.IsPK {
pkIdx = i
}
}
var values []interface{}
for i := 0; i < t.NumField(); i++ {
if !withPK && i == pkIdx {
continue
}
if fields[i].Virtual {
continue
}
vf := v.Field(i)
values = append(values, valuesOfField(vf)...)
}
return values
}
func genericSetPkValue(obj Entity, value interface{}) {
genericSet(obj, getSchemaFor(obj).pkName(), value)
}
func genericGetPKValue(obj Entity) interface{} {
t := reflect.TypeOf(obj)
val := reflect.ValueOf(obj)
if t.Kind() == reflect.Ptr {
val = val.Elem()
}
fields := getSchemaFor(obj).fields
for i, field := range fields {
if field.IsPK {
return val.Field(i).Interface()
}
}
return ""
}
func (s *schema) createdAt() *field {
for _, f := range s.fields {
if f.IsCreatedAt {
return f
}
}
return nil
}
func (s *schema) updatedAt() *field {
for _, f := range s.fields {
if f.IsUpdatedAt {
return f
}
}
return nil
}
func (s *schema) deletedAt() *field {
for _, f := range s.fields {
if f.IsDeletedAt {
return f
}
}
return nil
}
func pointersOf(v reflect.Value) map[string]interface{} {
m := map[string]interface{}{}
actualV := v
for actualV.Type().Kind() == reflect.Ptr {
actualV = actualV.Elem()
}
for i := 0; i < actualV.NumField(); i++ {
f := actualV.Field(i)
if (f.Type().Kind() == reflect.Struct || f.Type().Kind() == reflect.Ptr) && !f.Type().Implements(reflect.TypeOf((*driver.Valuer)(nil)).Elem()) {
fm := pointersOf(f)
for k, p := range fm {
m[k] = p
}
} else {
fm := fieldMetadata(actualV.Type().Field(i), nil)[0]
m[fm.Name] = actualV.Field(i)
}
}
return m
}
func genericSet(obj Entity, name string, value interface{}) {
n2p := pointersOf(reflect.ValueOf(obj))
var val interface{}
for k, v := range n2p {
if k == name {
val = v
}
}
val.(reflect.Value).Set(reflect.ValueOf(value))
}
func schemaOfHeavyReflectionStuff(v Entity) *schema {
userEntityConfigurator := newEntityConfigurator()
v.ConfigureEntity(userEntityConfigurator)
for _, relation := range userEntityConfigurator.resolveRelations {
relation()
}
schema := &schema{}
if userEntityConfigurator.connection != "" {
schema.Connection = userEntityConfigurator.connection
}
if userEntityConfigurator.table != "" {
schema.Table = userEntityConfigurator.table
} else {
panic("you need to have table name for getting schema.")
}
schema.columnConstraints = userEntityConfigurator.columnConstraints
if schema.Connection == "" {
schema.Connection = "default"
}
if schema.fields == nil {
schema.fields = genericFieldsOf(v)
}
if schema.getPK == nil {
schema.getPK = genericGetPKValue
}
if schema.setPK == nil {
schema.setPK = genericSetPkValue
}
schema.relations = userEntityConfigurator.relations
return schema
}
func (s *schema) getTable() string {
return s.Table
}
func (s *schema) getSQLDB() *sql.DB {
return s.getConnection().DB
}
func (s *schema) getConnection() *connection {
if len(globalConnections) > 1 && (s.Connection == "" || s.Table == "") {
panic("need table and DB name when having more than 1 DB registered")
}
if len(globalConnections) == 1 {
for _, db := range globalConnections {
return db
}
}
if db, exists := globalConnections[fmt.Sprintf("%s", s.Connection)]; exists {
return db
}
panic("no db found")
}

76
gdb/orm/schema_test.go Normal file
View File

@ -0,0 +1,76 @@
//
// schema_test.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
)
func setup(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
err = SetupConnections(ConnectionConfig{
Name: "default",
DB: db,
Dialect: Dialects.SQLite3,
})
// orm.Schematic()
_, err = GetConnection("default").DB.Exec(`CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, body text, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP)`)
_, err = GetConnection("default").DB.Exec(`CREATE TABLE IF NOT EXISTS emails (id INTEGER PRIMARY KEY, post_id INTEGER, email text)`)
_, err = GetConnection("default").DB.Exec(`CREATE TABLE IF NOT EXISTS header_pictures (id INTEGER PRIMARY KEY, post_id INTEGER, link text)`)
_, err = GetConnection("default").DB.Exec(`CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, post_id INTEGER, body text)`)
_, err = GetConnection("default").DB.Exec(`CREATE TABLE IF NOT EXISTS categories (id INTEGER PRIMARY KEY, title text)`)
_, err = GetConnection("default").DB.Exec(`CREATE TABLE IF NOT EXISTS post_categories (post_id INTEGER, category_id INTEGER, PRIMARY KEY(post_id, category_id))`)
assert.NoError(t, err)
}
type Object struct {
ID int64
Name string
Timestamps
}
func (o Object) ConfigureEntity(e *EntityConfigurator) {
e.Table("objects").Connection("default")
}
func TestGenericFieldsOf(t *testing.T) {
t.Run("fields of with id and timestamps embedded", func(t *testing.T) {
fs := genericFieldsOf(&Object{})
assert.Len(t, fs, 5)
assert.Equal(t, "id", fs[0].Name)
assert.True(t, fs[0].IsPK)
assert.Equal(t, "name", fs[1].Name)
assert.Equal(t, "created_at", fs[2].Name)
assert.Equal(t, "updated_at", fs[3].Name)
assert.Equal(t, "deleted_at", fs[4].Name)
})
}
func TestGenericValuesOf(t *testing.T) {
t.Run("values of", func(t *testing.T) {
setup(t)
vs := genericValuesOf(Object{}, true)
assert.Len(t, vs, 5)
})
}
func TestEntityConfigurator(t *testing.T) {
t.Run("test has many with user provided values", func(t *testing.T) {
setup(t)
var ec EntityConfigurator
ec.Table("users").Connection("default").HasMany(Object{}, HasManyConfig{
"objects", "user_id",
})
})
}

18
gdb/orm/timestamps.go Normal file
View File

@ -0,0 +1,18 @@
//
// timestamps.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package orm
import (
"database/sql"
)
type Timestamps struct {
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
DeletedAt sql.NullTime
}

507
helper/pluralize.go Normal file
View File

@ -0,0 +1,507 @@
//
// pluralize.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package helper
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// PluralizeRule -- pluralize rule expression and replacement value.
type PluralizeRule struct {
expression *regexp.Regexp
replacement string
}
// PluralizeClient -- pluralize client.
type PluralizeClient struct {
pluralRules []PluralizeRule
singularRules []PluralizeRule
uncountables map[string]bool
irregularSingles map[string]string
irregularPlurals map[string]string
interpolateExpr *regexp.Regexp
}
func NewPluralizeClient() *PluralizeClient {
client := PluralizeClient{}
client.init()
return &client
}
func (c *PluralizeClient) init() {
c.pluralRules = make([]PluralizeRule, 0)
c.singularRules = make([]PluralizeRule, 0)
c.uncountables = make(map[string]bool)
c.irregularSingles = make(map[string]string)
c.irregularPlurals = make(map[string]string)
c.loadIrregularRules()
c.loadPluralizationRules()
c.loadSingularizationRules()
c.loadUncountableRules()
c.interpolateExpr = regexp.MustCompile(`\$(\d{1,2})`)
}
// Pluralize -- Pluralize or singularize a word based on the passed in count.
//
// word: the word to pluralize
// count: how many of the word exist
// inclusive: whether to prefix with the number (e.g. 3 ducks)
func (c *PluralizeClient) Pluralize(word string, count int, inclusive bool) string {
pluralized := func() func(string) string {
if count == 1 {
return c.Singular
}
return c.Plural
}
if inclusive {
return fmt.Sprintf("%d %s", count, pluralized()(word))
}
return pluralized()(word)
}
// Plural -- Pluralize a word.
func (c *PluralizeClient) Plural(word string) string {
return c.replaceWord(c.irregularSingles, c.irregularPlurals, c.pluralRules)(word)
}
// IsPlural -- Check if a word is plural.
func (c *PluralizeClient) IsPlural(word string) bool {
return c.checkWord(c.irregularSingles, c.irregularPlurals, c.pluralRules)(word)
}
// Singular -- Singularize a word.
func (c *PluralizeClient) Singular(word string) string {
return c.replaceWord(c.irregularPlurals, c.irregularSingles, c.singularRules)(word)
}
// IsSingular -- Check if a word is singular.
func (c *PluralizeClient) IsSingular(word string) bool {
return c.checkWord(c.irregularPlurals, c.irregularSingles, c.singularRules)(word)
}
// AddPluralRule -- Add a pluralization rule to the collection.
func (c *PluralizeClient) AddPluralRule(rule string, replacement string) {
c.pluralRules = append(c.pluralRules, PluralizeRule{sanitizeRule(rule), replacement})
}
// AddSingularRule -- Add a singularization rule to the collection.
func (c *PluralizeClient) AddSingularRule(rule string, replacement string) {
c.singularRules = append(c.singularRules, PluralizeRule{sanitizeRule(rule), replacement})
}
// AddUncountableRule -- Add an uncountable word rule.
func (c *PluralizeClient) AddUncountableRule(word string) {
if !isExpr(word) {
c.uncountables[strings.ToLower(word)] = true
return
}
c.AddPluralRule(word, `$0`)
c.AddSingularRule(word, `$0`)
}
// AddIrregularRule -- Add an irregular word definition.
func (c *PluralizeClient) AddIrregularRule(single string, plural string) {
p := strings.ToLower(plural)
s := strings.ToLower(single)
c.irregularSingles[s] = p
c.irregularPlurals[p] = s
}
func (c *PluralizeClient) replaceWord(replaceMap map[string]string, keepMap map[string]string, rules []PluralizeRule) func(w string) string { //nolint:lll
f := func(word string) string {
// Get the correct token and case restoration functions.
var token = strings.ToLower(word)
// Check against the keep object map.
if _, ok := keepMap[token]; ok {
return restoreCase(word, token)
}
// Check against the replacement map for a direct word replacement.
if replaceToken, ok := replaceMap[token]; ok {
return restoreCase(word, replaceToken)
}
// Run all the rules against the word.
return c.sanitizeWord(token, word, rules)
}
return f
}
func (c *PluralizeClient) checkWord(replaceMap map[string]string, keepMap map[string]string, rules []PluralizeRule) func(w string) bool {
f := func(word string) bool {
var token = strings.ToLower(word)
if _, ok := keepMap[token]; ok {
return true
}
if _, ok := replaceMap[token]; ok {
return false
}
return c.sanitizeWord(token, token, rules) == token
}
return f
}
func (c *PluralizeClient) interpolate(str string, args []string) string {
lookup := map[string]string{}
for _, submatch := range c.interpolateExpr.FindAllStringSubmatch(str, -1) {
element, _ := strconv.Atoi(submatch[1])
lookup[submatch[0]] = args[element]
}
result := c.interpolateExpr.ReplaceAllStringFunc(str, func(repl string) string {
return lookup[repl]
})
return result
}
func (c *PluralizeClient) replace(word string, rule PluralizeRule) string {
return rule.expression.ReplaceAllStringFunc(word, func(w string) string {
match := rule.expression.FindString(word)
index := rule.expression.FindStringIndex(word)[0]
args := rule.expression.FindAllStringSubmatch(word, -1)[0]
result := c.interpolate(rule.replacement, args)
if match == `` {
return restoreCase(word[index-1:index], result)
}
return restoreCase(match, result)
})
}
func (c *PluralizeClient) sanitizeWord(token string, word string, rules []PluralizeRule) string {
// If empty string
if len(token) == 0 {
return word
}
// If does not need fixup
if _, ok := c.uncountables[token]; ok {
return word
}
// Iterate over the sanitization rules and use the first one to match.
// NOTE: iterate rules array in reverse order specific => general rules
for i := len(rules) - 1; i >= 0; i-- {
if rules[i].expression.MatchString(word) {
return c.replace(word, rules[i])
}
}
return word
}
func sanitizeRule(rule string) *regexp.Regexp {
if isExpr(rule) {
return regexp.MustCompile(rule)
}
return regexp.MustCompile(`(?i)^` + rule + `$`)
}
func restoreCase(word string, token string) string {
// Tokens are an exact match.
if word == token {
return token
}
// Lower cased words. E.g. "hello".
if word == strings.ToLower(word) {
return strings.ToLower(token)
}
// Upper cased words. E.g. "WHISKY".
if word == strings.ToUpper(word) {
return strings.ToUpper(token)
}
// Title cased words. E.g. "Title".
if word[:1] == strings.ToUpper(word[:1]) {
return strings.ToUpper(token[:1]) + strings.ToLower(token[1:])
}
// Lower cased words. E.g. "test".
return strings.ToLower(token)
}
// isExpr -- helper to detect if string represents an expression by checking first character to be `(`.
func isExpr(s string) bool {
return s[:1] == `(`
}
func (c *PluralizeClient) loadIrregularRules() { //nolint:funlen
var irregularRules = []struct {
single string
plural string
}{
// Pronouns.
{`I`, `we`},
{`me`, `us`},
{`he`, `they`},
{`she`, `they`},
{`them`, `them`},
{`myself`, `ourselves`},
{`yourself`, `yourselves`},
{`itself`, `themselves`},
{`herself`, `themselves`},
{`himself`, `themselves`},
{`themself`, `themselves`},
{`is`, `are`},
{`was`, `were`},
{`has`, `have`},
{`this`, `these`},
{`that`, `those`},
{`my`, `our`},
{`its`, `their`},
{`his`, `their`},
{`her`, `their`},
// Words ending in with a consonant and `o`.
{`echo`, `echoes`},
{`dingo`, `dingoes`},
{`volcano`, `volcanoes`},
{`tornado`, `tornadoes`},
{`torpedo`, `torpedoes`},
// Ends with `us`.
{`genus`, `genera`},
{`viscus`, `viscera`},
// Ends with `ma`.
{`stigma`, `stigmata`},
{`stoma`, `stomata`},
{`dogma`, `dogmata`},
{`lemma`, `lemmata`},
{`schema`, `schemata`},
{`anathema`, `anathemata`},
// Other irregular rules.
{`ox`, `oxen`},
{`axe`, `axes`},
{`die`, `dice`},
{`yes`, `yeses`},
{`foot`, `feet`},
{`eave`, `eaves`},
{`goose`, `geese`},
{`tooth`, `teeth`},
{`quiz`, `quizzes`},
{`human`, `humans`},
{`proof`, `proofs`},
{`carve`, `carves`},
{`valve`, `valves`},
{`looey`, `looies`},
{`thief`, `thieves`},
{`groove`, `grooves`},
{`pickaxe`, `pickaxes`},
{`passerby`, `passersby`},
{`canvas`, `canvases`},
{`sms`, `sms`},
}
for _, r := range irregularRules {
c.AddIrregularRule(r.single, r.plural)
}
}
func (c *PluralizeClient) loadPluralizationRules() {
var pluralizationRules = []struct {
rule string
replacement string
}{
{`(?i)s?$`, `s`},
{`(?i)[^[:ascii:]]$`, `$0`},
{`(?i)([^aeiou]ese)$`, `$1`},
{`(?i)(ax|test)is$`, `$1es`},
{`(?i)(alias|[^aou]us|t[lm]as|gas|ris)$`, `$1es`},
{`(?i)(e[mn]u)s?$`, `$1s`},
{`(?i)([^l]ias|[aeiou]las|[ejzr]as|[iu]am)$`, `$1`},
{`(?i)(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$`, `$1i`}, //nolint:lll,misspell
{`(?i)(alumn|alg|vertebr)(?:a|ae)$`, `$1ae`},
{`(?i)(seraph|cherub)(?:im)?$`, `$1im`},
{`(?i)(her|at|gr)o$`, `$1oes`},
{`(?i)(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|automat|quor)(?:a|um)$`, `$1a`}, //nolint:lll,misspell
{`(?i)(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)(?:a|on)$`, `$1a`},
{`(?i)sis$`, `ses`},
{`(?i)(?:(kni|wi|li)fe|(ar|l|ea|eo|oa|hoo)f)$`, `$1$2ves`},
{`(?i)([^aeiouy]|qu)y$`, `$1ies`},
{`(?i)([^ch][ieo][ln])ey$`, `$1ies`},
{`(?i)(x|ch|ss|sh|zz)$`, `$1es`},
{`(?i)(matr|cod|mur|sil|vert|ind|append)(?:ix|ex)$`, `$1ices`},
{`(?i)\b((?:tit)?m|l)(?:ice|ouse)$`, `$1ice`},
{`(?i)(pe)(?:rson|ople)$`, `$1ople`},
{`(?i)(child)(?:ren)?$`, `$1ren`},
{`(?i)eaux$`, `$0`},
{`(?i)m[ae]n$`, `men`},
{`thou`, `you`},
}
for _, r := range pluralizationRules {
c.AddPluralRule(r.rule, r.replacement)
}
}
func (c *PluralizeClient) loadSingularizationRules() {
var singularizationRules = []struct {
rule string
replacement string
}{
{`(?i)s$`, ``},
{`(?i)(ss)$`, `$1`},
{`(?i)(wi|kni|(?:after|half|high|low|mid|non|night|[^\w]|^)li)ves$`, `$1fe`},
{`(?i)(ar|(?:wo|[ae])l|[eo][ao])ves$`, `$1f`},
{`(?i)ies$`, `y`},
{`(?i)(dg|ss|ois|lk|ok|wn|mb|th|ch|ec|oal|is|ck|ix|sser|ts|wb)ies$`, `$1ie`},
{`(?i)\b(l|(?:neck|cross|hog|aun)?t|coll|faer|food|gen|goon|group|hipp|junk|vegg|(?:pork)?p|charl|calor|cut)ies$`, `$1ie`}, //nolint:lll
{`(?i)\b(mon|smil)ies$`, `$1ey`},
{`(?i)\b((?:tit)?m|l)ice$`, `$1ouse`},
{`(?i)(seraph|cherub)im$`, `$1`},
{`(?i)(x|ch|ss|sh|zz|tto|go|cho|alias|[^aou]us|t[lm]as|gas|(?:her|at|gr)o|[aeiou]ris)(?:es)?$`, `$1`},
{`(?i)(analy|diagno|parenthe|progno|synop|the|empha|cri|ne)(?:sis|ses)$`, `$1sis`},
{`(?i)(movie|twelve|abuse|e[mn]u)s$`, `$1`},
{`(?i)(test)(?:is|es)$`, `$1is`},
{`(?i)(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$`, `$1us`}, //nolint:lll,misspell
{`(?i)(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|quor)a$`, `$1um`}, //nolint:lll,misspell
{`(?i)(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)a$`, `$1on`},
{`(?i)(alumn|alg|vertebr)ae$`, `$1a`},
{`(?i)(cod|mur|sil|vert|ind)ices$`, `$1ex`},
{`(?i)(matr|append)ices$`, `$1ix`},
{`(?i)(pe)(rson|ople)$`, `$1rson`},
{`(?i)(child)ren$`, `$1`},
{`(?i)(eau)x?$`, `$1`},
{`(?i)men$`, `man`},
}
for _, r := range singularizationRules {
c.AddSingularRule(r.rule, r.replacement)
}
}
func (c *PluralizeClient) loadUncountableRules() { //nolint:funlen
var uncountableRules = []string{
// Singular words with no plurals.
`adulthood`,
`advice`,
`agenda`,
`aid`,
`aircraft`,
`alcohol`,
`ammo`,
`analytics`,
`anime`,
`athletics`,
`audio`,
`bison`,
`blood`,
`bream`,
`buffalo`,
`butter`,
`carp`,
`cash`,
`chassis`,
`chess`,
`clothing`,
`cod`,
`commerce`,
`cooperation`,
`corps`,
`debris`,
`diabetes`,
`digestion`,
`elk`,
`energy`,
`equipment`,
`excretion`,
`expertise`,
`firmware`,
`flounder`,
`fun`,
`gallows`,
`garbage`,
`graffiti`,
`hardware`,
`headquarters`,
`health`,
`herpes`,
`highjinks`,
`homework`,
`housework`,
`information`,
`jeans`,
`justice`,
`kudos`,
`labour`,
`literature`,
`machinery`,
`mackerel`,
`mail`,
`media`,
`mews`,
`moose`,
`music`,
`mud`,
`manga`,
`news`,
`only`,
`personnel`,
`pike`,
`plankton`,
`pliers`,
`police`,
`pollution`,
`premises`,
`rain`,
`research`,
`rice`,
`salmon`,
`scissors`,
`series`,
`sewage`,
`shambles`,
`shrimp`,
`software`,
`staff`,
`swine`,
`tennis`,
`traffic`,
`transportation`,
`trout`,
`tuna`,
`wealth`,
`welfare`,
`whiting`,
`wildebeest`,
`wildlife`,
`you`,
// Regexes.
`(?i)pok[eé]mon$`, //
`(?i)[^aeiou]ese$`, // "chinese", "japanese"
`(?i)deer$`, // "deer", "reindeer"
`(?i)(fish)$`, // "fish", "blowfish", "angelfish"
`(?i)measles$`, //
`(?i)o[iu]s$`, // "carnivorous"
`(?i)pox$`, // "chickpox", "smallpox"
`(?i)sheep$`, //
}
for _, w := range uncountableRules {
c.AddUncountableRule(w)
}
}

101
helper/table/config.go Normal file
View File

@ -0,0 +1,101 @@
package table
import "git.hexq.cn/tiglog/golib/helper/text"
// ColumnConfig contains configurations that determine and modify the way the
// contents of the column get rendered.
type ColumnConfig struct {
// Name is the name of the Column as it appears in the first Header row.
// If a Header is not provided, or the name is not found in the header, this
// will not work.
Name string
// Number is the Column # from left. When specified, it overrides the Name
// property. If you know the exact Column number, use this instead of Name.
Number int
// Align defines the horizontal alignment
Align text.Align
// AlignFooter defines the horizontal alignment of Footer rows
AlignFooter text.Align
// AlignHeader defines the horizontal alignment of Header rows
AlignHeader text.Align
// AutoMerge merges cells with similar values and prevents separators from
// being drawn. Caveats:
// * VAlign is applied on the individual cell and not on the merged cell
// * Does not work in CSV/HTML/Markdown render modes
// * Does not work well with horizontal auto-merge (RowConfig.AutoMerge)
//
// Works best when:
// * Style().Options.SeparateRows == true
// * Style().Color.Row == Style().Color.RowAlternate (or not set)
AutoMerge bool
// Colors defines the colors to be used on the column
Colors text.Colors
// ColorsFooter defines the colors to be used on the column in Footer rows
ColorsFooter text.Colors
// ColorsHeader defines the colors to be used on the column in Header rows
ColorsHeader text.Colors
// Hidden when set to true will prevent the column from being rendered.
// This is useful in cases like needing a column for sorting, but not for
// display.
Hidden bool
// Transformer is a custom-function that changes the way the value gets
// rendered to the console. Refer to text/transformer.go for ready-to-use
// Transformer functions.
Transformer text.Transformer
// TransformerFooter is like Transformer but for Footer rows
TransformerFooter text.Transformer
// TransformerHeader is like Transformer but for Header rows
TransformerHeader text.Transformer
// VAlign defines the vertical alignment
VAlign text.VAlign
// VAlignFooter defines the vertical alignment in Footer rows
VAlignFooter text.VAlign
// VAlignHeader defines the vertical alignment in Header rows
VAlignHeader text.VAlign
// WidthMax defines the maximum character length of the column
WidthMax int
// WidthEnforcer enforces the WidthMax value on the column contents;
// default: text.WrapText
WidthMaxEnforcer WidthEnforcer
// WidthMin defines the minimum character length of the column
WidthMin int
}
func (c ColumnConfig) getWidthMaxEnforcer() WidthEnforcer {
if c.WidthMax == 0 {
return widthEnforcerNone
}
if c.WidthMaxEnforcer != nil {
return c.WidthMaxEnforcer
}
return text.WrapText
}
// RowConfig contains configurations that determine and modify the way the
// contents of a row get rendered.
type RowConfig struct {
// AutoMerge merges cells with similar values and prevents separators from
// being drawn. Caveats:
// * Align is overridden to text.AlignCenter on the merged cell (unless set
// by AutoMergeAlign value below)
// * Does not work in CSV/HTML/Markdown render modes
// * Does not work well with vertical auto-merge (ColumnConfig.AutoMerge)
AutoMerge bool
// Alignment to use on a merge (defaults to text.AlignCenter)
AutoMergeAlign text.Align
}
func (rc RowConfig) getAutoMergeAlign() text.Align {
if rc.AutoMergeAlign == text.AlignDefault {
return text.AlignCenter
}
return rc.AutoMergeAlign
}

View File

@ -0,0 +1,75 @@
//
// config_test.go
// Copyright (C) 2023 tiglog <me@tiglog.com>
//
// Distributed under terms of the MIT license.
//
package table
import (
"testing"
"git.hexq.cn/tiglog/golib/helper/text"
"github.com/stretchr/testify/assert"
)
func TestColumnConfig_getWidthMaxEnforcer(t *testing.T) {
t.Run("no width enforcer", func(t *testing.T) {
cc := ColumnConfig{}
widthEnforcer := cc.getWidthMaxEnforcer()
assert.Equal(t, "1234567890", widthEnforcer("1234567890", 0))
assert.Equal(t, "1234567890", widthEnforcer("1234567890", 1))
assert.Equal(t, "1234567890", widthEnforcer("1234567890", 5))
assert.Equal(t, "1234567890", widthEnforcer("1234567890", 10))
assert.Equal(t, "1234567890", widthEnforcer("1234567890", 100))
assert.Equal(t, "1234567890", widthEnforcer("1234567890", 1000))
})
t.Run("default width enforcer", func(t *testing.T) {
cc := ColumnConfig{
WidthMax: 10,
}
widthEnforcer := cc.getWidthMaxEnforcer()
assert.Equal(t, "", widthEnforcer("1234567890", 0))
assert.Equal(t, "1\n2\n3\n4\n5\n6\n7\n8\n9\n0", widthEnforcer("1234567890", 1))
assert.Equal(t, "12345\n67890", widthEnforcer("1234567890", 5))
assert.Equal(t, "1234567890", widthEnforcer("1234567890", 10))
assert.Equal(t, "1234567890", widthEnforcer("1234567890", 100))
assert.Equal(t, "1234567890", widthEnforcer("1234567890", 1000))
})
t.Run("custom width enforcer (1)", func(t *testing.T) {
cc := ColumnConfig{
WidthMax: 10,
WidthMaxEnforcer: text.Trim,
}
widthEnforcer := cc.getWidthMaxEnforcer()
assert.Equal(t, text.Trim("1234567890", 0), widthEnforcer("1234567890", 0))
assert.Equal(t, text.Trim("1234567890", 1), widthEnforcer("1234567890", 1))
assert.Equal(t, text.Trim("1234567890", 5), widthEnforcer("1234567890", 5))
assert.Equal(t, text.Trim("1234567890", 10), widthEnforcer("1234567890", 10))
assert.Equal(t, text.Trim("1234567890", 100), widthEnforcer("1234567890", 100))
assert.Equal(t, text.Trim("1234567890", 1000), widthEnforcer("1234567890", 1000))
})
t.Run("custom width enforcer (2)", func(t *testing.T) {
cc := ColumnConfig{
WidthMax: 10,
WidthMaxEnforcer: func(col string, maxLen int) string {
return "foo"
},
}
widthEnforcer := cc.getWidthMaxEnforcer()
assert.Equal(t, "foo", widthEnforcer("1234567890", 0))
assert.Equal(t, "foo", widthEnforcer("1234567890", 1))
assert.Equal(t, "foo", widthEnforcer("1234567890", 5))
assert.Equal(t, "foo", widthEnforcer("1234567890", 10))
assert.Equal(t, "foo", widthEnforcer("1234567890", 100))
assert.Equal(t, "foo", widthEnforcer("1234567890", 1000))
})
}

408
helper/table/render.go Normal file
View File

@ -0,0 +1,408 @@
package table
import (
"fmt"
"strings"
"unicode/utf8"
"git.hexq.cn/tiglog/golib/helper/text"
)
// Render renders the Table in a human-readable "pretty" format. Example:
//
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
func (t *Table) Render() string {
t.initForRender()
var out strings.Builder
if t.numColumns > 0 {
t.renderTitle(&out)
// top-most border
t.renderRowsBorderTop(&out)
// header rows
t.renderRowsHeader(&out)
// (data) rows
t.renderRows(&out, t.rows, renderHint{})
// footer rows
t.renderRowsFooter(&out)
// bottom-most border
t.renderRowsBorderBottom(&out)
// caption
if t.caption != "" {
out.WriteRune('\n')
out.WriteString(t.caption)
}
}
return t.render(&out)
}
func (t *Table) renderColumn(out *strings.Builder, row rowStr, colIdx int, maxColumnLength int, hint renderHint) int {
numColumnsRendered := 1
// when working on the first column, and autoIndex is true, insert a new
// column with the row number on it.
if colIdx == 0 && t.autoIndex {
hintAutoIndex := hint
hintAutoIndex.isAutoIndexColumn = true
t.renderColumnAutoIndex(out, hintAutoIndex)
}
// when working on column number 2 or more, render the column separator
if colIdx > 0 {
t.renderColumnSeparator(out, row, colIdx, hint)
}
// extract the text, convert-case if not-empty and align horizontally
mergeVertically := t.shouldMergeCellsVertically(colIdx, hint)
var colStr string
if mergeVertically {
// leave colStr empty; align will expand the column as necessary
} else if colIdx < len(row) {
colStr = t.getFormat(hint).Apply(row[colIdx])
}
align := t.getAlign(colIdx, hint)
// if horizontal cell merges are enabled, look ahead and see how many cells
// have the same content and merge them all until a cell with a different
// content is found; override alignment to Center in this case
rowConfig := t.getRowConfig(hint)
if rowConfig.AutoMerge && !hint.isSeparatorRow {
// get the real row to consider all lines in each column instead of just
// looking at the current "line"
rowUnwrapped := t.getRow(hint.rowNumber-1, hint)
for idx := colIdx + 1; idx < len(rowUnwrapped); idx++ {
if rowUnwrapped[colIdx] != rowUnwrapped[idx] {
break
}
align = rowConfig.getAutoMergeAlign()
maxColumnLength += t.getMaxColumnLengthForMerging(idx)
numColumnsRendered++
}
}
colStr = align.Apply(colStr, maxColumnLength)
// pad both sides of the column
if !hint.isSeparatorRow || (hint.isSeparatorRow && mergeVertically) {
colStr = t.style.Box.PaddingLeft + colStr + t.style.Box.PaddingRight
}
t.renderColumnColorized(out, colIdx, colStr, hint)
return colIdx + numColumnsRendered
}
func (t *Table) renderColumnAutoIndex(out *strings.Builder, hint renderHint) {
var outAutoIndex strings.Builder
outAutoIndex.Grow(t.maxColumnLengths[0])
if hint.isSeparatorRow {
numChars := t.autoIndexVIndexMaxLength + utf8.RuneCountInString(t.style.Box.PaddingLeft) +
utf8.RuneCountInString(t.style.Box.PaddingRight)
chars := t.style.Box.MiddleHorizontal
if hint.isAutoIndexColumn && hint.isHeaderOrFooterSeparator() {
chars = text.RepeatAndTrim(" ", len(t.style.Box.MiddleHorizontal))
}
outAutoIndex.WriteString(text.RepeatAndTrim(chars, numChars))
} else {
outAutoIndex.WriteString(t.style.Box.PaddingLeft)
rowNumStr := fmt.Sprint(hint.rowNumber)
if hint.isHeaderRow || hint.isFooterRow || hint.rowLineNumber > 1 {
rowNumStr = strings.Repeat(" ", t.autoIndexVIndexMaxLength)
}
outAutoIndex.WriteString(text.AlignRight.Apply(rowNumStr, t.autoIndexVIndexMaxLength))
outAutoIndex.WriteString(t.style.Box.PaddingRight)
}
if t.style.Color.IndexColumn != nil {
colors := t.style.Color.IndexColumn
if hint.isFooterRow {
colors = t.style.Color.Footer
}
out.WriteString(colors.Sprint(outAutoIndex.String()))
} else {
out.WriteString(outAutoIndex.String())
}
hint.isAutoIndexColumn = true
t.renderColumnSeparator(out, rowStr{}, 0, hint)
}
func (t *Table) renderColumnColorized(out *strings.Builder, colIdx int, colStr string, hint renderHint) {
colors := t.getColumnColors(colIdx, hint)
if colors != nil {
out.WriteString(colors.Sprint(colStr))
} else if hint.isHeaderRow && t.style.Color.Header != nil {
out.WriteString(t.style.Color.Header.Sprint(colStr))
} else if hint.isFooterRow && t.style.Color.Footer != nil {
out.WriteString(t.style.Color.Footer.Sprint(colStr))
} else if hint.isRegularRow() {
if colIdx == t.indexColumn-1 && t.style.Color.IndexColumn != nil {
out.WriteString(t.style.Color.IndexColumn.Sprint(colStr))
} else if hint.rowNumber%2 == 0 && t.style.Color.RowAlternate != nil {
out.WriteString(t.style.Color.RowAlternate.Sprint(colStr))
} else if t.style.Color.Row != nil {
out.WriteString(t.style.Color.Row.Sprint(colStr))
} else {
out.WriteString(colStr)
}
} else {
out.WriteString(colStr)
}
}
func (t *Table) renderColumnSeparator(out *strings.Builder, row rowStr, colIdx int, hint renderHint) {
if t.style.Options.SeparateColumns {
separator := t.getColumnSeparator(row, colIdx, hint)
colors := t.getSeparatorColors(hint)
if colors.EscapeSeq() != "" {
out.WriteString(colors.Sprint(separator))
} else {
out.WriteString(separator)
}
}
}
func (t *Table) renderLine(out *strings.Builder, row rowStr, hint renderHint) {
// if the output has content, it means that this call is working on line
// number 2 or more; separate them with a newline
if out.Len() > 0 {
out.WriteRune('\n')
}
// use a brand-new strings.Builder if a row length limit has been set
var outLine *strings.Builder
if t.allowedRowLength > 0 {
outLine = &strings.Builder{}
} else {
outLine = out
}
// grow the strings.Builder to the maximum possible row length
outLine.Grow(t.maxRowLength)
nextColIdx := 0
t.renderMarginLeft(outLine, hint)
for colIdx, maxColumnLength := range t.maxColumnLengths {
if colIdx != nextColIdx {
continue
}
nextColIdx = t.renderColumn(outLine, row, colIdx, maxColumnLength, hint)
}
t.renderMarginRight(outLine, hint)
// merge the strings.Builder objects if a new one was created earlier
if outLine != out {
t.renderLineMergeOutputs(out, outLine)
}
// if a page size has been set, and said number of lines has already
// been rendered, and the header is not being rendered right now, render
// the header all over again with a spacing line
if hint.isRegularRow() {
t.numLinesRendered++
if t.pageSize > 0 && t.numLinesRendered%t.pageSize == 0 && !hint.isLastLineOfLastRow() {
t.renderRowsFooter(out)
t.renderRowsBorderBottom(out)
out.WriteString(t.style.Box.PageSeparator)
t.renderRowsBorderTop(out)
t.renderRowsHeader(out)
}
}
}
func (t *Table) renderLineMergeOutputs(out *strings.Builder, outLine *strings.Builder) {
outLineStr := outLine.String()
if text.RuneWidthWithoutEscSequences(outLineStr) > t.allowedRowLength {
trimLength := t.allowedRowLength - utf8.RuneCountInString(t.style.Box.UnfinishedRow)
if trimLength > 0 {
out.WriteString(text.Trim(outLineStr, trimLength))
out.WriteString(t.style.Box.UnfinishedRow)
}
} else {
out.WriteString(outLineStr)
}
}
func (t *Table) renderMarginLeft(out *strings.Builder, hint renderHint) {
out.WriteString(t.style.Format.Direction.Modifier())
if t.style.Options.DrawBorder {
border := t.getBorderLeft(hint)
colors := t.getBorderColors(hint)
if colors.EscapeSeq() != "" {
out.WriteString(colors.Sprint(border))
} else {
out.WriteString(border)
}
}
}
func (t *Table) renderMarginRight(out *strings.Builder, hint renderHint) {
if t.style.Options.DrawBorder {
border := t.getBorderRight(hint)
colors := t.getBorderColors(hint)
if colors.EscapeSeq() != "" {
out.WriteString(colors.Sprint(border))
} else {
out.WriteString(border)
}
}
}
func (t *Table) renderRow(out *strings.Builder, row rowStr, hint renderHint) {
if len(row) > 0 {
// fit every column into the allowedColumnLength/maxColumnLength limit
// and in the process find the max. number of lines in any column in
// this row
colMaxLines, rowWrapped := t.wrapRow(row)
// if there is just 1 line in all columns, add the row as such; else
// split each column into individual lines and render them one-by-one
if colMaxLines == 1 {
hint.isLastLineOfRow = true
t.renderLine(out, rowWrapped, hint)
} else {
// convert one row into N # of rows based on colMaxLines
rowLines := make([]rowStr, len(row))
for colIdx, colStr := range rowWrapped {
rowLines[colIdx] = t.getVAlign(colIdx, hint).ApplyStr(colStr, colMaxLines)
}
for colLineIdx := 0; colLineIdx < colMaxLines; colLineIdx++ {
rowLine := make(rowStr, len(rowLines))
for colIdx, colLines := range rowLines {
rowLine[colIdx] = colLines[colLineIdx]
}
hint.isLastLineOfRow = colLineIdx == colMaxLines-1
hint.rowLineNumber = colLineIdx + 1
t.renderLine(out, rowLine, hint)
}
}
}
}
func (t *Table) renderRowSeparator(out *strings.Builder, hint renderHint) {
if hint.isBorderTop || hint.isBorderBottom {
if !t.style.Options.DrawBorder {
return
}
} else if hint.isHeaderRow && !t.style.Options.SeparateHeader {
return
} else if hint.isFooterRow && !t.style.Options.SeparateFooter {
return
}
hint.isSeparatorRow = true
t.renderLine(out, t.rowSeparator, hint)
}
func (t *Table) renderRows(out *strings.Builder, rows []rowStr, hint renderHint) {
for rowIdx, row := range rows {
hint.isFirstRow = rowIdx == 0
hint.isLastRow = rowIdx == len(rows)-1
hint.rowNumber = rowIdx + 1
t.renderRow(out, row, hint)
if (t.style.Options.SeparateRows && rowIdx < len(rows)-1) || // last row before footer
(t.separators[rowIdx] && rowIdx != len(rows)-1) { // manually added separator not after last row
hint.isFirstRow = false
t.renderRowSeparator(out, hint)
}
}
}
func (t *Table) renderRowsBorderBottom(out *strings.Builder) {
if len(t.rowsFooter) > 0 {
t.renderRowSeparator(out, renderHint{isBorderBottom: true, isFooterRow: true, rowNumber: len(t.rowsFooter)})
} else {
t.renderRowSeparator(out, renderHint{isBorderBottom: true, isFooterRow: false, rowNumber: len(t.rows)})
}
}
func (t *Table) renderRowsBorderTop(out *strings.Builder) {
if len(t.rowsHeader) > 0 || t.autoIndex {
t.renderRowSeparator(out, renderHint{isBorderTop: true, isHeaderRow: true, rowNumber: 0})
} else {
t.renderRowSeparator(out, renderHint{isBorderTop: true, isHeaderRow: false, rowNumber: 0})
}
}
func (t *Table) renderRowsFooter(out *strings.Builder) {
if len(t.rowsFooter) > 0 {
t.renderRowSeparator(out, renderHint{
isFooterRow: true,
isFirstRow: true,
isSeparatorRow: true,
})
t.renderRows(out, t.rowsFooter, renderHint{isFooterRow: true})
}
}
func (t *Table) renderRowsHeader(out *strings.Builder) {
if len(t.rowsHeader) > 0 || t.autoIndex {
hintSeparator := renderHint{isHeaderRow: true, isLastRow: true, isSeparatorRow: true}
if len(t.rowsHeader) > 0 {
t.renderRows(out, t.rowsHeader, renderHint{isHeaderRow: true})
hintSeparator.rowNumber = len(t.rowsHeader)
} else if t.autoIndex {
t.renderRow(out, t.getAutoIndexColumnIDs(), renderHint{isAutoIndexRow: true, isHeaderRow: true})
hintSeparator.rowNumber = 1
}
t.renderRowSeparator(out, hintSeparator)
}
}
func (t *Table) renderTitle(out *strings.Builder) {
if t.title != "" {
colors := t.style.Title.Colors
colorsBorder := t.getBorderColors(renderHint{isTitleRow: true})
rowLength := t.maxRowLength
if t.allowedRowLength != 0 && t.allowedRowLength < rowLength {
rowLength = t.allowedRowLength
}
if t.style.Options.DrawBorder {
lenBorder := rowLength - text.RuneWidthWithoutEscSequences(t.style.Box.TopLeft+t.style.Box.TopRight)
out.WriteString(colorsBorder.Sprint(t.style.Box.TopLeft))
out.WriteString(colorsBorder.Sprint(text.RepeatAndTrim(t.style.Box.MiddleHorizontal, lenBorder)))
out.WriteString(colorsBorder.Sprint(t.style.Box.TopRight))
}
lenText := rowLength - text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft+t.style.Box.PaddingRight)
if t.style.Options.DrawBorder {
lenText -= text.RuneWidthWithoutEscSequences(t.style.Box.Left + t.style.Box.Right)
}
titleText := text.WrapText(t.title, lenText)
for _, titleLine := range strings.Split(titleText, "\n") {
t.renderTitleLine(out, lenText, titleLine, colors, colorsBorder)
}
}
}
func (t *Table) renderTitleLine(out *strings.Builder, lenText int, titleLine string, colors text.Colors, colorsBorder text.Colors) {
titleLine = strings.TrimSpace(titleLine)
titleLine = t.style.Title.Format.Apply(titleLine)
titleLine = t.style.Title.Align.Apply(titleLine, lenText)
titleLine = t.style.Box.PaddingLeft + titleLine + t.style.Box.PaddingRight
if out.Len() > 0 {
out.WriteRune('\n')
}
if t.style.Options.DrawBorder {
out.WriteString(colorsBorder.Sprint(t.style.Box.Left))
}
out.WriteString(colors.Sprint(titleLine))
if t.style.Options.DrawBorder {
out.WriteString(colorsBorder.Sprint(t.style.Box.Right))
}
}

View File

@ -0,0 +1,53 @@
package table
import (
"testing"
"github.com/jedib0t/go-pretty/v6/text"
)
func TestTable_Render_BiDiText(t *testing.T) {
table := Table{}
table.AppendHeader(Row{"תאריך", "סכום", "מחלקה", "תגים"})
table.AppendRow(Row{"2020-01-01", 5.0, "מחלקה1", []string{"תג1", "תג2"}})
table.AppendRow(Row{"2021-02-01", 5.0, "מחלקה1", []string{"תג1"}})
table.AppendRow(Row{"2022-03-01", 5.0, "מחלקה2", []string{"תג1"}})
table.AppendFooter(Row{"סהכ", 30})
table.SetAutoIndex(true)
//table.Style().Format.Direction = text.Default
compareOutput(t, table.Render(), `
+---+------------+------+--------+-----------+
| | תאריך | סכום | מחלקה | תגים |
+---+------------+------+--------+-----------+
| 1 | 2020-01-01 | 5 | מחלקה1 | [תג1 תג2] |
| 2 | 2021-02-01 | 5 | מחלקה1 | [תג1] |
| 3 | 2022-03-01 | 5 | מחלקה2 | [תג1] |
+---+------------+------+--------+-----------+
| | סהכ | 30 | | |
+---+------------+------+--------+-----------+`)
table.Style().Format.Direction = text.LeftToRight
compareOutput(t, table.Render(), `
+---+------------+------+--------+-----------+
| | תאריך | סכום | מחלקה | תגים |
+---+------------+------+--------+-----------+
| 1 | 2020-01-01 | 5 | מחלקה1 | [תג1 תג2] |
| 2 | 2021-02-01 | 5 | מחלקה1 | [תג1] |
| 3 | 2022-03-01 | 5 | מחלקה2 | [תג1] |
+---+------------+------+--------+-----------+
| | סהכ | 30 | | |
+---+------------+------+--------+-----------+`)
table.Style().Format.Direction = text.RightToLeft
compareOutput(t, table.Render(), `
+---+------------+------+--------+-----------+
| | תאריך | סכום | מחלקה | תגים |
+---+------------+------+--------+-----------+
| 1 | 2020-01-01 | 5 | מחלקה1 | [תג1 תג2] |
| 2 | 2021-02-01 | 5 | מחלקה1 | [תג1] |
| 3 | 2022-03-01 | 5 | מחלקה2 | [תג1] |
+---+------------+------+--------+-----------+
| | סהכ | 30 | | |
+---+------------+------+--------+-----------+`)
}

View File

@ -0,0 +1,81 @@
package table
import (
"fmt"
"strings"
"unicode/utf8"
)
// RenderCSV renders the Table in CSV format. Example:
// #,First Name,Last Name,Salary,
// 1,Arya,Stark,3000,
// 20,Jon,Snow,2000,"You know nothing\, Jon Snow!"
// 300,Tyrion,Lannister,5000,
// ,,Total,10000,
func (t *Table) RenderCSV() string {
t.initForRender()
var out strings.Builder
if t.numColumns > 0 {
if t.title != "" {
out.WriteString(t.title)
}
if t.autoIndex && len(t.rowsHeader) == 0 {
t.csvRenderRow(&out, t.getAutoIndexColumnIDs(), renderHint{isAutoIndexRow: true, isHeaderRow: true})
}
t.csvRenderRows(&out, t.rowsHeader, renderHint{isHeaderRow: true})
t.csvRenderRows(&out, t.rows, renderHint{})
t.csvRenderRows(&out, t.rowsFooter, renderHint{isFooterRow: true})
if t.caption != "" {
out.WriteRune('\n')
out.WriteString(t.caption)
}
}
return t.render(&out)
}
func (t *Table) csvFixCommas(str string) string {
return strings.Replace(str, ",", "\\,", -1)
}
func (t *Table) csvFixDoubleQuotes(str string) string {
return strings.Replace(str, "\"", "\\\"", -1)
}
func (t *Table) csvRenderRow(out *strings.Builder, row rowStr, hint renderHint) {
// when working on line number 2 or more, insert a newline first
if out.Len() > 0 {
out.WriteRune('\n')
}
// generate the columns to render in CSV format and append to "out"
for colIdx, colStr := range row {
// auto-index column
if colIdx == 0 && t.autoIndex {
if hint.isRegularRow() {
out.WriteString(fmt.Sprint(hint.rowNumber))
}
out.WriteRune(',')
}
if colIdx > 0 {
out.WriteRune(',')
}
if strings.ContainsAny(colStr, "\",\n") {
out.WriteRune('"')
out.WriteString(t.csvFixCommas(t.csvFixDoubleQuotes(colStr)))
out.WriteRune('"')
} else if utf8.RuneCountInString(colStr) > 0 {
out.WriteString(colStr)
}
}
for colIdx := len(row); colIdx < t.numColumns; colIdx++ {
out.WriteRune(',')
}
}
func (t *Table) csvRenderRows(out *strings.Builder, rows []rowStr, hint renderHint) {
for rowIdx, row := range rows {
hint.rowNumber = rowIdx + 1
t.csvRenderRow(out, row, hint)
}
}

View File

@ -0,0 +1,139 @@
package table
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTable_RenderCSV(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendRow(testRowMultiLine)
tw.AppendRow(testRowTabs)
tw.AppendFooter(testFooter)
tw.SetCaption(testCaption)
tw.SetTitle(testTitle1)
compareOutput(t, tw.RenderCSV(), `
Game of Thrones
#,First Name,Last Name,Salary,
1,Arya,Stark,3000,
20,Jon,Snow,2000,"You know nothing\, Jon Snow!"
300,Tyrion,Lannister,5000,
0,Winter,Is,0,"Coming.
The North Remembers!
This is known."
0,Valar,Morghulis,0,Faceless Men
,,Total,10000,
A Song of Ice and Fire`)
}
func TestTable_RenderCSV_AutoIndex(t *testing.T) {
tw := NewWriter()
for rowIdx := 0; rowIdx < 10; rowIdx++ {
row := make(Row, 10)
for colIdx := 0; colIdx < 10; colIdx++ {
row[colIdx] = fmt.Sprintf("%s%d", AutoIndexColumnID(colIdx), rowIdx+1)
}
tw.AppendRow(row)
}
for rowIdx := 0; rowIdx < 1; rowIdx++ {
row := make(Row, 10)
for colIdx := 0; colIdx < 10; colIdx++ {
row[colIdx] = AutoIndexColumnID(colIdx) + "F"
}
tw.AppendFooter(row)
}
tw.SetAutoIndex(true)
tw.SetStyle(StyleLight)
compareOutput(t, tw.RenderCSV(), `
,A,B,C,D,E,F,G,H,I,J
1,A1,B1,C1,D1,E1,F1,G1,H1,I1,J1
2,A2,B2,C2,D2,E2,F2,G2,H2,I2,J2
3,A3,B3,C3,D3,E3,F3,G3,H3,I3,J3
4,A4,B4,C4,D4,E4,F4,G4,H4,I4,J4
5,A5,B5,C5,D5,E5,F5,G5,H5,I5,J5
6,A6,B6,C6,D6,E6,F6,G6,H6,I6,J6
7,A7,B7,C7,D7,E7,F7,G7,H7,I7,J7
8,A8,B8,C8,D8,E8,F8,G8,H8,I8,J8
9,A9,B9,C9,D9,E9,F9,G9,H9,I9,J9
10,A10,B10,C10,D10,E10,F10,G10,H10,I10,J10
,AF,BF,CF,DF,EF,FF,GF,HF,IF,JF`)
}
func TestTable_RenderCSV_Empty(t *testing.T) {
tw := NewWriter()
assert.Empty(t, tw.RenderCSV())
}
func TestTable_RenderCSV_HiddenColumns(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendFooter(testFooter)
// ensure sorting is done before hiding the columns
tw.SortBy([]SortBy{
{Name: "Salary", Mode: DscNumeric},
})
t.Run("every column hidden", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0, 1, 2, 3, 4}))
compareOutput(t, tw.RenderCSV(), "")
})
t.Run("first column hidden", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0}))
compareOutput(t, tw.RenderCSV(), `
First Name,Last Name,Salary,
>>Tyrion,Lannister<<,5013,
>>Arya,Stark<<,3013,
>>Jon,Snow<<,2013,"~You know nothing\, Jon Snow!~"
,Total,10000,`)
})
t.Run("column hidden in the middle", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{1}))
compareOutput(t, tw.RenderCSV(), `
#,Last Name,Salary,
307,Lannister<<,5013,
8,Stark<<,3013,
27,Snow<<,2013,"~You know nothing\, Jon Snow!~"
,Total,10000,`)
})
t.Run("last column hidden", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{4}))
compareOutput(t, tw.RenderCSV(), `
#,First Name,Last Name,Salary
307,>>Tyrion,Lannister<<,5013
8,>>Arya,Stark<<,3013
27,>>Jon,Snow<<,2013
,,Total,10000`)
})
}
func TestTable_RenderCSV_Sorted(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendRow(Row{11, "Sansa", "Stark", 6000})
tw.AppendFooter(testFooter)
tw.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Asc}})
compareOutput(t, tw.RenderCSV(), `
#,First Name,Last Name,Salary,
300,Tyrion,Lannister,5000,
20,Jon,Snow,2000,"You know nothing\, Jon Snow!"
1,Arya,Stark,3000,
11,Sansa,Stark,6000,
,,Total,10000,`)
}

View File

@ -0,0 +1,39 @@
package table
// renderHint has hints for the Render*() logic
type renderHint struct {
isAutoIndexColumn bool // auto-index column?
isAutoIndexRow bool // auto-index row?
isBorderBottom bool // bottom-border?
isBorderTop bool // top-border?
isFirstRow bool // first-row of header/footer/regular-rows?
isFooterRow bool // footer row?
isHeaderRow bool // header row?
isLastLineOfRow bool // last-line of the current row?
isLastRow bool // last-row of header/footer/regular-rows?
isSeparatorRow bool // separator row?
isTitleRow bool // title row?
rowLineNumber int // the line number for a multi-line row
rowNumber int // the row number/index
}
func (h *renderHint) isBorderOrSeparator() bool {
return h.isBorderTop || h.isSeparatorRow || h.isBorderBottom
}
func (h *renderHint) isRegularRow() bool {
return !h.isHeaderRow && !h.isFooterRow
}
func (h *renderHint) isRegularNonSeparatorRow() bool {
return !h.isHeaderRow && !h.isFooterRow && !h.isSeparatorRow
}
func (h *renderHint) isHeaderOrFooterSeparator() bool {
return h.isSeparatorRow && !h.isBorderBottom && !h.isBorderTop &&
((h.isHeaderRow && !h.isLastRow) || (h.isFooterRow && (!h.isFirstRow || h.rowNumber > 0)))
}
func (h *renderHint) isLastLineOfLastRow() bool {
return h.isLastLineOfRow && h.isLastRow
}

244
helper/table/render_html.go Normal file
View File

@ -0,0 +1,244 @@
package table
import (
"fmt"
"html"
"strings"
)
const (
// DefaultHTMLCSSClass stores the css-class to use when none-provided via
// SetHTMLCSSClass(cssClass string).
DefaultHTMLCSSClass = "go-pretty-table"
)
// RenderHTML renders the Table in HTML format. Example:
// <table class="go-pretty-table">
// <thead>
// <tr>
// <th align="right">#</th>
// <th>First Name</th>
// <th>Last Name</th>
// <th align="right">Salary</th>
// <th>&nbsp;</th>
// </tr>
// </thead>
// <tbody>
// <tr>
// <td align="right">1</td>
// <td>Arya</td>
// <td>Stark</td>
// <td align="right">3000</td>
// <td>&nbsp;</td>
// </tr>
// <tr>
// <td align="right">20</td>
// <td>Jon</td>
// <td>Snow</td>
// <td align="right">2000</td>
// <td>You know nothing, Jon Snow!</td>
// </tr>
// <tr>
// <td align="right">300</td>
// <td>Tyrion</td>
// <td>Lannister</td>
// <td align="right">5000</td>
// <td>&nbsp;</td>
// </tr>
// </tbody>
// <tfoot>
// <tr>
// <td align="right">&nbsp;</td>
// <td>&nbsp;</td>
// <td>Total</td>
// <td align="right">10000</td>
// <td>&nbsp;</td>
// </tr>
// </tfoot>
// </table>
func (t *Table) RenderHTML() string {
t.initForRender()
var out strings.Builder
if t.numColumns > 0 {
out.WriteString("<table class=\"")
if t.htmlCSSClass != "" {
out.WriteString(t.htmlCSSClass)
} else {
out.WriteString(t.style.HTML.CSSClass)
}
out.WriteString("\">\n")
t.htmlRenderTitle(&out)
t.htmlRenderRowsHeader(&out)
t.htmlRenderRows(&out, t.rows, renderHint{})
t.htmlRenderRowsFooter(&out)
t.htmlRenderCaption(&out)
out.WriteString("</table>")
}
return t.render(&out)
}
func (t *Table) htmlGetColStrAndTag(row rowStr, colIdx int, hint renderHint) (string, string) {
// get the column contents
var colStr string
if colIdx < len(row) {
colStr = row[colIdx]
}
// header uses "th" instead of "td"
colTagName := "td"
if hint.isHeaderRow {
colTagName = "th"
}
return colStr, colTagName
}
func (t *Table) htmlRenderCaption(out *strings.Builder) {
if t.caption != "" {
out.WriteString(" <caption class=\"caption\" style=\"caption-side: bottom;\">")
out.WriteString(t.caption)
out.WriteString("</caption>\n")
}
}
func (t *Table) htmlRenderColumn(out *strings.Builder, colStr string) {
if t.style.HTML.EscapeText {
colStr = html.EscapeString(colStr)
}
if t.style.HTML.Newline != "\n" {
colStr = strings.Replace(colStr, "\n", t.style.HTML.Newline, -1)
}
out.WriteString(colStr)
}
func (t *Table) htmlRenderColumnAttributes(out *strings.Builder, row rowStr, colIdx int, hint renderHint) {
// determine the HTML "align"/"valign" property values
align := t.getAlign(colIdx, hint).HTMLProperty()
vAlign := t.getVAlign(colIdx, hint).HTMLProperty()
// determine the HTML "class" property values for the colors
class := t.getColumnColors(colIdx, hint).HTMLProperty()
if align != "" {
out.WriteRune(' ')
out.WriteString(align)
}
if class != "" {
out.WriteRune(' ')
out.WriteString(class)
}
if vAlign != "" {
out.WriteRune(' ')
out.WriteString(vAlign)
}
}
func (t *Table) htmlRenderColumnAutoIndex(out *strings.Builder, hint renderHint) {
if hint.isHeaderRow {
out.WriteString(" <th>")
out.WriteString(t.style.HTML.EmptyColumn)
out.WriteString("</th>\n")
} else if hint.isFooterRow {
out.WriteString(" <td>")
out.WriteString(t.style.HTML.EmptyColumn)
out.WriteString("</td>\n")
} else {
out.WriteString(" <td align=\"right\">")
out.WriteString(fmt.Sprint(hint.rowNumber))
out.WriteString("</td>\n")
}
}
func (t *Table) htmlRenderRow(out *strings.Builder, row rowStr, hint renderHint) {
out.WriteString(" <tr>\n")
for colIdx := 0; colIdx < t.numColumns; colIdx++ {
// auto-index column
if colIdx == 0 && t.autoIndex {
t.htmlRenderColumnAutoIndex(out, hint)
}
colStr, colTagName := t.htmlGetColStrAndTag(row, colIdx, hint)
// write the row
out.WriteString(" <")
out.WriteString(colTagName)
t.htmlRenderColumnAttributes(out, row, colIdx, hint)
out.WriteString(">")
if len(colStr) == 0 {
out.WriteString(t.style.HTML.EmptyColumn)
} else {
t.htmlRenderColumn(out, colStr)
}
out.WriteString("</")
out.WriteString(colTagName)
out.WriteString(">\n")
}
out.WriteString(" </tr>\n")
}
func (t *Table) htmlRenderRows(out *strings.Builder, rows []rowStr, hint renderHint) {
if len(rows) > 0 {
// determine that tag to use based on the type of the row
rowsTag := "tbody"
if hint.isHeaderRow {
rowsTag = "thead"
} else if hint.isFooterRow {
rowsTag = "tfoot"
}
var renderedTagOpen, shouldRenderTagClose bool
for idx, row := range rows {
hint.rowNumber = idx + 1
if len(row) > 0 {
if !renderedTagOpen {
out.WriteString(" <")
out.WriteString(rowsTag)
out.WriteString(">\n")
renderedTagOpen = true
}
t.htmlRenderRow(out, row, hint)
shouldRenderTagClose = true
}
}
if shouldRenderTagClose {
out.WriteString(" </")
out.WriteString(rowsTag)
out.WriteString(">\n")
}
}
}
func (t *Table) htmlRenderRowsFooter(out *strings.Builder) {
if len(t.rowsFooter) > 0 {
t.htmlRenderRows(out, t.rowsFooter, renderHint{isFooterRow: true})
}
}
func (t *Table) htmlRenderRowsHeader(out *strings.Builder) {
if len(t.rowsHeader) > 0 {
t.htmlRenderRows(out, t.rowsHeader, renderHint{isHeaderRow: true})
} else if t.autoIndex {
hint := renderHint{isAutoIndexRow: true, isHeaderRow: true}
t.htmlRenderRows(out, []rowStr{t.getAutoIndexColumnIDs()}, hint)
}
}
func (t *Table) htmlRenderTitle(out *strings.Builder) {
if t.title != "" {
align := t.style.Title.Align.HTMLProperty()
colors := t.style.Title.Colors.HTMLProperty()
title := t.style.Title.Format.Apply(t.title)
out.WriteString(" <caption class=\"title\"")
if align != "" {
out.WriteRune(' ')
out.WriteString(align)
}
if colors != "" {
out.WriteRune(' ')
out.WriteString(colors)
}
out.WriteRune('>')
out.WriteString(title)
out.WriteString("</caption>\n")
}
}

View File

@ -0,0 +1,519 @@
package table
import (
"fmt"
"testing"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/stretchr/testify/assert"
)
func TestTable_RenderHTML(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendRow(testRowMultiLine)
tw.AppendFooter(testFooter)
tw.SetColumnConfigs([]ColumnConfig{
{Name: "Salary", VAlign: text.VAlignBottom},
{Number: 5, VAlign: text.VAlignBottom},
})
tw.SetTitle(testTitle1)
tw.SetCaption(testCaption)
tw.Style().Title = TitleOptions{
Align: text.AlignLeft,
Colors: text.Colors{text.BgBlack, text.Bold, text.FgHiBlue},
Format: text.FormatTitle,
}
compareOutput(t, tw.RenderHTML(), `
<table class="go-pretty-table">
<caption class="title" align="left" class="bg-black bold fg-hi-blue">Game Of Thrones</caption>
<thead>
<tr>
<th align="right">#</th>
<th>First Name</th>
<th>Last Name</th>
<th align="right">Salary</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">1</td>
<td>Arya</td>
<td>Stark</td>
<td align="right" valign="bottom">3000</td>
<td valign="bottom">&nbsp;</td>
</tr>
<tr>
<td align="right">20</td>
<td>Jon</td>
<td>Snow</td>
<td align="right" valign="bottom">2000</td>
<td valign="bottom">You know nothing, Jon Snow!</td>
</tr>
<tr>
<td align="right">300</td>
<td>Tyrion</td>
<td>Lannister</td>
<td align="right" valign="bottom">5000</td>
<td valign="bottom">&nbsp;</td>
</tr>
<tr>
<td align="right">0</td>
<td>Winter</td>
<td>Is</td>
<td align="right" valign="bottom">0</td>
<td valign="bottom">Coming.<br/>The North Remembers!<br/>This is known.</td>
</tr>
</tbody>
<tfoot>
<tr>
<td align="right">&nbsp;</td>
<td>&nbsp;</td>
<td>Total</td>
<td align="right">10000</td>
<td>&nbsp;</td>
</tr>
</tfoot>
<caption class="caption" style="caption-side: bottom;">A Song of Ice and Fire</caption>
</table>`)
}
func TestTable_RenderHTML_AutoIndex(t *testing.T) {
tw := NewWriter()
for rowIdx := 0; rowIdx < 3; rowIdx++ {
row := make(Row, 3)
for colIdx := 0; colIdx < 3; colIdx++ {
row[colIdx] = fmt.Sprintf("%s%d", AutoIndexColumnID(colIdx), rowIdx+1)
}
tw.AppendRow(row)
}
for rowIdx := 0; rowIdx < 1; rowIdx++ {
row := make(Row, 3)
for colIdx := 0; colIdx < 3; colIdx++ {
row[colIdx] = AutoIndexColumnID(colIdx) + "F"
}
tw.AppendFooter(row)
}
tw.SetOutputMirror(nil)
tw.SetAutoIndex(true)
tw.SetStyle(StyleLight)
compareOutput(t, tw.RenderHTML(), `
<table class="go-pretty-table">
<thead>
<tr>
<th>&nbsp;</th>
<th align="center">A</th>
<th align="center">B</th>
<th align="center">C</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">1</td>
<td>A1</td>
<td>B1</td>
<td>C1</td>
</tr>
<tr>
<td align="right">2</td>
<td>A2</td>
<td>B2</td>
<td>C2</td>
</tr>
<tr>
<td align="right">3</td>
<td>A3</td>
<td>B3</td>
<td>C3</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>&nbsp;</td>
<td>AF</td>
<td>BF</td>
<td>CF</td>
</tr>
</tfoot>
</table>`)
}
func TestTable_RenderHTML_Colored(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendRow(testRowMultiLine)
tw.AppendFooter(testFooter)
tw.SetCaption(testCaption)
tw.SetTitle(testTitle1)
tw.Style().HTML.CSSClass = "go-pretty-table-colored"
colorsBlackOnWhite := text.Colors{text.BgWhite, text.FgBlack}
tw.SetColumnConfigs([]ColumnConfig{
{
Name: "#",
Colors: text.Colors{text.Bold},
ColorsHeader: colorsBlackOnWhite,
}, {
Name: "First Name",
Colors: text.Colors{text.FgCyan},
ColorsHeader: colorsBlackOnWhite,
}, {
Name: "Last Name",
Colors: text.Colors{text.FgMagenta},
ColorsHeader: colorsBlackOnWhite,
ColorsFooter: colorsBlackOnWhite,
}, {
Name: "Salary",
Colors: text.Colors{text.FgYellow},
ColorsHeader: colorsBlackOnWhite,
ColorsFooter: colorsBlackOnWhite,
VAlign: text.VAlignBottom,
}, {
Number: 5,
Colors: text.Colors{text.FgBlack},
ColorsHeader: colorsBlackOnWhite,
VAlign: text.VAlignBottom,
},
})
compareOutput(t, tw.RenderHTML(), `
<table class="go-pretty-table-colored">
<caption class="title">Game of Thrones</caption>
<thead>
<tr>
<th align="right" class="bg-white fg-black">#</th>
<th class="bg-white fg-black">First Name</th>
<th class="bg-white fg-black">Last Name</th>
<th align="right" class="bg-white fg-black">Salary</th>
<th class="bg-white fg-black">&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right" class="bold">1</td>
<td class="fg-cyan">Arya</td>
<td class="fg-magenta">Stark</td>
<td align="right" class="fg-yellow" valign="bottom">3000</td>
<td class="fg-black" valign="bottom">&nbsp;</td>
</tr>
<tr>
<td align="right" class="bold">20</td>
<td class="fg-cyan">Jon</td>
<td class="fg-magenta">Snow</td>
<td align="right" class="fg-yellow" valign="bottom">2000</td>
<td class="fg-black" valign="bottom">You know nothing, Jon Snow!</td>
</tr>
<tr>
<td align="right" class="bold">300</td>
<td class="fg-cyan">Tyrion</td>
<td class="fg-magenta">Lannister</td>
<td align="right" class="fg-yellow" valign="bottom">5000</td>
<td class="fg-black" valign="bottom">&nbsp;</td>
</tr>
<tr>
<td align="right" class="bold">0</td>
<td class="fg-cyan">Winter</td>
<td class="fg-magenta">Is</td>
<td align="right" class="fg-yellow" valign="bottom">0</td>
<td class="fg-black" valign="bottom">Coming.<br/>The North Remembers!<br/>This is known.</td>
</tr>
</tbody>
<tfoot>
<tr>
<td align="right">&nbsp;</td>
<td>&nbsp;</td>
<td class="bg-white fg-black">Total</td>
<td align="right" class="bg-white fg-black">10000</td>
<td>&nbsp;</td>
</tr>
</tfoot>
<caption class="caption" style="caption-side: bottom;">A Song of Ice and Fire</caption>
</table>`)
}
func TestTable_RenderHTML_CustomStyle(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRow(Row{1, "Arya", "Stark", 3000, "<a href=\"https://duckduckgo.com/?q=arya+stark+not+today\">Not today.</a>"})
tw.AppendRow(Row{1, "Jon", "Snow", 2000, "You know\nnothing,\nJon Snow!"})
tw.AppendRow(Row{300, "Tyrion", "Lannister", 5000})
tw.AppendFooter(testFooter)
tw.SetAutoIndex(true)
tw.Style().HTML = HTMLOptions{
CSSClass: "game-of-thrones",
EmptyColumn: "<!-- test -->&nbsp;",
EscapeText: false,
Newline: "<!-- newline -->",
}
tw.SetOutputMirror(nil)
compareOutput(t, tw.RenderHTML(), `
<table class="game-of-thrones">
<thead>
<tr>
<th><!-- test -->&nbsp;</th>
<th align="right">#</th>
<th>First Name</th>
<th>Last Name</th>
<th align="right">Salary</th>
<th><!-- test -->&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">1</td>
<td align="right">1</td>
<td>Arya</td>
<td>Stark</td>
<td align="right">3000</td>
<td><a href="https://duckduckgo.com/?q=arya+stark+not+today">Not today.</a></td>
</tr>
<tr>
<td align="right">2</td>
<td align="right">1</td>
<td>Jon</td>
<td>Snow</td>
<td align="right">2000</td>
<td>You know<!-- newline -->nothing,<!-- newline -->Jon Snow!</td>
</tr>
<tr>
<td align="right">3</td>
<td align="right">300</td>
<td>Tyrion</td>
<td>Lannister</td>
<td align="right">5000</td>
<td><!-- test -->&nbsp;</td>
</tr>
</tbody>
<tfoot>
<tr>
<td><!-- test -->&nbsp;</td>
<td align="right"><!-- test -->&nbsp;</td>
<td><!-- test -->&nbsp;</td>
<td>Total</td>
<td align="right">10000</td>
<td><!-- test -->&nbsp;</td>
</tr>
</tfoot>
</table>`)
}
func TestTable_RenderHTML_Empty(t *testing.T) {
tw := NewWriter()
assert.Empty(t, tw.RenderHTML())
}
func TestTable_RenderHTML_HiddenColumns(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendFooter(testFooter)
// ensure sorting is done before hiding the columns
tw.SortBy([]SortBy{
{Name: "Salary", Mode: DscNumeric},
})
t.Run("every column hidden", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0, 1, 2, 3, 4}))
compareOutput(t, tw.RenderHTML(), "")
})
t.Run("first column hidden", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0}))
compareOutput(t, tw.RenderHTML(), `
<table class="go-pretty-table">
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th align="right">Salary</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<td>&gt;&gt;Tyrion</td>
<td>Lannister&lt;&lt;</td>
<td align="right">5013</td>
<td>&nbsp;</td>
</tr>
<tr>
<td>&gt;&gt;Arya</td>
<td>Stark&lt;&lt;</td>
<td align="right">3013</td>
<td>&nbsp;</td>
</tr>
<tr>
<td>&gt;&gt;Jon</td>
<td>Snow&lt;&lt;</td>
<td align="right">2013</td>
<td>~You know nothing, Jon Snow!~</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>&nbsp;</td>
<td>Total</td>
<td align="right">10000</td>
<td>&nbsp;</td>
</tr>
</tfoot>
</table>`)
})
t.Run("column hidden in the middle", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{1}))
compareOutput(t, tw.RenderHTML(), `
<table class="go-pretty-table">
<thead>
<tr>
<th align="right">#</th>
<th>Last Name</th>
<th align="right">Salary</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">307</td>
<td>Lannister&lt;&lt;</td>
<td align="right">5013</td>
<td>&nbsp;</td>
</tr>
<tr>
<td align="right">8</td>
<td>Stark&lt;&lt;</td>
<td align="right">3013</td>
<td>&nbsp;</td>
</tr>
<tr>
<td align="right">27</td>
<td>Snow&lt;&lt;</td>
<td align="right">2013</td>
<td>~You know nothing, Jon Snow!~</td>
</tr>
</tbody>
<tfoot>
<tr>
<td align="right">&nbsp;</td>
<td>Total</td>
<td align="right">10000</td>
<td>&nbsp;</td>
</tr>
</tfoot>
</table>`)
})
t.Run("last column hidden", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{4}))
compareOutput(t, tw.RenderHTML(), `
<table class="go-pretty-table">
<thead>
<tr>
<th align="right">#</th>
<th>First Name</th>
<th>Last Name</th>
<th align="right">Salary</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">307</td>
<td>&gt;&gt;Tyrion</td>
<td>Lannister&lt;&lt;</td>
<td align="right">5013</td>
</tr>
<tr>
<td align="right">8</td>
<td>&gt;&gt;Arya</td>
<td>Stark&lt;&lt;</td>
<td align="right">3013</td>
</tr>
<tr>
<td align="right">27</td>
<td>&gt;&gt;Jon</td>
<td>Snow&lt;&lt;</td>
<td align="right">2013</td>
</tr>
</tbody>
<tfoot>
<tr>
<td align="right">&nbsp;</td>
<td>&nbsp;</td>
<td>Total</td>
<td align="right">10000</td>
</tr>
</tfoot>
</table>`)
})
}
func TestTable_RenderHTML_Sorted(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendRow(Row{11, "Sansa", "Stark", 6000})
tw.AppendFooter(testFooter)
tw.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Asc}})
compareOutput(t, tw.RenderHTML(), `
<table class="go-pretty-table">
<thead>
<tr>
<th align="right">#</th>
<th>First Name</th>
<th>Last Name</th>
<th align="right">Salary</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">300</td>
<td>Tyrion</td>
<td>Lannister</td>
<td align="right">5000</td>
<td>&nbsp;</td>
</tr>
<tr>
<td align="right">20</td>
<td>Jon</td>
<td>Snow</td>
<td align="right">2000</td>
<td>You know nothing, Jon Snow!</td>
</tr>
<tr>
<td align="right">1</td>
<td>Arya</td>
<td>Stark</td>
<td align="right">3000</td>
<td>&nbsp;</td>
</tr>
<tr>
<td align="right">11</td>
<td>Sansa</td>
<td>Stark</td>
<td align="right">6000</td>
<td>&nbsp;</td>
</tr>
</tbody>
<tfoot>
<tr>
<td align="right">&nbsp;</td>
<td>&nbsp;</td>
<td>Total</td>
<td align="right">10000</td>
<td>&nbsp;</td>
</tr>
</tfoot>
</table>`)
}

294
helper/table/render_init.go Normal file
View File

@ -0,0 +1,294 @@
package table
import (
"fmt"
"strings"
"git.hexq.cn/tiglog/golib/helper/text"
)
func (t *Table) analyzeAndStringify(row Row, hint renderHint) rowStr {
// update t.numColumns if this row is the longest seen till now
if len(row) > t.numColumns {
// init the slice for the first time; and pad it the rest of the time
if t.numColumns == 0 {
t.columnIsNonNumeric = make([]bool, len(row))
} else {
t.columnIsNonNumeric = append(t.columnIsNonNumeric, make([]bool, len(row)-t.numColumns)...)
}
// update t.numColumns
t.numColumns = len(row)
}
// convert each column to string and figure out if it has non-numeric data
rowOut := make(rowStr, len(row))
for colIdx, col := range row {
// if the column is not a number, keep track of it
if !hint.isHeaderRow && !hint.isFooterRow && !t.columnIsNonNumeric[colIdx] && !isNumber(col) {
t.columnIsNonNumeric[colIdx] = true
}
rowOut[colIdx] = t.analyzeAndStringifyColumn(colIdx, col, hint)
}
return rowOut
}
func (t *Table) analyzeAndStringifyColumn(colIdx int, col interface{}, hint renderHint) string {
// convert to a string and store it in the row
var colStr string
if transformer := t.getColumnTransformer(colIdx, hint); transformer != nil {
colStr = transformer(col)
} else if colStrVal, ok := col.(string); ok {
colStr = colStrVal
} else {
colStr = fmt.Sprint(col)
}
if strings.Contains(colStr, "\t") {
colStr = strings.Replace(colStr, "\t", " ", -1)
}
if strings.Contains(colStr, "\r") {
colStr = strings.Replace(colStr, "\r", "", -1)
}
return fmt.Sprintf("%s%s", t.style.Format.Direction.Modifier(), colStr)
}
func (t *Table) extractMaxColumnLengths(rows []rowStr, hint renderHint) {
for rowIdx, row := range rows {
hint.rowNumber = rowIdx + 1
t.extractMaxColumnLengthsFromRow(row, t.getMergedColumnIndices(row, hint))
}
}
func (t *Table) extractMaxColumnLengthsFromRow(row rowStr, mci mergedColumnIndices) {
for colIdx, colStr := range row {
longestLineLen := text.LongestLineLen(colStr)
maxColWidth := t.getColumnWidthMax(colIdx)
if maxColWidth > 0 && maxColWidth < longestLineLen {
longestLineLen = maxColWidth
}
mergedColumnsLength := mci.mergedLength(colIdx, t.maxColumnLengths)
if longestLineLen > mergedColumnsLength {
if mergedColumnsLength > 0 {
t.extractMaxColumnLengthsFromRowForMergedColumns(colIdx, longestLineLen, mci)
} else {
t.maxColumnLengths[colIdx] = longestLineLen
}
} else if maxColWidth == 0 && longestLineLen > t.maxColumnLengths[colIdx] {
t.maxColumnLengths[colIdx] = longestLineLen
}
}
}
func (t *Table) extractMaxColumnLengthsFromRowForMergedColumns(colIdx int, mergedColumnLength int, mci mergedColumnIndices) {
numMergedColumns := mci.len(colIdx)
mergedColumnLength -= (numMergedColumns - 1) * text.RuneWidthWithoutEscSequences(t.style.Box.MiddleSeparator)
maxLengthSplitAcrossColumns := mergedColumnLength / numMergedColumns
if maxLengthSplitAcrossColumns > t.maxColumnLengths[colIdx] {
t.maxColumnLengths[colIdx] = maxLengthSplitAcrossColumns
}
for otherColIdx := range mci[colIdx] {
if maxLengthSplitAcrossColumns > t.maxColumnLengths[otherColIdx] {
t.maxColumnLengths[otherColIdx] = maxLengthSplitAcrossColumns
}
}
}
func (t *Table) initForRender() {
// pick a default style if none was set until now
t.Style()
// reset rendering state
t.reset()
// initialize the column configs and normalize them
t.initForRenderColumnConfigs()
// initialize and stringify all the raw rows
t.initForRenderRows()
// find the longest continuous line in each column
t.initForRenderColumnLengths()
// generate a separator row and calculate maximum row length
t.initForRenderRowSeparator()
// reset the counter for the number of lines rendered
t.numLinesRendered = 0
}
func (t *Table) initForRenderColumnConfigs() {
t.columnConfigMap = map[int]ColumnConfig{}
for _, colCfg := range t.columnConfigs {
// find the column number if none provided; this logic can work only if
// a header row is present and has a column with the given name
if colCfg.Number == 0 {
for _, row := range t.rowsHeaderRaw {
colCfg.Number = row.findColumnNumber(colCfg.Name)
if colCfg.Number > 0 {
break
}
}
}
if colCfg.Number > 0 {
t.columnConfigMap[colCfg.Number-1] = colCfg
}
}
}
func (t *Table) initForRenderColumnLengths() {
t.maxColumnLengths = make([]int, t.numColumns)
t.extractMaxColumnLengths(t.rowsHeader, renderHint{isHeaderRow: true})
t.extractMaxColumnLengths(t.rows, renderHint{})
t.extractMaxColumnLengths(t.rowsFooter, renderHint{isFooterRow: true})
// increase the column lengths if any are under the limits
for colIdx := range t.maxColumnLengths {
minWidth := t.getColumnWidthMin(colIdx)
if minWidth > 0 && t.maxColumnLengths[colIdx] < minWidth {
t.maxColumnLengths[colIdx] = minWidth
}
}
}
func (t *Table) initForRenderHideColumns() {
if !t.hasHiddenColumns() {
return
}
colIdxMap := t.hideColumns()
// re-create columnIsNonNumeric with new column indices
columnIsNonNumeric := make([]bool, t.numColumns)
for oldColIdx, nonNumeric := range t.columnIsNonNumeric {
if newColIdx, ok := colIdxMap[oldColIdx]; ok {
columnIsNonNumeric[newColIdx] = nonNumeric
}
}
t.columnIsNonNumeric = columnIsNonNumeric
// re-create columnConfigMap with new column indices
columnConfigMap := make(map[int]ColumnConfig)
for oldColIdx, cc := range t.columnConfigMap {
if newColIdx, ok := colIdxMap[oldColIdx]; ok {
columnConfigMap[newColIdx] = cc
}
}
t.columnConfigMap = columnConfigMap
}
func (t *Table) initForRenderRows() {
// auto-index: calc the index column's max length
t.autoIndexVIndexMaxLength = len(fmt.Sprint(len(t.rowsRaw)))
// stringify all the rows to make it easy to render
if t.rowPainter != nil {
t.rowsColors = make([]text.Colors, len(t.rowsRaw))
}
t.rows = t.initForRenderRowsStringify(t.rowsRaw, renderHint{})
t.rowsFooter = t.initForRenderRowsStringify(t.rowsFooterRaw, renderHint{isFooterRow: true})
t.rowsHeader = t.initForRenderRowsStringify(t.rowsHeaderRaw, renderHint{isHeaderRow: true})
// sort the rows as requested
t.initForRenderSortRows()
// suppress columns without any content
t.initForRenderSuppressColumns()
// strip out hidden columns
t.initForRenderHideColumns()
}
func (t *Table) initForRenderRowsStringify(rows []Row, hint renderHint) []rowStr {
rowsStr := make([]rowStr, len(rows))
for idx, row := range rows {
if t.rowPainter != nil && hint.isRegularRow() {
t.rowsColors[idx] = t.rowPainter(row)
}
rowsStr[idx] = t.analyzeAndStringify(row, hint)
}
return rowsStr
}
func (t *Table) initForRenderRowSeparator() {
t.maxRowLength = 0
if t.autoIndex {
t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft)
t.maxRowLength += len(fmt.Sprint(len(t.rows)))
t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingRight)
if t.style.Options.SeparateColumns {
t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.MiddleSeparator)
}
}
if t.style.Options.SeparateColumns {
t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.MiddleSeparator) * (t.numColumns - 1)
}
t.rowSeparator = make(rowStr, t.numColumns)
for colIdx, maxColumnLength := range t.maxColumnLengths {
maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight)
t.maxRowLength += maxColumnLength
t.rowSeparator[colIdx] = text.RepeatAndTrim(t.style.Box.MiddleHorizontal, maxColumnLength)
}
if t.style.Options.DrawBorder {
t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.Left + t.style.Box.Right)
}
}
func (t *Table) initForRenderSortRows() {
if len(t.sortBy) == 0 {
return
}
// sort the rows
sortedRowIndices := t.getSortedRowIndices()
sortedRows := make([]rowStr, len(t.rows))
for idx := range t.rows {
sortedRows[idx] = t.rows[sortedRowIndices[idx]]
}
t.rows = sortedRows
// sort the rowsColors
if len(t.rowsColors) > 0 {
sortedRowsColors := make([]text.Colors, len(t.rows))
for idx := range t.rows {
sortedRowsColors[idx] = t.rowsColors[sortedRowIndices[idx]]
}
t.rowsColors = sortedRowsColors
}
}
func (t *Table) initForRenderSuppressColumns() {
shouldSuppressColumn := func(colIdx int) bool {
for _, row := range t.rows {
if colIdx < len(row) && row[colIdx] != "" {
return false
}
}
return true
}
if t.suppressEmptyColumns {
for colIdx := 0; colIdx < t.numColumns; colIdx++ {
if shouldSuppressColumn(colIdx) {
cc := t.columnConfigMap[colIdx]
cc.Hidden = true
t.columnConfigMap[colIdx] = cc
}
}
}
}
// reset initializes all the variables used to maintain rendering information
// that are written to in this file
func (t *Table) reset() {
t.autoIndexVIndexMaxLength = 0
t.columnConfigMap = nil
t.columnIsNonNumeric = nil
t.maxColumnLengths = nil
t.maxRowLength = 0
t.numColumns = 0
t.numLinesRendered = 0
t.rowSeparator = nil
t.rows = nil
t.rowsColors = nil
t.rowsFooter = nil
t.rowsHeader = nil
}

View File

@ -0,0 +1,113 @@
package table
import (
"fmt"
"strings"
)
// RenderMarkdown renders the Table in Markdown format. Example:
// | # | First Name | Last Name | Salary | |
// | ---:| --- | --- | ---:| --- |
// | 1 | Arya | Stark | 3000 | |
// | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
// | 300 | Tyrion | Lannister | 5000 | |
// | | | Total | 10000 | |
func (t *Table) RenderMarkdown() string {
t.initForRender()
var out strings.Builder
if t.numColumns > 0 {
t.markdownRenderTitle(&out)
t.markdownRenderRowsHeader(&out)
t.markdownRenderRows(&out, t.rows, renderHint{})
t.markdownRenderRowsFooter(&out)
t.markdownRenderCaption(&out)
}
return t.render(&out)
}
func (t *Table) markdownRenderCaption(out *strings.Builder) {
if t.caption != "" {
out.WriteRune('\n')
out.WriteRune('_')
out.WriteString(t.caption)
out.WriteRune('_')
}
}
func (t *Table) markdownRenderRow(out *strings.Builder, row rowStr, hint renderHint) {
// when working on line number 2 or more, insert a newline first
if out.Len() > 0 {
out.WriteRune('\n')
}
// render each column up to the max. columns seen in all the rows
out.WriteRune('|')
for colIdx := 0; colIdx < t.numColumns; colIdx++ {
t.markdownRenderRowAutoIndex(out, colIdx, hint)
if hint.isSeparatorRow {
out.WriteString(t.getAlign(colIdx, hint).MarkdownProperty())
} else {
var colStr string
if colIdx < len(row) {
colStr = row[colIdx]
}
out.WriteRune(' ')
if strings.Contains(colStr, "|") {
colStr = strings.Replace(colStr, "|", "\\|", -1)
}
if strings.Contains(colStr, "\n") {
colStr = strings.Replace(colStr, "\n", "<br/>", -1)
}
out.WriteString(colStr)
out.WriteRune(' ')
}
out.WriteRune('|')
}
}
func (t *Table) markdownRenderRowAutoIndex(out *strings.Builder, colIdx int, hint renderHint) {
if colIdx == 0 && t.autoIndex {
out.WriteRune(' ')
if hint.isSeparatorRow {
out.WriteString("---:")
} else if hint.isRegularRow() {
out.WriteString(fmt.Sprintf("%d ", hint.rowNumber))
}
out.WriteRune('|')
}
}
func (t *Table) markdownRenderRows(out *strings.Builder, rows []rowStr, hint renderHint) {
if len(rows) > 0 {
for idx, row := range rows {
hint.rowNumber = idx + 1
t.markdownRenderRow(out, row, hint)
if idx == len(rows)-1 && hint.isHeaderRow {
t.markdownRenderRow(out, t.rowSeparator, renderHint{isSeparatorRow: true})
}
}
}
}
func (t *Table) markdownRenderRowsFooter(out *strings.Builder) {
t.markdownRenderRows(out, t.rowsFooter, renderHint{isFooterRow: true})
}
func (t *Table) markdownRenderRowsHeader(out *strings.Builder) {
if len(t.rowsHeader) > 0 {
t.markdownRenderRows(out, t.rowsHeader, renderHint{isHeaderRow: true})
} else if t.autoIndex {
t.markdownRenderRows(out, []rowStr{t.getAutoIndexColumnIDs()}, renderHint{isAutoIndexRow: true, isHeaderRow: true})
}
}
func (t *Table) markdownRenderTitle(out *strings.Builder) {
if t.title != "" {
out.WriteString("# ")
out.WriteString(t.title)
}
}

View File

@ -0,0 +1,143 @@
package table
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTable_RenderMarkdown(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendRow(testRowNewLines)
tw.AppendRow(testRowPipes)
tw.AppendFooter(testFooter)
tw.SetCaption(testCaption)
tw.SetTitle(testTitle1)
compareOutput(t, tw.RenderMarkdown(), `
# Game of Thrones
| # | First Name | Last Name | Salary | |
| ---:| --- | --- | ---:| --- |
| 1 | Arya | Stark | 3000 | |
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
| 300 | Tyrion | Lannister | 5000 | |
| 0 | Valar | Morghulis | 0 | Faceless<br/>Men |
| 0 | Valar | Morghulis | 0 | Faceless\|Men |
| | | Total | 10000 | |
_A Song of Ice and Fire_`)
}
func TestTable_RenderMarkdown_AutoIndex(t *testing.T) {
tw := NewWriter()
for rowIdx := 0; rowIdx < 10; rowIdx++ {
row := make(Row, 10)
for colIdx := 0; colIdx < 10; colIdx++ {
row[colIdx] = fmt.Sprintf("%s%d", AutoIndexColumnID(colIdx), rowIdx+1)
}
tw.AppendRow(row)
}
for rowIdx := 0; rowIdx < 1; rowIdx++ {
row := make(Row, 10)
for colIdx := 0; colIdx < 10; colIdx++ {
row[colIdx] = AutoIndexColumnID(colIdx) + "F"
}
tw.AppendFooter(row)
}
tw.SetAutoIndex(true)
tw.SetStyle(StyleLight)
compareOutput(t, tw.RenderMarkdown(), `
| | A | B | C | D | E | F | G | H | I | J |
| ---:| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | A1 | B1 | C1 | D1 | E1 | F1 | G1 | H1 | I1 | J1 |
| 2 | A2 | B2 | C2 | D2 | E2 | F2 | G2 | H2 | I2 | J2 |
| 3 | A3 | B3 | C3 | D3 | E3 | F3 | G3 | H3 | I3 | J3 |
| 4 | A4 | B4 | C4 | D4 | E4 | F4 | G4 | H4 | I4 | J4 |
| 5 | A5 | B5 | C5 | D5 | E5 | F5 | G5 | H5 | I5 | J5 |
| 6 | A6 | B6 | C6 | D6 | E6 | F6 | G6 | H6 | I6 | J6 |
| 7 | A7 | B7 | C7 | D7 | E7 | F7 | G7 | H7 | I7 | J7 |
| 8 | A8 | B8 | C8 | D8 | E8 | F8 | G8 | H8 | I8 | J8 |
| 9 | A9 | B9 | C9 | D9 | E9 | F9 | G9 | H9 | I9 | J9 |
| 10 | A10 | B10 | C10 | D10 | E10 | F10 | G10 | H10 | I10 | J10 |
| | AF | BF | CF | DF | EF | FF | GF | HF | IF | JF |`)
}
func TestTable_RenderMarkdown_Empty(t *testing.T) {
tw := NewWriter()
assert.Empty(t, tw.RenderMarkdown())
}
func TestTable_RenderMarkdown_HiddenColumns(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendFooter(testFooter)
// ensure sorting is done before hiding the columns
tw.SortBy([]SortBy{
{Name: "Salary", Mode: DscNumeric},
})
t.Run("every column hidden", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0, 1, 2, 3, 4}))
compareOutput(t, tw.RenderMarkdown(), "")
})
t.Run("first column hidden", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{0}))
compareOutput(t, tw.RenderMarkdown(), `
| First Name | Last Name | Salary | |
| --- | --- | ---:| --- |
| >>Tyrion | Lannister<< | 5013 | |
| >>Arya | Stark<< | 3013 | |
| >>Jon | Snow<< | 2013 | ~You know nothing, Jon Snow!~ |
| | Total | 10000 | |`)
})
t.Run("column hidden in the middle", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{1}))
compareOutput(t, tw.RenderMarkdown(), `
| # | Last Name | Salary | |
| ---:| --- | ---:| --- |
| 307 | Lannister<< | 5013 | |
| 8 | Stark<< | 3013 | |
| 27 | Snow<< | 2013 | ~You know nothing, Jon Snow!~ |
| | Total | 10000 | |`)
})
t.Run("last column hidden", func(t *testing.T) {
tw.SetColumnConfigs(generateColumnConfigsWithHiddenColumns([]int{4}))
compareOutput(t, tw.RenderMarkdown(), `
| # | First Name | Last Name | Salary |
| ---:| --- | --- | ---:|
| 307 | >>Tyrion | Lannister<< | 5013 |
| 8 | >>Arya | Stark<< | 3013 |
| 27 | >>Jon | Snow<< | 2013 |
| | | Total | 10000 |`)
})
}
func TestTable_RendeMarkdown_Sorted(t *testing.T) {
tw := NewWriter()
tw.AppendHeader(testHeader)
tw.AppendRows(testRows)
tw.AppendRow(Row{11, "Sansa", "Stark", 6000})
tw.AppendFooter(testFooter)
tw.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Asc}})
compareOutput(t, tw.RenderMarkdown(), `
| # | First Name | Last Name | Salary | |
| ---:| --- | --- | ---:| --- |
| 300 | Tyrion | Lannister | 5000 | |
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
| 1 | Arya | Stark | 3000 | |
| 11 | Sansa | Stark | 6000 | |
| | | Total | 10000 | |`)
}

1863
helper/table/render_test.go Normal file

File diff suppressed because it is too large Load Diff

127
helper/table/sort.go Normal file
View File

@ -0,0 +1,127 @@
package table
import (
"sort"
"strconv"
)
// SortBy defines What to sort (Column Name or Number), and How to sort (Mode).
type SortBy struct {
// Name is the name of the Column as it appears in the first Header row.
// If a Header is not provided, or the name is not found in the header, this
// will not work.
Name string
// Number is the Column # from left. When specified, it overrides the Name
// property. If you know the exact Column number, use this instead of Name.
Number int
// Mode tells the Writer how to Sort. Asc/Dsc/etc.
Mode SortMode
}
// SortMode defines How to sort.
type SortMode int
const (
// Asc sorts the column in Ascending order alphabetically.
Asc SortMode = iota
// AscNumeric sorts the column in Ascending order numerically.
AscNumeric
// Dsc sorts the column in Descending order alphabetically.
Dsc
// DscNumeric sorts the column in Descending order numerically.
DscNumeric
)
type rowsSorter struct {
rows []rowStr
sortBy []SortBy
sortedIndices []int
}
// getSortedRowIndices sorts and returns the row indices in Sorted order as
// directed by Table.sortBy which can be set using Table.SortBy(...)
func (t *Table) getSortedRowIndices() []int {
sortedIndices := make([]int, len(t.rows))
for idx := range t.rows {
sortedIndices[idx] = idx
}
if t.sortBy != nil && len(t.sortBy) > 0 {
sort.Sort(rowsSorter{
rows: t.rows,
sortBy: t.parseSortBy(t.sortBy),
sortedIndices: sortedIndices,
})
}
return sortedIndices
}
func (t *Table) parseSortBy(sortBy []SortBy) []SortBy {
var resSortBy []SortBy
for _, col := range sortBy {
colNum := 0
if col.Number > 0 && col.Number <= t.numColumns {
colNum = col.Number
} else if col.Name != "" && len(t.rowsHeader) > 0 {
for idx, colName := range t.rowsHeader[0] {
if col.Name == colName {
colNum = idx + 1
break
}
}
}
if colNum > 0 {
resSortBy = append(resSortBy, SortBy{
Name: col.Name,
Number: colNum,
Mode: col.Mode,
})
}
}
return resSortBy
}
func (rs rowsSorter) Len() int {
return len(rs.rows)
}
func (rs rowsSorter) Swap(i, j int) {
rs.sortedIndices[i], rs.sortedIndices[j] = rs.sortedIndices[j], rs.sortedIndices[i]
}
func (rs rowsSorter) Less(i, j int) bool {
realI, realJ := rs.sortedIndices[i], rs.sortedIndices[j]
for _, col := range rs.sortBy {
rowI, rowJ, colIdx := rs.rows[realI], rs.rows[realJ], col.Number-1
if colIdx < len(rowI) && colIdx < len(rowJ) {
shouldContinue, returnValue := rs.lessColumns(rowI, rowJ, colIdx, col)
if !shouldContinue {
return returnValue
}
}
}
return false
}
func (rs rowsSorter) lessColumns(rowI rowStr, rowJ rowStr, colIdx int, col SortBy) (bool, bool) {
if rowI[colIdx] == rowJ[colIdx] {
return true, false
} else if col.Mode == Asc {
return false, rowI[colIdx] < rowJ[colIdx]
} else if col.Mode == Dsc {
return false, rowI[colIdx] > rowJ[colIdx]
}
iVal, iErr := strconv.ParseFloat(rowI[colIdx], 64)
jVal, jErr := strconv.ParseFloat(rowJ[colIdx], 64)
if iErr == nil && jErr == nil {
if col.Mode == AscNumeric {
return false, iVal < jVal
} else if col.Mode == DscNumeric {
return false, jVal < iVal
}
}
return true, false
}

148
helper/table/sort_test.go Normal file
View File

@ -0,0 +1,148 @@
package table
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTable_sortRows_WithName(t *testing.T) {
table := Table{}
table.AppendHeader(Row{"#", "First Name", "Last Name", "Salary"})
table.AppendRows([]Row{
{1, "Arya", "Stark", 3000},
{11, "Sansa", "Stark", 3000},
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
{300, "Tyrion", "Lannister", 5000},
})
table.SetStyle(StyleDefault)
table.initForRenderRows()
// sort by nothing
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
// sort by "#"
table.SortBy([]SortBy{{Name: "#", Mode: AscNumeric}})
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Name: "#", Mode: DscNumeric}})
assert.Equal(t, []int{3, 2, 1, 0}, table.getSortedRowIndices())
// sort by First Name, Last Name
table.SortBy([]SortBy{{Name: "First Name", Mode: Asc}, {Name: "Last Name", Mode: Asc}})
assert.Equal(t, []int{0, 2, 1, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Name: "First Name", Mode: Asc}, {Name: "Last Name", Mode: Dsc}})
assert.Equal(t, []int{0, 2, 1, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Name: "First Name", Mode: Dsc}, {Name: "Last Name", Mode: Asc}})
assert.Equal(t, []int{3, 1, 2, 0}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Name: "First Name", Mode: Dsc}, {Name: "Last Name", Mode: Dsc}})
assert.Equal(t, []int{3, 1, 2, 0}, table.getSortedRowIndices())
// sort by Last Name, First Name
table.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Asc}})
assert.Equal(t, []int{3, 2, 0, 1}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Name: "Last Name", Mode: Asc}, {Name: "First Name", Mode: Dsc}})
assert.Equal(t, []int{3, 2, 1, 0}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Name: "Last Name", Mode: Dsc}, {Name: "First Name", Mode: Asc}})
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Name: "Last Name", Mode: Dsc}, {Name: "First Name", Mode: Dsc}})
assert.Equal(t, []int{1, 0, 2, 3}, table.getSortedRowIndices())
// sort by Unknown Column
table.SortBy([]SortBy{{Name: "Last Name", Mode: Dsc}, {Name: "Foo Bar", Mode: Dsc}})
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
// sort by Salary
table.SortBy([]SortBy{{Name: "Salary", Mode: AscNumeric}})
assert.Equal(t, []int{2, 0, 1, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Name: "Salary", Mode: DscNumeric}})
assert.Equal(t, []int{3, 0, 1, 2}, table.getSortedRowIndices())
table.SortBy(nil)
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
}
func TestTable_sortRows_WithoutName(t *testing.T) {
table := Table{}
table.AppendRows([]Row{
{1, "Arya", "Stark", 3000},
{11, "Sansa", "Stark", 3000},
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
{300, "Tyrion", "Lannister", 5000},
})
table.SetStyle(StyleDefault)
table.initForRenderRows()
// sort by nothing
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
// sort by "#"
table.SortBy([]SortBy{{Number: 1, Mode: AscNumeric}})
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Number: 1, Mode: DscNumeric}})
assert.Equal(t, []int{3, 2, 1, 0}, table.getSortedRowIndices())
// sort by First Name, Last Name
table.SortBy([]SortBy{{Number: 2, Mode: Asc}, {Number: 3, Mode: Asc}})
assert.Equal(t, []int{0, 2, 1, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Number: 2, Mode: Asc}, {Number: 3, Mode: Dsc}})
assert.Equal(t, []int{0, 2, 1, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Number: 2, Mode: Dsc}, {Number: 3, Mode: Asc}})
assert.Equal(t, []int{3, 1, 2, 0}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Number: 2, Mode: Dsc}, {Number: 3, Mode: Dsc}})
assert.Equal(t, []int{3, 1, 2, 0}, table.getSortedRowIndices())
// sort by Last Name, First Name
table.SortBy([]SortBy{{Number: 3, Mode: Asc}, {Number: 2, Mode: Asc}})
assert.Equal(t, []int{3, 2, 0, 1}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Number: 3, Mode: Asc}, {Number: 2, Mode: Dsc}})
assert.Equal(t, []int{3, 2, 1, 0}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Number: 3, Mode: Dsc}, {Number: 2, Mode: Asc}})
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Number: 3, Mode: Dsc}, {Number: 2, Mode: Dsc}})
assert.Equal(t, []int{1, 0, 2, 3}, table.getSortedRowIndices())
// sort by Unknown Column
table.SortBy([]SortBy{{Number: 3, Mode: Dsc}, {Number: 99, Mode: Dsc}})
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
// sort by Salary
table.SortBy([]SortBy{{Number: 4, Mode: AscNumeric}})
assert.Equal(t, []int{2, 0, 1, 3}, table.getSortedRowIndices())
table.SortBy([]SortBy{{Number: 4, Mode: DscNumeric}})
assert.Equal(t, []int{3, 0, 1, 2}, table.getSortedRowIndices())
table.SortBy(nil)
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
}
func TestTable_sortRows_InvalidMode(t *testing.T) {
table := Table{}
table.AppendRows([]Row{
{1, "Arya", "Stark", 3000},
{11, "Sansa", "Stark", 3000},
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
{300, "Tyrion", "Lannister", 5000},
})
table.SetStyle(StyleDefault)
table.initForRenderRows()
// sort by "First Name"
table.SortBy([]SortBy{{Number: 2, Mode: AscNumeric}})
assert.Equal(t, []int{0, 1, 2, 3}, table.getSortedRowIndices())
}

879
helper/table/style.go Normal file
View File

@ -0,0 +1,879 @@
package table
import "git.hexq.cn/tiglog/golib/helper/text"
// Style declares how to render the Table and provides very fine-grained control
// on how the Table gets rendered on the Console.
type Style struct {
Name string // name of the Style
Box BoxStyle // characters to use for the boxes
Color ColorOptions // colors to use for the rows and columns
Format FormatOptions // formatting options for the rows and columns
HTML HTMLOptions // rendering options for HTML mode
Options Options // misc. options for the table
Title TitleOptions // formation options for the title text
}
var (
// StyleDefault renders a Table like below:
// +-----+------------+-----------+--------+-----------------------------+
// | # | FIRST NAME | LAST NAME | SALARY | |
// +-----+------------+-----------+--------+-----------------------------+
// | 1 | Arya | Stark | 3000 | |
// | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
// | 300 | Tyrion | Lannister | 5000 | |
// +-----+------------+-----------+--------+-----------------------------+
// | | | TOTAL | 10000 | |
// +-----+------------+-----------+--------+-----------------------------+
StyleDefault = Style{
Name: "StyleDefault",
Box: StyleBoxDefault,
Color: ColorOptionsDefault,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsDefault,
Title: TitleOptionsDefault,
}
// StyleBold renders a Table like below:
// ┏━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ # ┃ FIRST NAME ┃ LAST NAME ┃ SALARY ┃ ┃
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 1 ┃ Arya ┃ Stark ┃ 3000 ┃ ┃
// ┃ 20 ┃ Jon ┃ Snow ┃ 2000 ┃ You know nothing, Jon Snow! ┃
// ┃ 300 ┃ Tyrion ┃ Lannister ┃ 5000 ┃ ┃
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ ┃ ┃ TOTAL ┃ 10000 ┃ ┃
// ┗━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
StyleBold = Style{
Name: "StyleBold",
Box: StyleBoxBold,
Color: ColorOptionsDefault,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsDefault,
Title: TitleOptionsDefault,
}
// StyleColoredBright renders a Table without any borders or separators,
// and with Black text on Cyan background for Header/Footer and
// White background for other rows.
StyleColoredBright = Style{
Name: "StyleColoredBright",
Box: StyleBoxDefault,
Color: ColorOptionsBright,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsDark,
}
// StyleColoredDark renders a Table without any borders or separators, and
// with Header/Footer in Cyan text and other rows with White text, all on
// Black background.
StyleColoredDark = Style{
Name: "StyleColoredDark",
Box: StyleBoxDefault,
Color: ColorOptionsDark,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsBright,
}
// StyleColoredBlackOnBlueWhite renders a Table without any borders or
// separators, and with Black text on Blue background for Header/Footer and
// White background for other rows.
StyleColoredBlackOnBlueWhite = Style{
Name: "StyleColoredBlackOnBlueWhite",
Box: StyleBoxDefault,
Color: ColorOptionsBlackOnBlueWhite,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsBlueOnBlack,
}
// StyleColoredBlackOnCyanWhite renders a Table without any borders or
// separators, and with Black text on Cyan background for Header/Footer and
// White background for other rows.
StyleColoredBlackOnCyanWhite = Style{
Name: "StyleColoredBlackOnCyanWhite",
Box: StyleBoxDefault,
Color: ColorOptionsBlackOnCyanWhite,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsCyanOnBlack,
}
// StyleColoredBlackOnGreenWhite renders a Table without any borders or
// separators, and with Black text on Green background for Header/Footer and
// White background for other rows.
StyleColoredBlackOnGreenWhite = Style{
Name: "StyleColoredBlackOnGreenWhite",
Box: StyleBoxDefault,
Color: ColorOptionsBlackOnGreenWhite,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsGreenOnBlack,
}
// StyleColoredBlackOnMagentaWhite renders a Table without any borders or
// separators, and with Black text on Magenta background for Header/Footer and
// White background for other rows.
StyleColoredBlackOnMagentaWhite = Style{
Name: "StyleColoredBlackOnMagentaWhite",
Box: StyleBoxDefault,
Color: ColorOptionsBlackOnMagentaWhite,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsMagentaOnBlack,
}
// StyleColoredBlackOnYellowWhite renders a Table without any borders or
// separators, and with Black text on Yellow background for Header/Footer and
// White background for other rows.
StyleColoredBlackOnYellowWhite = Style{
Name: "StyleColoredBlackOnYellowWhite",
Box: StyleBoxDefault,
Color: ColorOptionsBlackOnYellowWhite,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsYellowOnBlack,
}
// StyleColoredBlackOnRedWhite renders a Table without any borders or
// separators, and with Black text on Red background for Header/Footer and
// White background for other rows.
StyleColoredBlackOnRedWhite = Style{
Name: "StyleColoredBlackOnRedWhite",
Box: StyleBoxDefault,
Color: ColorOptionsBlackOnRedWhite,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsRedOnBlack,
}
// StyleColoredBlueWhiteOnBlack renders a Table without any borders or
// separators, and with Header/Footer in Blue text and other rows with
// White text, all on Black background.
StyleColoredBlueWhiteOnBlack = Style{
Name: "StyleColoredBlueWhiteOnBlack",
Box: StyleBoxDefault,
Color: ColorOptionsBlueWhiteOnBlack,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsBlackOnBlue,
}
// StyleColoredCyanWhiteOnBlack renders a Table without any borders or
// separators, and with Header/Footer in Cyan text and other rows with
// White text, all on Black background.
StyleColoredCyanWhiteOnBlack = Style{
Name: "StyleColoredCyanWhiteOnBlack",
Box: StyleBoxDefault,
Color: ColorOptionsCyanWhiteOnBlack,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsBlackOnCyan,
}
// StyleColoredGreenWhiteOnBlack renders a Table without any borders or
// separators, and with Header/Footer in Green text and other rows with
// White text, all on Black background.
StyleColoredGreenWhiteOnBlack = Style{
Name: "StyleColoredGreenWhiteOnBlack",
Box: StyleBoxDefault,
Color: ColorOptionsGreenWhiteOnBlack,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsBlackOnGreen,
}
// StyleColoredMagentaWhiteOnBlack renders a Table without any borders or
// separators, and with Header/Footer in Magenta text and other rows with
// White text, all on Black background.
StyleColoredMagentaWhiteOnBlack = Style{
Name: "StyleColoredMagentaWhiteOnBlack",
Box: StyleBoxDefault,
Color: ColorOptionsMagentaWhiteOnBlack,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsBlackOnMagenta,
}
// StyleColoredRedWhiteOnBlack renders a Table without any borders or
// separators, and with Header/Footer in Red text and other rows with
// White text, all on Black background.
StyleColoredRedWhiteOnBlack = Style{
Name: "StyleColoredRedWhiteOnBlack",
Box: StyleBoxDefault,
Color: ColorOptionsRedWhiteOnBlack,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsBlackOnRed,
}
// StyleColoredYellowWhiteOnBlack renders a Table without any borders or
// separators, and with Header/Footer in Yellow text and other rows with
// White text, all on Black background.
StyleColoredYellowWhiteOnBlack = Style{
Name: "StyleColoredYellowWhiteOnBlack",
Box: StyleBoxDefault,
Color: ColorOptionsYellowWhiteOnBlack,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsNoBordersAndSeparators,
Title: TitleOptionsBlackOnYellow,
}
// StyleDouble renders a Table like below:
// ╔═════╦════════════╦═══════════╦════════╦═════════════════════════════╗
// ║ # ║ FIRST NAME ║ LAST NAME ║ SALARY ║ ║
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
// ║ 1 ║ Arya ║ Stark ║ 3000 ║ ║
// ║ 20 ║ Jon ║ Snow ║ 2000 ║ You know nothing, Jon Snow! ║
// ║ 300 ║ Tyrion ║ Lannister ║ 5000 ║ ║
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
// ║ ║ ║ TOTAL ║ 10000 ║ ║
// ╚═════╩════════════╩═══════════╩════════╩═════════════════════════════╝
StyleDouble = Style{
Name: "StyleDouble",
Box: StyleBoxDouble,
Color: ColorOptionsDefault,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsDefault,
Title: TitleOptionsDefault,
}
// StyleLight renders a Table like below:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
StyleLight = Style{
Name: "StyleLight",
Box: StyleBoxLight,
Color: ColorOptionsDefault,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsDefault,
Title: TitleOptionsDefault,
}
// StyleRounded renders a Table like below:
// ╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// ╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯
StyleRounded = Style{
Name: "StyleRounded",
Box: StyleBoxRounded,
Color: ColorOptionsDefault,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsDefault,
Title: TitleOptionsDefault,
}
// styleTest renders a Table like below:
// (-----^------------^-----------^--------^-----------------------------)
// [< #>|<FIRST NAME>|<LAST NAME>|<SALARY>|< >]
// {-----+------------+-----------+--------+-----------------------------}
// [< 1>|<Arya >|<Stark >|< 3000>|< >]
// [< 20>|<Jon >|<Snow >|< 2000>|<You know nothing, Jon Snow!>]
// [<300>|<Tyrion >|<Lannister>|< 5000>|< >]
// {-----+------------+-----------+--------+-----------------------------}
// [< >|< >|<TOTAL >|< 10000>|< >]
// \-----v------------v-----------v--------v-----------------------------/
styleTest = Style{
Name: "styleTest",
Box: styleBoxTest,
Color: ColorOptionsDefault,
Format: FormatOptionsDefault,
HTML: DefaultHTMLOptions,
Options: OptionsDefault,
Title: TitleOptionsDefault,
}
)
// BoxStyle defines the characters/strings to use to render the borders and
// separators for the Table.
type BoxStyle struct {
BottomLeft string
BottomRight string
BottomSeparator string
EmptySeparator string
Left string
LeftSeparator string
MiddleHorizontal string
MiddleSeparator string
MiddleVertical string
PaddingLeft string
PaddingRight string
PageSeparator string
Right string
RightSeparator string
TopLeft string
TopRight string
TopSeparator string
UnfinishedRow string
}
var (
// StyleBoxDefault defines a Boxed-Table like below:
// +-----+------------+-----------+--------+-----------------------------+
// | # | FIRST NAME | LAST NAME | SALARY | |
// +-----+------------+-----------+--------+-----------------------------+
// | 1 | Arya | Stark | 3000 | |
// | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
// | 300 | Tyrion | Lannister | 5000 | |
// +-----+------------+-----------+--------+-----------------------------+
// | | | TOTAL | 10000 | |
// +-----+------------+-----------+--------+-----------------------------+
StyleBoxDefault = BoxStyle{
BottomLeft: "+",
BottomRight: "+",
BottomSeparator: "+",
EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("+")),
Left: "|",
LeftSeparator: "+",
MiddleHorizontal: "-",
MiddleSeparator: "+",
MiddleVertical: "|",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "|",
RightSeparator: "+",
TopLeft: "+",
TopRight: "+",
TopSeparator: "+",
UnfinishedRow: " ~",
}
// StyleBoxBold defines a Boxed-Table like below:
// ┏━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ # ┃ FIRST NAME ┃ LAST NAME ┃ SALARY ┃ ┃
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 1 ┃ Arya ┃ Stark ┃ 3000 ┃ ┃
// ┃ 20 ┃ Jon ┃ Snow ┃ 2000 ┃ You know nothing, Jon Snow! ┃
// ┃ 300 ┃ Tyrion ┃ Lannister ┃ 5000 ┃ ┃
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ ┃ ┃ TOTAL ┃ 10000 ┃ ┃
// ┗━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
StyleBoxBold = BoxStyle{
BottomLeft: "┗",
BottomRight: "┛",
BottomSeparator: "┻",
EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("╋")),
Left: "┃",
LeftSeparator: "┣",
MiddleHorizontal: "━",
MiddleSeparator: "╋",
MiddleVertical: "┃",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "┃",
RightSeparator: "┫",
TopLeft: "┏",
TopRight: "┓",
TopSeparator: "┳",
UnfinishedRow: " ≈",
}
// StyleBoxDouble defines a Boxed-Table like below:
// ╔═════╦════════════╦═══════════╦════════╦═════════════════════════════╗
// ║ # ║ FIRST NAME ║ LAST NAME ║ SALARY ║ ║
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
// ║ 1 ║ Arya ║ Stark ║ 3000 ║ ║
// ║ 20 ║ Jon ║ Snow ║ 2000 ║ You know nothing, Jon Snow! ║
// ║ 300 ║ Tyrion ║ Lannister ║ 5000 ║ ║
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
// ║ ║ ║ TOTAL ║ 10000 ║ ║
// ╚═════╩════════════╩═══════════╩════════╩═════════════════════════════╝
StyleBoxDouble = BoxStyle{
BottomLeft: "╚",
BottomRight: "╝",
BottomSeparator: "╩",
EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("╬")),
Left: "║",
LeftSeparator: "╠",
MiddleHorizontal: "═",
MiddleSeparator: "╬",
MiddleVertical: "║",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "║",
RightSeparator: "╣",
TopLeft: "╔",
TopRight: "╗",
TopSeparator: "╦",
UnfinishedRow: " ≈",
}
// StyleBoxLight defines a Boxed-Table like below:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
StyleBoxLight = BoxStyle{
BottomLeft: "└",
BottomRight: "┘",
BottomSeparator: "┴",
EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("┼")),
Left: "│",
LeftSeparator: "├",
MiddleHorizontal: "─",
MiddleSeparator: "┼",
MiddleVertical: "│",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "│",
RightSeparator: "┤",
TopLeft: "┌",
TopRight: "┐",
TopSeparator: "┬",
UnfinishedRow: " ≈",
}
// StyleBoxRounded defines a Boxed-Table like below:
// ╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// ╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯
StyleBoxRounded = BoxStyle{
BottomLeft: "╰",
BottomRight: "╯",
BottomSeparator: "┴",
EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("┼")),
Left: "│",
LeftSeparator: "├",
MiddleHorizontal: "─",
MiddleSeparator: "┼",
MiddleVertical: "│",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "│",
RightSeparator: "┤",
TopLeft: "╭",
TopRight: "╮",
TopSeparator: "┬",
UnfinishedRow: " ≈",
}
// styleBoxTest defines a Boxed-Table like below:
// (-----^------------^-----------^--------^-----------------------------)
// [< #>|<FIRST NAME>|<LAST NAME>|<SALARY>|< >]
// {-----+------------+-----------+--------+-----------------------------}
// [< 1>|<Arya >|<Stark >|< 3000>|< >]
// [< 20>|<Jon >|<Snow >|< 2000>|<You know nothing, Jon Snow!>]
// [<300>|<Tyrion >|<Lannister>|< 5000>|< >]
// {-----+------------+-----------+--------+-----------------------------}
// [< >|< >|<TOTAL >|< 10000>|< >]
// \-----v------------v-----------v--------v-----------------------------/
styleBoxTest = BoxStyle{
BottomLeft: "\\",
BottomRight: "/",
BottomSeparator: "v",
EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("+")),
Left: "[",
LeftSeparator: "{",
MiddleHorizontal: "--",
MiddleSeparator: "+",
MiddleVertical: "|",
PaddingLeft: "<",
PaddingRight: ">",
PageSeparator: "\n",
Right: "]",
RightSeparator: "}",
TopLeft: "(",
TopRight: ")",
TopSeparator: "^",
UnfinishedRow: " ~~~",
}
)
// ColorOptions defines the ANSI colors to use for parts of the Table.
type ColorOptions struct {
Border text.Colors // borders (if nil, uses one of the below)
Footer text.Colors // footer row(s) colors
Header text.Colors // header row(s) colors
IndexColumn text.Colors // index-column colors (row #, etc.)
Row text.Colors // regular row(s) colors
RowAlternate text.Colors // regular row(s) colors for the even-numbered rows
Separator text.Colors // separators (if nil, uses one of the above)
}
var (
// ColorOptionsDefault defines sensible ANSI color options - basically NONE.
ColorOptionsDefault = ColorOptions{}
// ColorOptionsBright renders dark text on bright background.
ColorOptionsBright = ColorOptionsBlackOnCyanWhite
// ColorOptionsDark renders bright text on dark background.
ColorOptionsDark = ColorOptionsCyanWhiteOnBlack
// ColorOptionsBlackOnBlueWhite renders Black text on Blue/White background.
ColorOptionsBlackOnBlueWhite = ColorOptions{
Footer: text.Colors{text.BgBlue, text.FgBlack},
Header: text.Colors{text.BgHiBlue, text.FgBlack},
IndexColumn: text.Colors{text.BgHiBlue, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnCyanWhite renders Black text on Cyan/White background.
ColorOptionsBlackOnCyanWhite = ColorOptions{
Footer: text.Colors{text.BgCyan, text.FgBlack},
Header: text.Colors{text.BgHiCyan, text.FgBlack},
IndexColumn: text.Colors{text.BgHiCyan, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnGreenWhite renders Black text on Green/White
// background.
ColorOptionsBlackOnGreenWhite = ColorOptions{
Footer: text.Colors{text.BgGreen, text.FgBlack},
Header: text.Colors{text.BgHiGreen, text.FgBlack},
IndexColumn: text.Colors{text.BgHiGreen, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnMagentaWhite renders Black text on Magenta/White
// background.
ColorOptionsBlackOnMagentaWhite = ColorOptions{
Footer: text.Colors{text.BgMagenta, text.FgBlack},
Header: text.Colors{text.BgHiMagenta, text.FgBlack},
IndexColumn: text.Colors{text.BgHiMagenta, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnRedWhite renders Black text on Red/White background.
ColorOptionsBlackOnRedWhite = ColorOptions{
Footer: text.Colors{text.BgRed, text.FgBlack},
Header: text.Colors{text.BgHiRed, text.FgBlack},
IndexColumn: text.Colors{text.BgHiRed, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnYellowWhite renders Black text on Yellow/White
// background.
ColorOptionsBlackOnYellowWhite = ColorOptions{
Footer: text.Colors{text.BgYellow, text.FgBlack},
Header: text.Colors{text.BgHiYellow, text.FgBlack},
IndexColumn: text.Colors{text.BgHiYellow, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlueWhiteOnBlack renders Blue/White text on Black background.
ColorOptionsBlueWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgBlue, text.BgHiBlack},
Header: text.Colors{text.FgHiBlue, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiBlue, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsCyanWhiteOnBlack renders Cyan/White text on Black background.
ColorOptionsCyanWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgCyan, text.BgHiBlack},
Header: text.Colors{text.FgHiCyan, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiCyan, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsGreenWhiteOnBlack renders Green/White text on Black
// background.
ColorOptionsGreenWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgGreen, text.BgHiBlack},
Header: text.Colors{text.FgHiGreen, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiGreen, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsMagentaWhiteOnBlack renders Magenta/White text on Black
// background.
ColorOptionsMagentaWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgMagenta, text.BgHiBlack},
Header: text.Colors{text.FgHiMagenta, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiMagenta, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsRedWhiteOnBlack renders Red/White text on Black background.
ColorOptionsRedWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgRed, text.BgHiBlack},
Header: text.Colors{text.FgHiRed, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiRed, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsYellowWhiteOnBlack renders Yellow/White text on Black
// background.
ColorOptionsYellowWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgYellow, text.BgHiBlack},
Header: text.Colors{text.FgHiYellow, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiYellow, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
)
// FormatOptions defines the text-formatting to perform on parts of the Table.
type FormatOptions struct {
Direction text.Direction // (forced) BiDi direction for each Column
Footer text.Format // footer row(s) text format
Header text.Format // header row(s) text format
Row text.Format // (data) row(s) text format
}
var (
// FormatOptionsDefault defines sensible formatting options.
FormatOptionsDefault = FormatOptions{
Footer: text.FormatUpper,
Header: text.FormatUpper,
Row: text.FormatDefault,
}
)
// HTMLOptions defines the global options to control HTML rendering.
type HTMLOptions struct {
CSSClass string // CSS class to set on the overall <table> tag
EmptyColumn string // string to replace "" columns with (entire content being "")
EscapeText bool // escape text into HTML-safe content?
Newline string // string to replace "\n" characters with
}
var (
// DefaultHTMLOptions defines sensible HTML rendering defaults.
DefaultHTMLOptions = HTMLOptions{
CSSClass: DefaultHTMLCSSClass,
EmptyColumn: "&nbsp;",
EscapeText: true,
Newline: "<br/>",
}
)
// Options defines the global options that determine how the Table is
// rendered.
type Options struct {
// DoNotColorBordersAndSeparators disables coloring all the borders and row
// or column separators.
DoNotColorBordersAndSeparators bool
// DrawBorder enables or disables drawing the border around the Table.
// Example of a table where it is disabled:
// # │ FIRST NAME │ LAST NAME │ SALARY │
// ─────┼────────────┼───────────┼────────┼─────────────────────────────
// 1 │ Arya │ Stark │ 3000 │
// 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow!
// 300 │ Tyrion │ Lannister │ 5000 │
// ─────┼────────────┼───────────┼────────┼─────────────────────────────
// │ │ TOTAL │ 10000 │
DrawBorder bool
// SeparateColumns enables or disable drawing border between columns.
// Example of a table where it is disabled:
// ┌─────────────────────────────────────────────────────────────────┐
// │ # FIRST NAME LAST NAME SALARY │
// ├─────────────────────────────────────────────────────────────────┤
// │ 1 Arya Stark 3000 │
// │ 20 Jon Snow 2000 You know nothing, Jon Snow! │
// │ 300 Tyrion Lannister 5000 │
// │ TOTAL 10000 │
// └─────────────────────────────────────────────────────────────────┘
SeparateColumns bool
// SeparateFooter enables or disable drawing border between the footer and
// the rows. Example of a table where it is disabled:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
SeparateFooter bool
// SeparateHeader enables or disable drawing border between the header and
// the rows. Example of a table where it is disabled:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
SeparateHeader bool
// SeparateRows enables or disables drawing separators between each row.
// Example of a table where it is enabled:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
SeparateRows bool
}
var (
// OptionsDefault defines sensible global options.
OptionsDefault = Options{
DrawBorder: true,
SeparateColumns: true,
SeparateFooter: true,
SeparateHeader: true,
SeparateRows: false,
}
// OptionsNoBorders sets up a table without any borders.
OptionsNoBorders = Options{
DrawBorder: false,
SeparateColumns: true,
SeparateFooter: true,
SeparateHeader: true,
SeparateRows: false,
}
// OptionsNoBordersAndSeparators sets up a table without any borders or
// separators.
OptionsNoBordersAndSeparators = Options{
DrawBorder: false,
SeparateColumns: false,
SeparateFooter: false,
SeparateHeader: false,
SeparateRows: false,
}
)
// TitleOptions defines the way the title text is to be rendered.
type TitleOptions struct {
Align text.Align
Colors text.Colors
Format text.Format
}
var (
// TitleOptionsDefault defines sensible title options - basically NONE.
TitleOptionsDefault = TitleOptions{}
// TitleOptionsBright renders Bright Bold text on Dark background.
TitleOptionsBright = TitleOptionsBlackOnCyan
// TitleOptionsDark renders Dark Bold text on Bright background.
TitleOptionsDark = TitleOptionsCyanOnBlack
// TitleOptionsBlackOnBlue renders Black text on Blue background.
TitleOptionsBlackOnBlue = TitleOptions{
Colors: append(ColorOptionsBlackOnBlueWhite.Header, text.Bold),
}
// TitleOptionsBlackOnCyan renders Black Bold text on Cyan background.
TitleOptionsBlackOnCyan = TitleOptions{
Colors: append(ColorOptionsBlackOnCyanWhite.Header, text.Bold),
}
// TitleOptionsBlackOnGreen renders Black Bold text onGreen background.
TitleOptionsBlackOnGreen = TitleOptions{
Colors: append(ColorOptionsBlackOnGreenWhite.Header, text.Bold),
}
// TitleOptionsBlackOnMagenta renders Black Bold text on Magenta background.
TitleOptionsBlackOnMagenta = TitleOptions{
Colors: append(ColorOptionsBlackOnMagentaWhite.Header, text.Bold),
}
// TitleOptionsBlackOnRed renders Black Bold text on Red background.
TitleOptionsBlackOnRed = TitleOptions{
Colors: append(ColorOptionsBlackOnRedWhite.Header, text.Bold),
}
// TitleOptionsBlackOnYellow renders Black Bold text on Yellow background.
TitleOptionsBlackOnYellow = TitleOptions{
Colors: append(ColorOptionsBlackOnYellowWhite.Header, text.Bold),
}
// TitleOptionsBlueOnBlack renders Blue Bold text on Black background.
TitleOptionsBlueOnBlack = TitleOptions{
Colors: append(ColorOptionsBlueWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsCyanOnBlack renders Cyan Bold text on Black background.
TitleOptionsCyanOnBlack = TitleOptions{
Colors: append(ColorOptionsCyanWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsGreenOnBlack renders Green Bold text on Black background.
TitleOptionsGreenOnBlack = TitleOptions{
Colors: append(ColorOptionsGreenWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsMagentaOnBlack renders Magenta Bold text on Black background.
TitleOptionsMagentaOnBlack = TitleOptions{
Colors: append(ColorOptionsMagentaWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsRedOnBlack renders Red Bold text on Black background.
TitleOptionsRedOnBlack = TitleOptions{
Colors: append(ColorOptionsRedWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsYellowOnBlack renders Yellow Bold text on Black background.
TitleOptionsYellowOnBlack = TitleOptions{
Colors: append(ColorOptionsYellowWhiteOnBlack.Header, text.Bold),
}
)

784
helper/table/table.go Normal file
View File

@ -0,0 +1,784 @@
package table
import (
"fmt"
"io"
"strings"
"git.hexq.cn/tiglog/golib/helper/text"
)
// Row defines a single row in the Table.
type Row []interface{}
func (r Row) findColumnNumber(colName string) int {
for colIdx, col := range r {
if fmt.Sprint(col) == colName {
return colIdx + 1
}
}
return 0
}
// RowPainter is a custom function that takes a Row as input and returns the
// text.Colors{} to use on the entire row
type RowPainter func(row Row) text.Colors
// rowStr defines a single row in the Table comprised of just string objects.
type rowStr []string
// areEqual returns true if the contents of the 2 given columns are the same
func (row rowStr) areEqual(colIdx1 int, colIdx2 int) bool {
return colIdx1 >= 0 && colIdx2 < len(row) && row[colIdx1] == row[colIdx2]
}
// Table helps print a 2-dimensional array in a human readable pretty-table.
type Table struct {
// allowedRowLength is the max allowed length for a row (or line of output)
allowedRowLength int
// enable automatic indexing of the rows and columns like a spreadsheet?
autoIndex bool
// autoIndexVIndexMaxLength denotes the length in chars for the last rownum
autoIndexVIndexMaxLength int
// caption stores the text to be rendered just below the table; and doesn't
// get used when rendered as a CSV
caption string
// columnIsNonNumeric stores if a column contains non-numbers in all rows
columnIsNonNumeric []bool
// columnConfigs stores the custom-configuration for 1 or more columns
columnConfigs []ColumnConfig
// columnConfigMap stores the custom-configuration by column
// number and is generated before rendering
columnConfigMap map[int]ColumnConfig
// htmlCSSClass stores the HTML CSS Class to use on the <table> node
htmlCSSClass string
// indexColumn stores the number of the column considered as the "index"
indexColumn int
// maxColumnLengths stores the length of the longest line in each column
maxColumnLengths []int
// maxRowLength stores the length of the longest row
maxRowLength int
// numColumns stores the (max.) number of columns seen
numColumns int
// numLinesRendered keeps track of the number of lines rendered and helps in
// paginating long tables
numLinesRendered int
// outputMirror stores an io.Writer where the "Render" functions would write
outputMirror io.Writer
// pageSize stores the maximum lines to render before rendering the header
// again (to denote a page break) - useful when you are dealing with really
// long tables
pageSize int
// rows stores the rows that make up the body (in string form)
rows []rowStr
// rowsColors stores the text.Colors over-rides for each row as defined by
// rowPainter
rowsColors []text.Colors
// rowsConfigs stores RowConfig for each row
rowsConfigMap map[int]RowConfig
// rowsRaw stores the rows that make up the body
rowsRaw []Row
// rowsFooter stores the rows that make up the footer (in string form)
rowsFooter []rowStr
// rowsFooterConfigs stores RowConfig for each footer row
rowsFooterConfigMap map[int]RowConfig
// rowsFooterRaw stores the rows that make up the footer
rowsFooterRaw []Row
// rowsHeader stores the rows that make up the header (in string form)
rowsHeader []rowStr
// rowsHeaderConfigs stores RowConfig for each header row
rowsHeaderConfigMap map[int]RowConfig
// rowsHeaderRaw stores the rows that make up the header
rowsHeaderRaw []Row
// rowPainter is a custom function that given a Row, returns the colors to
// use on the entire row
rowPainter RowPainter
// rowSeparator is a dummy row that contains the separator columns (dashes
// that make up the separator between header/body/footer
rowSeparator rowStr
// separators is used to keep track of all rowIndices after which a
// separator has to be rendered
separators map[int]bool
// sortBy stores a map of Column
sortBy []SortBy
// style contains all the strings used to draw the table, and more
style *Style
// suppressEmptyColumns hides columns which have no content on all regular
// rows
suppressEmptyColumns bool
// title contains the text to appear above the table
title string
}
// AppendFooter appends the row to the List of footers to render.
//
// Only the first item in the "config" will be tagged against this row.
func (t *Table) AppendFooter(row Row, config ...RowConfig) {
t.rowsFooterRaw = append(t.rowsFooterRaw, row)
if len(config) > 0 {
if t.rowsFooterConfigMap == nil {
t.rowsFooterConfigMap = make(map[int]RowConfig)
}
t.rowsFooterConfigMap[len(t.rowsFooterRaw)-1] = config[0]
}
}
// AppendHeader appends the row to the List of headers to render.
//
// Only the first item in the "config" will be tagged against this row.
func (t *Table) AppendHeader(row Row, config ...RowConfig) {
t.rowsHeaderRaw = append(t.rowsHeaderRaw, row)
if len(config) > 0 {
if t.rowsHeaderConfigMap == nil {
t.rowsHeaderConfigMap = make(map[int]RowConfig)
}
t.rowsHeaderConfigMap[len(t.rowsHeaderRaw)-1] = config[0]
}
}
// AppendRow appends the row to the List of rows to render.
//
// Only the first item in the "config" will be tagged against this row.
func (t *Table) AppendRow(row Row, config ...RowConfig) {
t.rowsRaw = append(t.rowsRaw, row)
if len(config) > 0 {
if t.rowsConfigMap == nil {
t.rowsConfigMap = make(map[int]RowConfig)
}
t.rowsConfigMap[len(t.rowsRaw)-1] = config[0]
}
}
// AppendRows appends the rows to the List of rows to render.
//
// Only the first item in the "config" will be tagged against all the rows.
func (t *Table) AppendRows(rows []Row, config ...RowConfig) {
for _, row := range rows {
t.AppendRow(row, config...)
}
}
// AppendSeparator helps render a separator row after the current last row. You
// could call this function over and over, but it will be a no-op unless you
// call AppendRow or AppendRows in between. Likewise, if the last thing you
// append is a separator, it will not be rendered in addition to the usual table
// separator.
//
// ******************************************************************************
// Please note the following caveats:
// 1. SetPageSize(): this may end up creating consecutive separator rows near
// the end of a page or at the beginning of a page
// 2. SortBy(): since SortBy could inherently alter the ordering of rows, the
// separators may not appear after the row it was originally intended to
// follow
//
// ******************************************************************************
func (t *Table) AppendSeparator() {
if t.separators == nil {
t.separators = make(map[int]bool)
}
if len(t.rowsRaw) > 0 {
t.separators[len(t.rowsRaw)-1] = true
}
}
// Length returns the number of rows to be rendered.
func (t *Table) Length() int {
return len(t.rowsRaw)
}
// ResetFooters resets and clears all the Footer rows appended earlier.
func (t *Table) ResetFooters() {
t.rowsFooterRaw = nil
}
// ResetHeaders resets and clears all the Header rows appended earlier.
func (t *Table) ResetHeaders() {
t.rowsHeaderRaw = nil
}
// ResetRows resets and clears all the rows appended earlier.
func (t *Table) ResetRows() {
t.rowsRaw = nil
t.separators = nil
}
// SetAllowedRowLength sets the maximum allowed length or a row (or line of
// output) when rendered as a table. Rows that are longer than this limit will
// be "snipped" to the length. Length has to be a positive value to take effect.
func (t *Table) SetAllowedRowLength(length int) {
t.allowedRowLength = length
}
// SetAutoIndex adds a generated header with columns such as "A", "B", "C", etc.
// and a leading column with the row number similar to what you'd see on any
// spreadsheet application. NOTE: Appending a Header will void this
// functionality.
func (t *Table) SetAutoIndex(autoIndex bool) {
t.autoIndex = autoIndex
}
// SetCaption sets the text to be rendered just below the table. This will not
// show up when the Table is rendered as a CSV.
func (t *Table) SetCaption(format string, a ...interface{}) {
t.caption = fmt.Sprintf(format, a...)
}
// SetColumnConfigs sets the configs for each Column.
func (t *Table) SetColumnConfigs(configs []ColumnConfig) {
t.columnConfigs = configs
}
// SetHTMLCSSClass sets the the HTML CSS Class to use on the <table> node
// when rendering the Table in HTML format.
//
// Deprecated: in favor of Style().HTML.CSSClass
func (t *Table) SetHTMLCSSClass(cssClass string) {
t.htmlCSSClass = cssClass
}
// SetIndexColumn sets the given Column # as the column that has the row
// "Number". Valid values range from 1 to N. Note that this is not 0-indexed.
func (t *Table) SetIndexColumn(colNum int) {
t.indexColumn = colNum
}
// SetOutputMirror sets an io.Writer for all the Render functions to "Write" to
// in addition to returning a string.
func (t *Table) SetOutputMirror(mirror io.Writer) {
t.outputMirror = mirror
}
// SetPageSize sets the maximum number of lines to render before rendering the
// header rows again. This can be useful when dealing with tables containing a
// long list of rows that can span pages. Please note that the pagination logic
// will not consider Header/Footer lines for paging.
func (t *Table) SetPageSize(numLines int) {
t.pageSize = numLines
}
// SetRowPainter sets the RowPainter function which determines the colors to use
// on a row. Before rendering, this function is invoked on all rows and the
// color of each row is determined. This color takes precedence over other ways
// to set color (ColumnConfig.Color*, SetColor*()).
func (t *Table) SetRowPainter(painter RowPainter) {
t.rowPainter = painter
}
// SetStyle overrides the DefaultStyle with the provided one.
func (t *Table) SetStyle(style Style) {
t.style = &style
}
// SetTitle sets the title text to be rendered above the table.
func (t *Table) SetTitle(format string, a ...interface{}) {
t.title = fmt.Sprintf(format, a...)
}
// SortBy sets the rules for sorting the Rows in the order specified. i.e., the
// first SortBy instruction takes precedence over the second and so on. Any
// duplicate instructions on the same column will be discarded while sorting.
func (t *Table) SortBy(sortBy []SortBy) {
t.sortBy = sortBy
}
// Style returns the current style.
func (t *Table) Style() *Style {
if t.style == nil {
tempStyle := StyleDefault
t.style = &tempStyle
}
return t.style
}
// SuppressEmptyColumns hides columns when the column is empty in ALL the
// regular rows.
func (t *Table) SuppressEmptyColumns() {
t.suppressEmptyColumns = true
}
func (t *Table) getAlign(colIdx int, hint renderHint) text.Align {
align := text.AlignDefault
if cfg, ok := t.columnConfigMap[colIdx]; ok {
if hint.isHeaderRow {
align = cfg.AlignHeader
} else if hint.isFooterRow {
align = cfg.AlignFooter
} else {
align = cfg.Align
}
}
if align == text.AlignDefault {
if !t.columnIsNonNumeric[colIdx] {
align = text.AlignRight
} else if hint.isAutoIndexRow {
align = text.AlignCenter
}
}
return align
}
func (t *Table) getAutoIndexColumnIDs() rowStr {
row := make(rowStr, t.numColumns)
for colIdx := range row {
row[colIdx] = AutoIndexColumnID(colIdx)
}
return row
}
func (t *Table) getBorderColors(hint renderHint) text.Colors {
if t.style.Options.DoNotColorBordersAndSeparators {
return nil
} else if t.style.Color.Border != nil {
return t.style.Color.Border
} else if hint.isTitleRow {
return t.style.Title.Colors
} else if hint.isHeaderRow {
return t.style.Color.Header
} else if hint.isFooterRow {
return t.style.Color.Footer
} else if t.autoIndex {
return t.style.Color.IndexColumn
} else if hint.rowNumber%2 == 0 && t.style.Color.RowAlternate != nil {
return t.style.Color.RowAlternate
}
return t.style.Color.Row
}
func (t *Table) getBorderLeft(hint renderHint) string {
border := t.style.Box.Left
if hint.isBorderTop {
if t.title != "" {
border = t.style.Box.LeftSeparator
} else {
border = t.style.Box.TopLeft
}
} else if hint.isBorderBottom {
border = t.style.Box.BottomLeft
} else if hint.isSeparatorRow {
if t.autoIndex && hint.isHeaderOrFooterSeparator() {
border = t.style.Box.Left
} else if !t.autoIndex && t.shouldMergeCellsVertically(0, hint) {
border = t.style.Box.Left
} else {
border = t.style.Box.LeftSeparator
}
}
return border
}
func (t *Table) getBorderRight(hint renderHint) string {
border := t.style.Box.Right
if hint.isBorderTop {
if t.title != "" {
border = t.style.Box.RightSeparator
} else {
border = t.style.Box.TopRight
}
} else if hint.isBorderBottom {
border = t.style.Box.BottomRight
} else if hint.isSeparatorRow {
if t.shouldMergeCellsVertically(t.numColumns-1, hint) {
border = t.style.Box.Right
} else {
border = t.style.Box.RightSeparator
}
}
return border
}
func (t *Table) getColumnColors(colIdx int, hint renderHint) text.Colors {
if hint.isBorderOrSeparator() {
if colors := t.getColumnColorsForBorderOrSeparator(colIdx, hint); colors != nil {
return colors
}
}
if t.rowPainter != nil && hint.isRegularNonSeparatorRow() && !t.isIndexColumn(colIdx, hint) {
if colors := t.rowsColors[hint.rowNumber-1]; colors != nil {
return colors
}
}
if cfg, ok := t.columnConfigMap[colIdx]; ok {
if hint.isSeparatorRow {
return nil
} else if hint.isHeaderRow {
return cfg.ColorsHeader
} else if hint.isFooterRow {
return cfg.ColorsFooter
}
return cfg.Colors
}
return nil
}
func (t *Table) getColumnColorsForBorderOrSeparator(colIdx int, hint renderHint) text.Colors {
if t.style.Options.DoNotColorBordersAndSeparators {
return text.Colors{} // not nil to force caller to paint with no colors
}
if (hint.isBorderBottom || hint.isBorderTop) && t.style.Color.Border != nil {
return t.style.Color.Border
}
if hint.isSeparatorRow && t.style.Color.Separator != nil {
return t.style.Color.Separator
}
return nil
}
func (t *Table) getColumnSeparator(row rowStr, colIdx int, hint renderHint) string {
separator := t.style.Box.MiddleVertical
if hint.isSeparatorRow {
if hint.isBorderTop {
if t.shouldMergeCellsHorizontallyBelow(row, colIdx, hint) {
separator = t.style.Box.MiddleHorizontal
} else {
separator = t.style.Box.TopSeparator
}
} else if hint.isBorderBottom {
if t.shouldMergeCellsHorizontallyAbove(row, colIdx, hint) {
separator = t.style.Box.MiddleHorizontal
} else {
separator = t.style.Box.BottomSeparator
}
} else {
separator = t.getColumnSeparatorNonBorder(
t.shouldMergeCellsHorizontallyAbove(row, colIdx, hint),
t.shouldMergeCellsHorizontallyBelow(row, colIdx, hint),
colIdx,
hint,
)
}
}
return separator
}
func (t *Table) getColumnSeparatorNonBorder(mergeCellsAbove bool, mergeCellsBelow bool, colIdx int, hint renderHint) string {
mergeNextCol := t.shouldMergeCellsVertically(colIdx, hint)
if hint.isAutoIndexColumn {
return t.getColumnSeparatorNonBorderAutoIndex(mergeNextCol, hint)
}
mergeCurrCol := t.shouldMergeCellsVertically(colIdx-1, hint)
return t.getColumnSeparatorNonBorderNonAutoIndex(mergeCellsAbove, mergeCellsBelow, mergeCurrCol, mergeNextCol)
}
func (t *Table) getColumnSeparatorNonBorderAutoIndex(mergeNextCol bool, hint renderHint) string {
if hint.isHeaderOrFooterSeparator() {
if mergeNextCol {
return t.style.Box.MiddleVertical
}
return t.style.Box.LeftSeparator
} else if mergeNextCol {
return t.style.Box.RightSeparator
}
return t.style.Box.MiddleSeparator
}
func (t *Table) getColumnSeparatorNonBorderNonAutoIndex(mergeCellsAbove bool, mergeCellsBelow bool, mergeCurrCol bool, mergeNextCol bool) string {
if mergeCellsAbove && mergeCellsBelow && mergeCurrCol && mergeNextCol {
return t.style.Box.EmptySeparator
} else if mergeCellsAbove && mergeCellsBelow {
return t.style.Box.MiddleHorizontal
} else if mergeCellsAbove {
return t.style.Box.TopSeparator
} else if mergeCellsBelow {
return t.style.Box.BottomSeparator
} else if mergeCurrCol && mergeNextCol {
return t.style.Box.MiddleVertical
} else if mergeCurrCol {
return t.style.Box.LeftSeparator
} else if mergeNextCol {
return t.style.Box.RightSeparator
}
return t.style.Box.MiddleSeparator
}
func (t *Table) getColumnTransformer(colIdx int, hint renderHint) text.Transformer {
var transformer text.Transformer
if cfg, ok := t.columnConfigMap[colIdx]; ok {
if hint.isHeaderRow {
transformer = cfg.TransformerHeader
} else if hint.isFooterRow {
transformer = cfg.TransformerFooter
} else {
transformer = cfg.Transformer
}
}
return transformer
}
func (t *Table) getColumnWidthMax(colIdx int) int {
if cfg, ok := t.columnConfigMap[colIdx]; ok {
return cfg.WidthMax
}
return 0
}
func (t *Table) getColumnWidthMin(colIdx int) int {
if cfg, ok := t.columnConfigMap[colIdx]; ok {
return cfg.WidthMin
}
return 0
}
func (t *Table) getFormat(hint renderHint) text.Format {
if hint.isSeparatorRow {
return text.FormatDefault
} else if hint.isHeaderRow {
return t.style.Format.Header
} else if hint.isFooterRow {
return t.style.Format.Footer
}
return t.style.Format.Row
}
func (t *Table) getMaxColumnLengthForMerging(colIdx int) int {
maxColumnLength := t.maxColumnLengths[colIdx]
maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingRight + t.style.Box.PaddingLeft)
if t.style.Options.SeparateColumns {
maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.EmptySeparator)
}
return maxColumnLength
}
// getMergedColumnIndices returns a map of colIdx values to all the other colIdx
// values (that are being merged) and their lengths.
func (t *Table) getMergedColumnIndices(row rowStr, hint renderHint) mergedColumnIndices {
if !t.getRowConfig(hint).AutoMerge {
return nil
}
mci := make(mergedColumnIndices)
for colIdx := 0; colIdx < t.numColumns-1; colIdx++ {
// look backward
for otherColIdx := colIdx - 1; colIdx >= 0 && otherColIdx >= 0; otherColIdx-- {
if row[colIdx] != row[otherColIdx] {
break
}
mci.safeAppend(colIdx, otherColIdx)
}
// look forward
for otherColIdx := colIdx + 1; colIdx < len(row) && otherColIdx < len(row); otherColIdx++ {
if row[colIdx] != row[otherColIdx] {
break
}
mci.safeAppend(colIdx, otherColIdx)
}
}
return mci
}
func (t *Table) getRow(rowIdx int, hint renderHint) rowStr {
switch {
case hint.isHeaderRow:
if rowIdx >= 0 && rowIdx < len(t.rowsHeader) {
return t.rowsHeader[rowIdx]
}
case hint.isFooterRow:
if rowIdx >= 0 && rowIdx < len(t.rowsFooter) {
return t.rowsFooter[rowIdx]
}
default:
if rowIdx >= 0 && rowIdx < len(t.rows) {
return t.rows[rowIdx]
}
}
return rowStr{}
}
func (t *Table) getRowConfig(hint renderHint) RowConfig {
rowIdx := hint.rowNumber - 1
if rowIdx < 0 {
rowIdx = 0
}
switch {
case hint.isHeaderRow:
return t.rowsHeaderConfigMap[rowIdx]
case hint.isFooterRow:
return t.rowsFooterConfigMap[rowIdx]
default:
return t.rowsConfigMap[rowIdx]
}
}
func (t *Table) getSeparatorColors(hint renderHint) text.Colors {
if t.style.Options.DoNotColorBordersAndSeparators {
return nil
} else if (hint.isBorderBottom || hint.isBorderTop) && t.style.Color.Border != nil {
return t.style.Color.Border
} else if t.style.Color.Separator != nil {
return t.style.Color.Separator
} else if hint.isHeaderRow {
return t.style.Color.Header
} else if hint.isFooterRow {
return t.style.Color.Footer
} else if hint.isAutoIndexColumn {
return t.style.Color.IndexColumn
} else if hint.rowNumber > 0 && hint.rowNumber%2 == 0 {
return t.style.Color.RowAlternate
}
return t.style.Color.Row
}
func (t *Table) getVAlign(colIdx int, hint renderHint) text.VAlign {
vAlign := text.VAlignDefault
if cfg, ok := t.columnConfigMap[colIdx]; ok {
if hint.isHeaderRow {
vAlign = cfg.VAlignHeader
} else if hint.isFooterRow {
vAlign = cfg.VAlignFooter
} else {
vAlign = cfg.VAlign
}
}
return vAlign
}
func (t *Table) hasHiddenColumns() bool {
for _, cc := range t.columnConfigMap {
if cc.Hidden {
return true
}
}
return false
}
func (t *Table) hideColumns() map[int]int {
colIdxMap := make(map[int]int)
numColumns := 0
hideColumnsInRows := func(rows []rowStr) []rowStr {
var rsp []rowStr
for _, row := range rows {
var rowNew rowStr
for colIdx, col := range row {
cc := t.columnConfigMap[colIdx]
if !cc.Hidden {
rowNew = append(rowNew, col)
colIdxMap[colIdx] = len(rowNew) - 1
}
}
if len(rowNew) > numColumns {
numColumns = len(rowNew)
}
rsp = append(rsp, rowNew)
}
return rsp
}
// hide columns as directed
t.rows = hideColumnsInRows(t.rows)
t.rowsFooter = hideColumnsInRows(t.rowsFooter)
t.rowsHeader = hideColumnsInRows(t.rowsHeader)
// reset numColumns to the new number of columns
t.numColumns = numColumns
return colIdxMap
}
func (t *Table) isIndexColumn(colIdx int, hint renderHint) bool {
return t.indexColumn == colIdx+1 || hint.isAutoIndexColumn
}
func (t *Table) render(out *strings.Builder) string {
outStr := out.String()
if t.outputMirror != nil && len(outStr) > 0 {
_, _ = t.outputMirror.Write([]byte(outStr))
_, _ = t.outputMirror.Write([]byte("\n"))
}
return outStr
}
func (t *Table) shouldMergeCellsHorizontallyAbove(row rowStr, colIdx int, hint renderHint) bool {
if hint.isAutoIndexColumn || hint.isAutoIndexRow {
return false
}
rowConfig := t.getRowConfig(hint)
if hint.isSeparatorRow {
if hint.isHeaderRow && hint.rowNumber == 1 {
rowConfig = t.getRowConfig(hint)
row = t.getRow(hint.rowNumber-1, hint)
} else if hint.isFooterRow && hint.isFirstRow {
rowConfig = t.getRowConfig(renderHint{isLastRow: true, rowNumber: len(t.rows)})
row = t.getRow(len(t.rows)-1, renderHint{})
} else if hint.isFooterRow && hint.isBorderBottom {
row = t.getRow(len(t.rowsFooter)-1, renderHint{isFooterRow: true})
} else {
row = t.getRow(hint.rowNumber-1, hint)
}
}
if rowConfig.AutoMerge {
return row.areEqual(colIdx-1, colIdx)
}
return false
}
func (t *Table) shouldMergeCellsHorizontallyBelow(row rowStr, colIdx int, hint renderHint) bool {
if hint.isAutoIndexColumn || hint.isAutoIndexRow {
return false
}
var rowConfig RowConfig
if hint.isSeparatorRow {
if hint.isHeaderRow && hint.rowNumber == 0 {
rowConfig = t.getRowConfig(renderHint{isHeaderRow: true, rowNumber: 1})
row = t.getRow(0, hint)
} else if hint.isHeaderRow && hint.isLastRow {
rowConfig = t.getRowConfig(renderHint{rowNumber: 1})
row = t.getRow(0, renderHint{})
} else if hint.isHeaderRow {
rowConfig = t.getRowConfig(renderHint{isHeaderRow: true, rowNumber: hint.rowNumber + 1})
row = t.getRow(hint.rowNumber, hint)
} else if hint.isFooterRow && hint.rowNumber >= 0 {
rowConfig = t.getRowConfig(renderHint{isFooterRow: true, rowNumber: 1})
row = t.getRow(hint.rowNumber, renderHint{isFooterRow: true})
} else if hint.isRegularRow() {
rowConfig = t.getRowConfig(renderHint{rowNumber: hint.rowNumber + 1})
row = t.getRow(hint.rowNumber, renderHint{})
}
}
if rowConfig.AutoMerge {
return row.areEqual(colIdx-1, colIdx)
}
return false
}
func (t *Table) shouldMergeCellsVertically(colIdx int, hint renderHint) bool {
if t.columnConfigMap[colIdx].AutoMerge && colIdx < t.numColumns {
if hint.isSeparatorRow {
rowPrev := t.getRow(hint.rowNumber-1, hint)
rowNext := t.getRow(hint.rowNumber, hint)
if colIdx < len(rowPrev) && colIdx < len(rowNext) {
return rowPrev[colIdx] == rowNext[colIdx] || "" == rowNext[colIdx]
}
} else {
rowPrev := t.getRow(hint.rowNumber-2, hint)
rowCurr := t.getRow(hint.rowNumber-1, hint)
if colIdx < len(rowPrev) && colIdx < len(rowCurr) {
return rowPrev[colIdx] == rowCurr[colIdx] || "" == rowCurr[colIdx]
}
}
}
return false
}
func (t *Table) wrapRow(row rowStr) (int, rowStr) {
colMaxLines := 0
rowWrapped := make(rowStr, len(row))
for colIdx, colStr := range row {
widthEnforcer := t.columnConfigMap[colIdx].getWidthMaxEnforcer()
maxWidth := t.getColumnWidthMax(colIdx)
if maxWidth == 0 {
maxWidth = t.maxColumnLengths[colIdx]
}
rowWrapped[colIdx] = widthEnforcer(colStr, maxWidth)
colNumLines := strings.Count(rowWrapped[colIdx], "\n") + 1
if colNumLines > colMaxLines {
colMaxLines = colNumLines
}
}
return colMaxLines, rowWrapped
}

375
helper/table/table_test.go Normal file
View File

@ -0,0 +1,375 @@
package table
import (
"strings"
"testing"
"unicode/utf8"
"git.hexq.cn/tiglog/golib/helper/text"
"github.com/stretchr/testify/assert"
)
var (
testCaption = "A Song of Ice and Fire"
testColor = text.Colors{text.FgGreen}
testColorHiRedBold = text.Colors{text.FgHiRed, text.Bold}
testColorHiBlueBold = text.Colors{text.FgHiBlue, text.Bold}
testCSSClass = "test-css-class"
testFooter = Row{"", "", "Total", 10000}
testFooterMultiLine = Row{"", "", "Total\nSalary", 10000}
testHeader = Row{"#", "First Name", "Last Name", "Salary"}
testHeaderMultiLine = Row{"#", "First\nName", "Last\nName", "Salary"}
testRows = []Row{
{1, "Arya", "Stark", 3000},
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
{300, "Tyrion", "Lannister", 5000},
}
testRowMultiLine = Row{0, "Winter", "Is", 0, "Coming.\r\nThe North Remembers!\nThis is known."}
testRowNewLines = Row{0, "Valar", "Morghulis", 0, "Faceless\nMen"}
testRowPipes = Row{0, "Valar", "Morghulis", 0, "Faceless|Men"}
testRowTabs = Row{0, "Valar", "Morghulis", 0, "Faceless\tMen"}
testTitle1 = "Game of Thrones"
testTitle2 = "When you play the Game of Thrones, you win or you die. There is no middle ground."
)
func init() {
text.EnableColors()
}
type myMockOutputMirror struct {
mirroredOutput string
}
func (t *myMockOutputMirror) Write(p []byte) (n int, err error) {
t.mirroredOutput += string(p)
return len(p), nil
}
func TestNewWriter(t *testing.T) {
tw := NewWriter()
assert.NotNil(t, tw.Style())
assert.Equal(t, StyleDefault, *tw.Style())
tw.SetStyle(StyleBold)
assert.NotNil(t, tw.Style())
assert.Equal(t, StyleBold, *tw.Style())
}
func TestTable_AppendFooter(t *testing.T) {
table := Table{}
assert.Equal(t, 0, len(table.rowsFooterRaw))
table.AppendFooter([]interface{}{})
assert.Equal(t, 0, table.Length())
assert.Equal(t, 1, len(table.rowsFooterRaw))
assert.Equal(t, 0, len(table.rowsHeaderRaw))
table.AppendFooter([]interface{}{})
assert.Equal(t, 0, table.Length())
assert.Equal(t, 2, len(table.rowsFooterRaw))
assert.Equal(t, 0, len(table.rowsHeaderRaw))
table.AppendFooter([]interface{}{}, RowConfig{AutoMerge: true})
assert.Equal(t, 0, table.Length())
assert.Equal(t, 3, len(table.rowsFooterRaw))
assert.Equal(t, 0, len(table.rowsHeaderRaw))
assert.False(t, table.rowsFooterConfigMap[0].AutoMerge)
assert.False(t, table.rowsFooterConfigMap[1].AutoMerge)
assert.True(t, table.rowsFooterConfigMap[2].AutoMerge)
}
func TestTable_AppendHeader(t *testing.T) {
table := Table{}
assert.Equal(t, 0, len(table.rowsHeaderRaw))
table.AppendHeader([]interface{}{})
assert.Equal(t, 0, table.Length())
assert.Equal(t, 0, len(table.rowsFooterRaw))
assert.Equal(t, 1, len(table.rowsHeaderRaw))
table.AppendHeader([]interface{}{})
assert.Equal(t, 0, table.Length())
assert.Equal(t, 0, len(table.rowsFooterRaw))
assert.Equal(t, 2, len(table.rowsHeaderRaw))
table.AppendHeader([]interface{}{}, RowConfig{AutoMerge: true})
assert.Equal(t, 0, table.Length())
assert.Equal(t, 0, len(table.rowsFooterRaw))
assert.Equal(t, 3, len(table.rowsHeaderRaw))
assert.False(t, table.rowsHeaderConfigMap[0].AutoMerge)
assert.False(t, table.rowsHeaderConfigMap[1].AutoMerge)
assert.True(t, table.rowsHeaderConfigMap[2].AutoMerge)
}
func TestTable_AppendRow(t *testing.T) {
table := Table{}
assert.Equal(t, 0, table.Length())
table.AppendRow([]interface{}{})
assert.Equal(t, 1, table.Length())
assert.Equal(t, 0, len(table.rowsFooter))
assert.Equal(t, 0, len(table.rowsHeader))
table.AppendRow([]interface{}{})
assert.Equal(t, 2, table.Length())
assert.Equal(t, 0, len(table.rowsFooter))
assert.Equal(t, 0, len(table.rowsHeader))
table.AppendRow([]interface{}{}, RowConfig{AutoMerge: true})
assert.Equal(t, 3, table.Length())
assert.Equal(t, 0, len(table.rowsFooterRaw))
assert.Equal(t, 0, len(table.rowsHeaderRaw))
assert.False(t, table.rowsConfigMap[0].AutoMerge)
assert.False(t, table.rowsConfigMap[1].AutoMerge)
assert.True(t, table.rowsConfigMap[2].AutoMerge)
}
func TestTable_AppendRows(t *testing.T) {
table := Table{}
assert.Equal(t, 0, table.Length())
table.AppendRows([]Row{{}})
assert.Equal(t, 1, table.Length())
assert.Equal(t, 0, len(table.rowsFooter))
assert.Equal(t, 0, len(table.rowsHeader))
table.AppendRows([]Row{{}})
assert.Equal(t, 2, table.Length())
assert.Equal(t, 0, len(table.rowsFooter))
assert.Equal(t, 0, len(table.rowsHeader))
table.AppendRows([]Row{{}, {}}, RowConfig{AutoMerge: true})
assert.Equal(t, 4, table.Length())
assert.Equal(t, 0, len(table.rowsFooterRaw))
assert.Equal(t, 0, len(table.rowsHeaderRaw))
assert.False(t, table.rowsConfigMap[0].AutoMerge)
assert.False(t, table.rowsConfigMap[1].AutoMerge)
assert.True(t, table.rowsConfigMap[2].AutoMerge)
assert.True(t, table.rowsConfigMap[3].AutoMerge)
}
func TestTable_Length(t *testing.T) {
table := Table{}
assert.Zero(t, table.Length())
table.AppendRow(testRows[0])
assert.Equal(t, 1, table.Length())
table.AppendRow(testRows[1])
assert.Equal(t, 2, table.Length())
table.AppendHeader(testHeader)
assert.Equal(t, 2, table.Length())
}
func TestTable_ResetFooters(t *testing.T) {
table := Table{}
table.AppendFooter(testFooter)
assert.NotEmpty(t, table.rowsFooterRaw)
table.ResetFooters()
assert.Empty(t, table.rowsFooterRaw)
}
func TestTable_ResetHeaders(t *testing.T) {
table := Table{}
table.AppendHeader(testHeader)
assert.NotEmpty(t, table.rowsHeaderRaw)
table.ResetHeaders()
assert.Empty(t, table.rowsHeaderRaw)
}
func TestTable_ResetRows(t *testing.T) {
table := Table{}
table.AppendRows(testRows)
assert.NotEmpty(t, table.rowsRaw)
table.ResetRows()
assert.Empty(t, table.rowsRaw)
}
func TestTable_SetAllowedRowLength(t *testing.T) {
table := Table{}
table.AppendRows(testRows)
table.SetStyle(styleTest)
expectedOutWithNoRowLimit := `(-----^--------^-----------^------^-----------------------------)
[< 1>|<Arya >|<Stark >|<3000>|< >]
[< 20>|<Jon >|<Snow >|<2000>|<You know nothing, Jon Snow!>]
[<300>|<Tyrion>|<Lannister>|<5000>|< >]
\-----v--------v-----------v------v-----------------------------/`
assert.Zero(t, table.allowedRowLength)
assert.Equal(t, expectedOutWithNoRowLimit, table.Render())
table.SetAllowedRowLength(utf8.RuneCountInString(table.style.Box.UnfinishedRow))
assert.Equal(t, utf8.RuneCountInString(table.style.Box.UnfinishedRow), table.allowedRowLength)
assert.Equal(t, "", table.Render())
table.SetAllowedRowLength(5)
expectedOutWithRowLimit := `( ~~~
[ ~~~
[ ~~~
[ ~~~
\ ~~~`
assert.Equal(t, 5, table.allowedRowLength)
assert.Equal(t, expectedOutWithRowLimit, table.Render())
table.SetAllowedRowLength(30)
expectedOutWithRowLimit = `(-----^--------^---------- ~~~
[< 1>|<Arya >|<Stark ~~~
[< 20>|<Jon >|<Snow ~~~
[<300>|<Tyrion>|<Lannister ~~~
\-----v--------v---------- ~~~`
assert.Equal(t, 30, table.allowedRowLength)
assert.Equal(t, expectedOutWithRowLimit, table.Render())
table.SetAllowedRowLength(300)
assert.Equal(t, 300, table.allowedRowLength)
assert.Equal(t, expectedOutWithNoRowLimit, table.Render())
}
func TestTable_SetAutoIndex(t *testing.T) {
table := Table{}
table.AppendRows(testRows)
table.SetStyle(styleTest)
expectedOut := `(-----^--------^-----------^------^-----------------------------)
[< 1>|<Arya >|<Stark >|<3000>|< >]
[< 20>|<Jon >|<Snow >|<2000>|<You know nothing, Jon Snow!>]
[<300>|<Tyrion>|<Lannister>|<5000>|< >]
\-----v--------v-----------v------v-----------------------------/`
assert.False(t, table.autoIndex)
assert.Equal(t, expectedOut, table.Render())
table.SetAutoIndex(true)
expectedOut = `(---^-----^--------^-----------^------^-----------------------------)
[< >|< A>|< B >|< C >|< D>|< E >]
{---+-----+--------+-----------+------+-----------------------------}
[<1>|< 1>|<Arya >|<Stark >|<3000>|< >]
[<2>|< 20>|<Jon >|<Snow >|<2000>|<You know nothing, Jon Snow!>]
[<3>|<300>|<Tyrion>|<Lannister>|<5000>|< >]
\---v-----v--------v-----------v------v-----------------------------/`
assert.True(t, table.autoIndex)
assert.Equal(t, expectedOut, table.Render())
table.AppendHeader(testHeader)
expectedOut = `(---^-----^------------^-----------^--------^-----------------------------)
[< >|< #>|<FIRST NAME>|<LAST NAME>|<SALARY>|< >]
{---+-----+------------+-----------+--------+-----------------------------}
[<1>|< 1>|<Arya >|<Stark >|< 3000>|< >]
[<2>|< 20>|<Jon >|<Snow >|< 2000>|<You know nothing, Jon Snow!>]
[<3>|<300>|<Tyrion >|<Lannister>|< 5000>|< >]
\---v-----v------------v-----------v--------v-----------------------------/`
assert.True(t, table.autoIndex)
assert.Equal(t, expectedOut, table.Render())
table.AppendRow(testRowMultiLine)
expectedOut = `(---^-----^------------^-----------^--------^-----------------------------)
[< >|< #>|<FIRST NAME>|<LAST NAME>|<SALARY>|< >]
{---+-----+------------+-----------+--------+-----------------------------}
[<1>|< 1>|<Arya >|<Stark >|< 3000>|< >]
[<2>|< 20>|<Jon >|<Snow >|< 2000>|<You know nothing, Jon Snow!>]
[<3>|<300>|<Tyrion >|<Lannister>|< 5000>|< >]
[<4>|< 0>|<Winter >|<Is >|< 0>|<Coming. >]
[< >|< >|< >|< >|< >|<The North Remembers! >]
[< >|< >|< >|< >|< >|<This is known. >]
\---v-----v------------v-----------v--------v-----------------------------/`
assert.Equal(t, expectedOut, table.Render())
table.SetStyle(StyleLight)
expectedOut = `
# FIRST NAME LAST NAME SALARY
1 1 Arya Stark 3000
2 20 Jon Snow 2000 You know nothing, Jon Snow!
3 300 Tyrion Lannister 5000
4 0 Winter Is 0 Coming.
The North Remembers!
This is known.
`
assert.Equal(t, expectedOut, table.Render())
}
func TestTable_SetCaption(t *testing.T) {
table := Table{}
assert.Empty(t, table.caption)
table.SetCaption(testCaption)
assert.NotEmpty(t, table.caption)
assert.Equal(t, testCaption, table.caption)
}
func TestTable_SetColumnConfigs(t *testing.T) {
table := Table{}
assert.Empty(t, table.columnConfigs)
table.SetColumnConfigs([]ColumnConfig{{}, {}, {}})
assert.NotEmpty(t, table.columnConfigs)
assert.Equal(t, 3, len(table.columnConfigs))
}
func TestTable_SetHTMLCSSClass(t *testing.T) {
table := Table{}
table.AppendRow(testRows[0])
expectedHTML := `<table class="` + DefaultHTMLCSSClass + `">
<tbody>
<tr>
<td align="right">1</td>
<td>Arya</td>
<td>Stark</td>
<td align="right">3000</td>
</tr>
</tbody>
</table>`
assert.Equal(t, "", table.htmlCSSClass)
assert.Equal(t, expectedHTML, table.RenderHTML())
table.SetHTMLCSSClass(testCSSClass)
assert.Equal(t, testCSSClass, table.htmlCSSClass)
assert.Equal(t, strings.Replace(expectedHTML, DefaultHTMLCSSClass, testCSSClass, -1), table.RenderHTML())
}
func TestTable_SetOutputMirror(t *testing.T) {
table := Table{}
table.AppendRow(testRows[0])
expectedOut := `+---+------+-------+------+
| 1 | Arya | Stark | 3000 |
+---+------+-------+------+`
assert.Equal(t, nil, table.outputMirror)
assert.Equal(t, expectedOut, table.Render())
mockOutputMirror := &myMockOutputMirror{}
table.SetOutputMirror(mockOutputMirror)
assert.Equal(t, mockOutputMirror, table.outputMirror)
assert.Equal(t, expectedOut, table.Render())
assert.Equal(t, expectedOut+"\n", mockOutputMirror.mirroredOutput)
}
func TestTable_SePageSize(t *testing.T) {
table := Table{}
assert.Equal(t, 0, table.pageSize)
table.SetPageSize(13)
assert.Equal(t, 13, table.pageSize)
}
func TestTable_SortByColumn(t *testing.T) {
table := Table{}
assert.Empty(t, table.sortBy)
table.SortBy([]SortBy{{Name: "#", Mode: Asc}})
assert.Equal(t, 1, len(table.sortBy))
table.SortBy([]SortBy{{Name: "First Name", Mode: Dsc}, {Name: "Last Name", Mode: Asc}})
assert.Equal(t, 2, len(table.sortBy))
}
func TestTable_SetStyle(t *testing.T) {
table := Table{}
assert.NotNil(t, table.Style())
assert.Equal(t, StyleDefault, *table.Style())
table.SetStyle(StyleDefault)
assert.NotNil(t, table.Style())
assert.Equal(t, StyleDefault, *table.Style())
}

69
helper/table/util.go Normal file
View File

@ -0,0 +1,69 @@
package table
import (
"reflect"
)
// AutoIndexColumnID returns a unique Column ID/Name for the given Column Number.
// The functionality is similar to what you get in an Excel spreadsheet w.r.t.
// the Column ID/Name.
func AutoIndexColumnID(colIdx int) string {
charIdx := colIdx % 26
out := string(rune(65 + charIdx))
colIdx = colIdx / 26
if colIdx > 0 {
return AutoIndexColumnID(colIdx-1) + out
}
return out
}
// WidthEnforcer is a function that helps enforce a width condition on a string.
type WidthEnforcer func(col string, maxLen int) string
// widthEnforcerNone returns the input string as is without any modifications.
func widthEnforcerNone(col string, maxLen int) string {
return col
}
// isNumber returns true if the argument is a numeric type; false otherwise.
func isNumber(x interface{}) bool {
if x == nil {
return false
}
switch reflect.TypeOf(x).Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
}
return false
}
type mergedColumnIndices map[int]map[int]bool
func (m mergedColumnIndices) mergedLength(colIdx int, maxColumnLengths []int) int {
mergedLength := maxColumnLengths[colIdx]
for otherColIdx := range m[colIdx] {
mergedLength += maxColumnLengths[otherColIdx]
}
return mergedLength
}
func (m mergedColumnIndices) len(colIdx int) int {
return len(m[colIdx]) + 1
}
func (m mergedColumnIndices) safeAppend(colIdx, otherColIdx int) {
// map
if m[colIdx] == nil {
m[colIdx] = make(map[int]bool)
}
m[colIdx][otherColIdx] = true
// reverse map
if m[otherColIdx] == nil {
m[otherColIdx] = make(map[int]bool)
}
m[otherColIdx][colIdx] = true
}

53
helper/table/util_test.go Normal file
View File

@ -0,0 +1,53 @@
package table
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func ExampleAutoIndexColumnID() {
fmt.Printf("AutoIndexColumnID( 0): \"%s\"\n", AutoIndexColumnID(0))
fmt.Printf("AutoIndexColumnID( 1): \"%s\"\n", AutoIndexColumnID(1))
fmt.Printf("AutoIndexColumnID( 2): \"%s\"\n", AutoIndexColumnID(2))
fmt.Printf("AutoIndexColumnID( 25): \"%s\"\n", AutoIndexColumnID(25))
fmt.Printf("AutoIndexColumnID( 26): \"%s\"\n", AutoIndexColumnID(26))
fmt.Printf("AutoIndexColumnID( 702): \"%s\"\n", AutoIndexColumnID(702))
fmt.Printf("AutoIndexColumnID(18278): \"%s\"\n", AutoIndexColumnID(18278))
// Output: AutoIndexColumnID( 0): "A"
// AutoIndexColumnID( 1): "B"
// AutoIndexColumnID( 2): "C"
// AutoIndexColumnID( 25): "Z"
// AutoIndexColumnID( 26): "AA"
// AutoIndexColumnID( 702): "AAA"
// AutoIndexColumnID(18278): "AAAA"
}
func TestAutoIndexColumnID(t *testing.T) {
assert.Equal(t, "A", AutoIndexColumnID(0))
assert.Equal(t, "Z", AutoIndexColumnID(25))
assert.Equal(t, "AA", AutoIndexColumnID(26))
assert.Equal(t, "ZZ", AutoIndexColumnID(701))
assert.Equal(t, "AAA", AutoIndexColumnID(702))
assert.Equal(t, "ZZZ", AutoIndexColumnID(18277))
assert.Equal(t, "AAAA", AutoIndexColumnID(18278))
}
func TestIsNumber(t *testing.T) {
assert.True(t, isNumber(int(1)))
assert.True(t, isNumber(int8(1)))
assert.True(t, isNumber(int16(1)))
assert.True(t, isNumber(int32(1)))
assert.True(t, isNumber(int64(1)))
assert.True(t, isNumber(uint(1)))
assert.True(t, isNumber(uint8(1)))
assert.True(t, isNumber(uint16(1)))
assert.True(t, isNumber(uint32(1)))
assert.True(t, isNumber(uint64(1)))
assert.True(t, isNumber(float32(1)))
assert.True(t, isNumber(float64(1)))
assert.False(t, isNumber("1"))
assert.False(t, isNumber(nil))
}

43
helper/table/writer.go Normal file
View File

@ -0,0 +1,43 @@
package table
import (
"io"
)
// Writer declares the interfaces that can be used to setup and render a table.
type Writer interface {
AppendFooter(row Row, configs ...RowConfig)
AppendHeader(row Row, configs ...RowConfig)
AppendRow(row Row, configs ...RowConfig)
AppendRows(rows []Row, configs ...RowConfig)
AppendSeparator()
Length() int
Render() string
RenderCSV() string
RenderHTML() string
RenderMarkdown() string
ResetFooters()
ResetHeaders()
ResetRows()
SetAllowedRowLength(length int)
SetAutoIndex(autoIndex bool)
SetCaption(format string, a ...interface{})
SetColumnConfigs(configs []ColumnConfig)
SetIndexColumn(colNum int)
SetOutputMirror(mirror io.Writer)
SetPageSize(numLines int)
SetRowPainter(painter RowPainter)
SetStyle(style Style)
SetTitle(format string, a ...interface{})
SortBy(sortBy []SortBy)
Style() *Style
SuppressEmptyColumns()
// deprecated; in favor of Style().HTML.CSSClass
SetHTMLCSSClass(cssClass string)
}
// NewWriter initializes and returns a Writer.
func NewWriter() Writer {
return &Table{}
}

View File

@ -0,0 +1,78 @@
package table
import (
"fmt"
"git.hexq.cn/tiglog/golib/helper/text"
)
func Example_simple() {
// simple table with zero customizations
tw := NewWriter()
// append a header row
tw.AppendHeader(Row{"#", "First Name", "Last Name", "Salary"})
// append some data rows
tw.AppendRows([]Row{
{1, "Arya", "Stark", 3000},
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
{300, "Tyrion", "Lannister", 5000},
})
// append a footer row
tw.AppendFooter(Row{"", "", "Total", 10000})
// render it
fmt.Printf("Table without any customizations:\n%s", tw.Render())
// Output: Table without any customizations:
// +-----+------------+-----------+--------+-----------------------------+
// | # | FIRST NAME | LAST NAME | SALARY | |
// +-----+------------+-----------+--------+-----------------------------+
// | 1 | Arya | Stark | 3000 | |
// | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
// | 300 | Tyrion | Lannister | 5000 | |
// +-----+------------+-----------+--------+-----------------------------+
// | | | TOTAL | 10000 | |
// +-----+------------+-----------+--------+-----------------------------+
}
func Example_styled() {
// table with some amount of customization
tw := NewWriter()
// append a header row
tw.AppendHeader(Row{"First Name", "Last Name", "Salary"})
// append some data rows
tw.AppendRows([]Row{
{"Jaime", "Lannister", 5000},
{"Arya", "Stark", 3000, "A girl has no name."},
{"Sansa", "Stark", 4000},
{"Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
{"Tyrion", "Lannister", 5000, "A Lannister always pays his debts."},
})
// append a footer row
tw.AppendFooter(Row{"", "Total", 10000})
// auto-index rows
tw.SetAutoIndex(true)
// sort by last name and then by salary
tw.SortBy([]SortBy{{Name: "Last Name", Mode: Dsc}, {Name: "Salary", Mode: AscNumeric}})
// use a ready-to-use style
tw.SetStyle(StyleLight)
// customize the style and change some stuff
tw.Style().Format.Header = text.FormatLower
tw.Style().Format.Row = text.FormatLower
tw.Style().Format.Footer = text.FormatLower
tw.Style().Options.SeparateColumns = false
// render it
fmt.Printf("Table with customizations:\n%s", tw.Render())
// Output: Table with customizations:
// ┌──────────────────────────────────────────────────────────────────────┐
// │ first name last name salary │
// ├──────────────────────────────────────────────────────────────────────┤
// │ 1 arya stark 3000 a girl has no name. │
// │ 2 sansa stark 4000 │
// │ 3 jon snow 2000 you know nothing, jon snow! │
// │ 4 jaime lannister 5000 │
// │ 5 tyrion lannister 5000 a lannister always pays his debts. │
// ├──────────────────────────────────────────────────────────────────────┤
// │ total 10000 │
// └──────────────────────────────────────────────────────────────────────┘
}

137
helper/text/align.go Normal file
View File

@ -0,0 +1,137 @@
package text
import (
"fmt"
"strconv"
"strings"
"unicode/utf8"
)
// Align denotes how text is to be aligned horizontally.
type Align int
// Align enumerations
const (
AlignDefault Align = iota // same as AlignLeft
AlignLeft // "left "
AlignCenter // " center "
AlignJustify // "justify it"
AlignRight // " right"
)
// Apply aligns the text as directed. For ex.:
// * AlignDefault.Apply("Jon Snow", 12) returns "Jon Snow "
// * AlignLeft.Apply("Jon Snow", 12) returns "Jon Snow "
// * AlignCenter.Apply("Jon Snow", 12) returns " Jon Snow "
// * AlignJustify.Apply("Jon Snow", 12) returns "Jon Snow"
// * AlignRight.Apply("Jon Snow", 12) returns " Jon Snow"
func (a Align) Apply(text string, maxLength int) string {
text = a.trimString(text)
sLen := utf8.RuneCountInString(text)
sLenWoE := RuneWidthWithoutEscSequences(text)
numEscChars := sLen - sLenWoE
// now, align the text
switch a {
case AlignDefault, AlignLeft:
return fmt.Sprintf("%-"+strconv.Itoa(maxLength+numEscChars)+"s", text)
case AlignCenter:
if sLenWoE < maxLength {
// left pad with half the number of spaces needed before using %text
return fmt.Sprintf("%"+strconv.Itoa(maxLength+numEscChars)+"s",
text+strings.Repeat(" ", int((maxLength-sLenWoE)/2)))
}
case AlignJustify:
return a.justifyText(text, sLenWoE, maxLength)
}
return fmt.Sprintf("%"+strconv.Itoa(maxLength+numEscChars)+"s", text)
}
// HTMLProperty returns the equivalent HTML horizontal-align tag property.
func (a Align) HTMLProperty() string {
switch a {
case AlignLeft:
return "align=\"left\""
case AlignCenter:
return "align=\"center\""
case AlignJustify:
return "align=\"justify\""
case AlignRight:
return "align=\"right\""
default:
return ""
}
}
// MarkdownProperty returns the equivalent Markdown horizontal-align separator.
func (a Align) MarkdownProperty() string {
switch a {
case AlignLeft:
return ":--- "
case AlignCenter:
return ":---:"
case AlignRight:
return " ---:"
default:
return " --- "
}
}
func (a Align) justifyText(text string, textLength int, maxLength int) string {
// split the text into individual words
wordsUnfiltered := strings.Split(text, " ")
words := Filter(wordsUnfiltered, func(item string) bool {
return item != ""
})
// empty string implies spaces for maxLength
if len(words) == 0 {
return strings.Repeat(" ", maxLength)
}
// get the number of spaces to insert into the text
numSpacesNeeded := maxLength - textLength + strings.Count(text, " ")
numSpacesNeededBetweenWords := 0
if len(words) > 1 {
numSpacesNeededBetweenWords = numSpacesNeeded / (len(words) - 1)
}
// create the output string word by word with spaces in between
var outText strings.Builder
outText.Grow(maxLength)
for idx, word := range words {
if idx > 0 {
// insert spaces only after the first word
if idx == len(words)-1 {
// insert all the remaining space before the last word
outText.WriteString(strings.Repeat(" ", numSpacesNeeded))
numSpacesNeeded = 0
} else {
// insert the determined number of spaces between each word
outText.WriteString(strings.Repeat(" ", numSpacesNeededBetweenWords))
// and reduce the number of spaces needed after this
numSpacesNeeded -= numSpacesNeededBetweenWords
}
}
outText.WriteString(word)
if idx == len(words)-1 && numSpacesNeeded > 0 {
outText.WriteString(strings.Repeat(" ", numSpacesNeeded))
}
}
return outText.String()
}
func (a Align) trimString(text string) string {
switch a {
case AlignDefault, AlignLeft:
if strings.HasSuffix(text, " ") {
return strings.TrimRight(text, " ")
}
case AlignRight:
if strings.HasPrefix(text, " ") {
return strings.TrimLeft(text, " ")
}
default:
if strings.HasPrefix(text, " ") || strings.HasSuffix(text, " ") {
return strings.Trim(text, " ")
}
}
return text
}

138
helper/text/align_test.go Normal file
View File

@ -0,0 +1,138 @@
package text
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func ExampleAlign_Apply() {
fmt.Printf("AlignDefault: '%s'\n", AlignDefault.Apply("Jon Snow", 12))
fmt.Printf("AlignLeft : '%s'\n", AlignLeft.Apply("Jon Snow", 12))
fmt.Printf("AlignCenter : '%s'\n", AlignCenter.Apply("Jon Snow", 12))
fmt.Printf("AlignJustify: '%s'\n", AlignJustify.Apply("Jon Snow", 12))
fmt.Printf("AlignRight : '%s'\n", AlignRight.Apply("Jon Snow", 12))
// Output: AlignDefault: 'Jon Snow '
// AlignLeft : 'Jon Snow '
// AlignCenter : ' Jon Snow '
// AlignJustify: 'Jon Snow'
// AlignRight : ' Jon Snow'
}
func TestAlign_Apply(t *testing.T) {
// AlignDefault & AlignLeft are the same
assert.Equal(t, "Jon Snow ", AlignDefault.Apply("Jon Snow", 12))
assert.Equal(t, " Jon Snow ", AlignDefault.Apply(" Jon Snow", 12))
assert.Equal(t, " ", AlignDefault.Apply("", 12))
assert.Equal(t, "Jon Snow ", AlignLeft.Apply("Jon Snow ", 12))
assert.Equal(t, " Jon Snow ", AlignLeft.Apply(" Jon Snow ", 12))
assert.Equal(t, " ", AlignLeft.Apply("", 12))
// AlignCenter
assert.Equal(t, " Jon Snow ", AlignCenter.Apply("Jon Snow ", 12))
assert.Equal(t, " Jon Snow ", AlignCenter.Apply(" Jon Snow", 12))
assert.Equal(t, " Jon Snow ", AlignCenter.Apply(" Jon Snow ", 12))
assert.Equal(t, " ", AlignCenter.Apply("", 12))
// AlignJustify
assert.Equal(t, "Jon Snow", AlignJustify.Apply("Jon Snow", 12))
assert.Equal(t, "JS vs. DT", AlignJustify.Apply("JS vs. DT", 12))
assert.Equal(t, "JS is AT", AlignJustify.Apply("JS is AT", 12))
assert.Equal(t, "JS is AT", AlignJustify.Apply("JS is AT", 12))
assert.Equal(t, "JonSnow ", AlignJustify.Apply("JonSnow", 12))
assert.Equal(t, "JonSnow ", AlignJustify.Apply(" JonSnow", 12))
assert.Equal(t, " ", AlignJustify.Apply("", 12))
// Align Right
assert.Equal(t, " Jon Snow", AlignRight.Apply("Jon Snow", 12))
assert.Equal(t, " Jon Snow ", AlignRight.Apply("Jon Snow ", 12))
assert.Equal(t, " Jon Snow ", AlignRight.Apply(" Jon Snow ", 12))
assert.Equal(t, " ", AlignRight.Apply("", 12))
}
func TestAlign_Apply_ColoredText(t *testing.T) {
// AlignDefault & AlignLeft are the same
assert.Equal(t, "\x1b[33mJon Snow\x1b[0m ", AlignDefault.Apply("\x1b[33mJon Snow\x1b[0m", 12))
assert.Equal(t, "\x1b[33m Jon Snow\x1b[0m ", AlignDefault.Apply("\x1b[33m Jon Snow\x1b[0m", 12))
assert.Equal(t, "\x1b[33m\x1b[0m ", AlignDefault.Apply("\x1b[33m\x1b[0m", 12))
assert.Equal(t, "\x1b[33mJon Snow \x1b[0m ", AlignLeft.Apply("\x1b[33mJon Snow \x1b[0m", 12))
assert.Equal(t, "\x1b[33m Jon Snow \x1b[0m ", AlignLeft.Apply("\x1b[33m Jon Snow \x1b[0m", 12))
assert.Equal(t, "\x1b[33m\x1b[0m ", AlignLeft.Apply("\x1b[33m\x1b[0m", 12))
// AlignCenter
assert.Equal(t, " \x1b[33mJon Snow \x1b[0m ", AlignCenter.Apply("\x1b[33mJon Snow \x1b[0m", 12))
assert.Equal(t, " \x1b[33m Jon Snow\x1b[0m ", AlignCenter.Apply("\x1b[33m Jon Snow\x1b[0m", 12))
assert.Equal(t, " \x1b[33m Jon Snow \x1b[0m", AlignCenter.Apply("\x1b[33m Jon Snow \x1b[0m", 12))
assert.Equal(t, " \x1b[33m\x1b[0m ", AlignCenter.Apply("\x1b[33m\x1b[0m", 12))
// AlignJustify
assert.Equal(t, "\x1b[33mJon Snow\x1b[0m", AlignJustify.Apply("\x1b[33mJon Snow\x1b[0m", 12))
assert.Equal(t, "\x1b[33mJS vs. DT\x1b[0m", AlignJustify.Apply("\x1b[33mJS vs. DT\x1b[0m", 12))
assert.Equal(t, "\x1b[33mJS is AT\x1b[0m", AlignJustify.Apply("\x1b[33mJS is AT\x1b[0m", 12))
assert.Equal(t, "\x1b[33mJS is AT\x1b[0m", AlignJustify.Apply("\x1b[33mJS is AT\x1b[0m", 12))
assert.Equal(t, "\x1b[33mJonSnow\x1b[0m ", AlignJustify.Apply("\x1b[33mJonSnow\x1b[0m", 12))
assert.Equal(t, "\x1b[33m JonSnow\x1b[0m", AlignJustify.Apply("\x1b[33m JonSnow\x1b[0m", 12))
assert.Equal(t, "\x1b[33m\x1b[0m ", AlignJustify.Apply("\x1b[33m\x1b[0m", 12))
// Align Right
assert.Equal(t, " \x1b[33mJon Snow\x1b[0m", AlignRight.Apply("\x1b[33mJon Snow\x1b[0m", 12))
assert.Equal(t, " \x1b[33mJon Snow \x1b[0m", AlignRight.Apply("\x1b[33mJon Snow \x1b[0m", 12))
assert.Equal(t, " \x1b[33m Jon Snow \x1b[0m", AlignRight.Apply("\x1b[33m Jon Snow \x1b[0m", 12))
assert.Equal(t, " \x1b[33m\x1b[0m", AlignRight.Apply("\x1b[33m\x1b[0m", 12))
}
func ExampleAlign_HTMLProperty() {
fmt.Printf("AlignDefault: '%s'\n", AlignDefault.HTMLProperty())
fmt.Printf("AlignLeft : '%s'\n", AlignLeft.HTMLProperty())
fmt.Printf("AlignCenter : '%s'\n", AlignCenter.HTMLProperty())
fmt.Printf("AlignJustify: '%s'\n", AlignJustify.HTMLProperty())
fmt.Printf("AlignRight : '%s'\n", AlignRight.HTMLProperty())
// Output: AlignDefault: ''
// AlignLeft : 'align="left"'
// AlignCenter : 'align="center"'
// AlignJustify: 'align="justify"'
// AlignRight : 'align="right"'
}
func TestAlign_HTMLProperty(t *testing.T) {
aligns := map[Align]string{
AlignDefault: "",
AlignLeft: "left",
AlignCenter: "center",
AlignJustify: "justify",
AlignRight: "right",
}
for align, htmlStyle := range aligns {
assert.Contains(t, align.HTMLProperty(), htmlStyle)
}
}
func ExampleAlign_MarkdownProperty() {
fmt.Printf("AlignDefault: '%s'\n", AlignDefault.MarkdownProperty())
fmt.Printf("AlignLeft : '%s'\n", AlignLeft.MarkdownProperty())
fmt.Printf("AlignCenter : '%s'\n", AlignCenter.MarkdownProperty())
fmt.Printf("AlignJustify: '%s'\n", AlignJustify.MarkdownProperty())
fmt.Printf("AlignRight : '%s'\n", AlignRight.MarkdownProperty())
// Output: AlignDefault: ' --- '
// AlignLeft : ':--- '
// AlignCenter : ':---:'
// AlignJustify: ' --- '
// AlignRight : ' ---:'
}
func TestAlign_MarkdownProperty(t *testing.T) {
aligns := map[Align]string{
AlignDefault: " --- ",
AlignLeft: ":--- ",
AlignCenter: ":---:",
AlignJustify: " --- ",
AlignRight: " ---:",
}
for align, markdownSeparator := range aligns {
assert.Contains(t, align.MarkdownProperty(), markdownSeparator)
}
}

55
helper/text/ansi.go Normal file
View File

@ -0,0 +1,55 @@
package text
import "strings"
// ANSICodesSupported will be true on consoles where ANSI Escape Codes/Sequences
// are supported.
var ANSICodesSupported = areANSICodesSupported()
// Escape encodes the string with the ANSI Escape Sequence.
// For ex.:
// Escape("Ghost", "") == "Ghost"
// Escape("Ghost", "\x1b[91m") == "\x1b[91mGhost\x1b[0m"
// Escape("\x1b[94mGhost\x1b[0mLady", "\x1b[91m") == "\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m"
// Escape("Nymeria\x1b[94mGhost\x1b[0mLady", "\x1b[91m") == "\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m"
// Escape("Nymeria \x1b[94mGhost\x1b[0m Lady", "\x1b[91m") == "\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m"
func Escape(str string, escapeSeq string) string {
out := ""
if !strings.HasPrefix(str, EscapeStart) {
out += escapeSeq
}
out += strings.Replace(str, EscapeReset, EscapeReset+escapeSeq, -1)
if !strings.HasSuffix(out, EscapeReset) {
out += EscapeReset
}
if strings.Contains(out, escapeSeq+EscapeReset) {
out = strings.Replace(out, escapeSeq+EscapeReset, "", -1)
}
return out
}
// StripEscape strips all ANSI Escape Sequence from the string.
// For ex.:
// StripEscape("Ghost") == "Ghost"
// StripEscape("\x1b[91mGhost\x1b[0m") == "Ghost"
// StripEscape("\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m") == "GhostLady"
// StripEscape("\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m") == "NymeriaGhostLady"
// StripEscape("\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m") == "Nymeria Ghost Lady"
func StripEscape(str string) string {
var out strings.Builder
out.Grow(RuneWidthWithoutEscSequences(str))
isEscSeq := false
for _, sChr := range str {
if sChr == EscapeStartRune {
isEscSeq = true
}
if !isEscSeq {
out.WriteRune(sChr)
}
if isEscSeq && sChr == EscapeStopRune {
isEscSeq = false
}
}
return out.String()
}

50
helper/text/ansi_test.go Normal file
View File

@ -0,0 +1,50 @@
package text
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestEscape(t *testing.T) {
assert.Equal(t, "\x1b[91mGhost\x1b[0m", Escape("Ghost", FgHiRed.EscapeSeq()))
assert.Equal(t, "\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m", Escape(FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq()))
assert.Equal(t, "\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m", Escape("Nymeria"+FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq()))
assert.Equal(t, "\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m", Escape("Nymeria "+FgHiBlue.Sprint("Ghost")+" Lady", FgHiRed.EscapeSeq()))
}
func ExampleEscape() {
fmt.Printf("Escape(%#v, %#v) == %#v\n", "Ghost", "", Escape("Ghost", ""))
fmt.Printf("Escape(%#v, %#v) == %#v\n", "Ghost", FgHiRed.EscapeSeq(), Escape("Ghost", FgHiRed.EscapeSeq()))
fmt.Printf("Escape(%#v, %#v) == %#v\n", FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq(), Escape(FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq()))
fmt.Printf("Escape(%#v, %#v) == %#v\n", "Nymeria"+FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq(), Escape("Nymeria"+FgHiBlue.Sprint("Ghost")+"Lady", FgHiRed.EscapeSeq()))
fmt.Printf("Escape(%#v, %#v) == %#v\n", "Nymeria "+FgHiBlue.Sprint("Ghost")+" Lady", FgHiRed.EscapeSeq(), Escape("Nymeria "+FgHiBlue.Sprint("Ghost")+" Lady", FgHiRed.EscapeSeq()))
// Output: Escape("Ghost", "") == "Ghost"
// Escape("Ghost", "\x1b[91m") == "\x1b[91mGhost\x1b[0m"
// Escape("\x1b[94mGhost\x1b[0mLady", "\x1b[91m") == "\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m"
// Escape("Nymeria\x1b[94mGhost\x1b[0mLady", "\x1b[91m") == "\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m"
// Escape("Nymeria \x1b[94mGhost\x1b[0m Lady", "\x1b[91m") == "\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m"
}
func TestStripEscape(t *testing.T) {
assert.Equal(t, "Ghost", StripEscape(FgHiRed.Sprint("Ghost")))
assert.Equal(t, "GhostLady", StripEscape(FgHiBlue.Sprint("Ghost")+"Lady"))
assert.Equal(t, "NymeriaGhostLady", StripEscape("Nymeria"+FgHiBlue.Sprint("Ghost")+"Lady"))
assert.Equal(t, "Nymeria Ghost Lady", StripEscape("Nymeria "+FgHiBlue.Sprint("Ghost")+" Lady"))
}
func ExampleStripEscape() {
fmt.Printf("StripEscape(%#v) == %#v\n", "Ghost", StripEscape("Ghost"))
fmt.Printf("StripEscape(%#v) == %#v\n", "\x1b[91mGhost\x1b[0m", StripEscape("\x1b[91mGhost\x1b[0m"))
fmt.Printf("StripEscape(%#v) == %#v\n", "\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m", StripEscape("\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m"))
fmt.Printf("StripEscape(%#v) == %#v\n", "\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m", StripEscape("\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m"))
fmt.Printf("StripEscape(%#v) == %#v\n", "\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m", StripEscape("\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m"))
// Output: StripEscape("Ghost") == "Ghost"
// StripEscape("\x1b[91mGhost\x1b[0m") == "Ghost"
// StripEscape("\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m") == "GhostLady"
// StripEscape("\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m") == "NymeriaGhostLady"
// StripEscape("\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m") == "Nymeria Ghost Lady"
}

8
helper/text/ansi_unix.go Normal file
View File

@ -0,0 +1,8 @@
//go:build !windows
// +build !windows
package text
func areANSICodesSupported() bool {
return true
}

View File

@ -0,0 +1,32 @@
//go:build windows
// +build windows
package text
import (
"os"
"sync"
"golang.org/x/sys/windows"
)
var (
enableVTPMutex = sync.Mutex{}
)
func areANSICodesSupported() bool {
enableVTPMutex.Lock()
defer enableVTPMutex.Unlock()
outHandle := windows.Handle(os.Stdout.Fd())
var outMode uint32
if err := windows.GetConsoleMode(outHandle, &outMode); err == nil {
if outMode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 {
return true
}
if err := windows.SetConsoleMode(outHandle, outMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING); err == nil {
return true
}
}
return false
}

183
helper/text/color.go Normal file
View File

@ -0,0 +1,183 @@
package text
import (
"fmt"
"sort"
"strconv"
"strings"
"sync"
)
var (
colorsEnabled = areANSICodesSupported()
)
// DisableColors (forcefully) disables color coding globally.
func DisableColors() {
colorsEnabled = false
}
// EnableColors (forcefully) enables color coding globally.
func EnableColors() {
colorsEnabled = true
}
// The logic here is inspired from github.com/fatih/color; the following is
// the the bare minimum logic required to print Colored to the console.
// The differences:
// * This one caches the escape sequences for cases with multiple colors
// * This one handles cases where the incoming already has colors in the
// form of escape sequences; in which case, text that does not have any
// escape sequences are colored/escaped
// Color represents a single color to render with.
type Color int
// Base colors -- attributes in reality
const (
Reset Color = iota
Bold
Faint
Italic
Underline
BlinkSlow
BlinkRapid
ReverseVideo
Concealed
CrossedOut
)
// Foreground colors
const (
FgBlack Color = iota + 30
FgRed
FgGreen
FgYellow
FgBlue
FgMagenta
FgCyan
FgWhite
)
// Foreground Hi-Intensity colors
const (
FgHiBlack Color = iota + 90
FgHiRed
FgHiGreen
FgHiYellow
FgHiBlue
FgHiMagenta
FgHiCyan
FgHiWhite
)
// Background colors
const (
BgBlack Color = iota + 40
BgRed
BgGreen
BgYellow
BgBlue
BgMagenta
BgCyan
BgWhite
)
// Background Hi-Intensity colors
const (
BgHiBlack Color = iota + 100
BgHiRed
BgHiGreen
BgHiYellow
BgHiBlue
BgHiMagenta
BgHiCyan
BgHiWhite
)
// EscapeSeq returns the ANSI escape sequence for the color.
func (c Color) EscapeSeq() string {
return EscapeStart + strconv.Itoa(int(c)) + EscapeStop
}
// HTMLProperty returns the "class" attribute for the color.
func (c Color) HTMLProperty() string {
out := ""
if class, ok := colorCSSClassMap[c]; ok {
out = fmt.Sprintf("class=\"%s\"", class)
}
return out
}
// Sprint colorizes and prints the given string(s).
func (c Color) Sprint(a ...interface{}) string {
return colorize(fmt.Sprint(a...), c.EscapeSeq())
}
// Sprintf formats and colorizes and prints the given string(s).
func (c Color) Sprintf(format string, a ...interface{}) string {
return colorize(fmt.Sprintf(format, a...), c.EscapeSeq())
}
// Colors represents an array of Color objects to render with.
// Example: Colors{FgCyan, BgBlack}
type Colors []Color
var (
// colorsSeqMap caches the escape sequence for a set of colors
colorsSeqMap = sync.Map{}
)
// EscapeSeq returns the ANSI escape sequence for the colors set.
func (c Colors) EscapeSeq() string {
if len(c) == 0 {
return ""
}
colorsKey := fmt.Sprintf("%#v", c)
escapeSeq, ok := colorsSeqMap.Load(colorsKey)
if !ok || escapeSeq == "" {
colorNums := make([]string, len(c))
for idx, color := range c {
colorNums[idx] = strconv.Itoa(int(color))
}
escapeSeq = EscapeStart + strings.Join(colorNums, ";") + EscapeStop
colorsSeqMap.Store(colorsKey, escapeSeq)
}
return escapeSeq.(string)
}
// HTMLProperty returns the "class" attribute for the colors.
func (c Colors) HTMLProperty() string {
if len(c) == 0 {
return ""
}
var classes []string
for _, color := range c {
if class, ok := colorCSSClassMap[color]; ok {
classes = append(classes, class)
}
}
if len(classes) > 1 {
sort.Strings(classes)
}
return fmt.Sprintf("class=\"%s\"", strings.Join(classes, " "))
}
// Sprint colorizes and prints the given string(s).
func (c Colors) Sprint(a ...interface{}) string {
return colorize(fmt.Sprint(a...), c.EscapeSeq())
}
// Sprintf formats and colorizes and prints the given string(s).
func (c Colors) Sprintf(format string, a ...interface{}) string {
return colorize(fmt.Sprintf(format, a...), c.EscapeSeq())
}
func colorize(s string, escapeSeq string) string {
if !colorsEnabled || escapeSeq == "" {
return s
}
return Escape(s, escapeSeq)
}

48
helper/text/color_html.go Normal file
View File

@ -0,0 +1,48 @@
package text
var (
// colorCSSClassMap contains the equivalent CSS-class for all colors
colorCSSClassMap = map[Color]string{
Bold: "bold",
Faint: "faint",
Italic: "italic",
Underline: "underline",
BlinkSlow: "blink-slow",
BlinkRapid: "blink-rapid",
ReverseVideo: "reverse-video",
Concealed: "concealed",
CrossedOut: "crossed-out",
FgBlack: "fg-black",
FgRed: "fg-red",
FgGreen: "fg-green",
FgYellow: "fg-yellow",
FgBlue: "fg-blue",
FgMagenta: "fg-magenta",
FgCyan: "fg-cyan",
FgWhite: "fg-white",
FgHiBlack: "fg-hi-black",
FgHiRed: "fg-hi-red",
FgHiGreen: "fg-hi-green",
FgHiYellow: "fg-hi-yellow",
FgHiBlue: "fg-hi-blue",
FgHiMagenta: "fg-hi-magenta",
FgHiCyan: "fg-hi-cyan",
FgHiWhite: "fg-hi-white",
BgBlack: "bg-black",
BgRed: "bg-red",
BgGreen: "bg-green",
BgYellow: "bg-yellow",
BgBlue: "bg-blue",
BgMagenta: "bg-magenta",
BgCyan: "bg-cyan",
BgWhite: "bg-white",
BgHiBlack: "bg-hi-black",
BgHiRed: "bg-hi-red",
BgHiGreen: "bg-hi-green",
BgHiYellow: "bg-hi-yellow",
BgHiBlue: "bg-hi-blue",
BgHiMagenta: "bg-hi-magenta",
BgHiCyan: "bg-hi-cyan",
BgHiWhite: "bg-hi-white",
}
)

158
helper/text/color_test.go Normal file
View File

@ -0,0 +1,158 @@
package text
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func init() {
EnableColors()
}
func TestColor_EnableAndDisable(t *testing.T) {
defer EnableColors()
EnableColors()
assert.Equal(t, "\x1b[31mtest\x1b[0m", FgRed.Sprint("test"))
DisableColors()
assert.Equal(t, "test", FgRed.Sprint("test"))
EnableColors()
assert.Equal(t, "\x1b[31mtest\x1b[0m", FgRed.Sprint("test"))
}
func ExampleColor_EscapeSeq() {
fmt.Printf("Black Background: %#v\n", BgBlack.EscapeSeq())
fmt.Printf("Black Foreground: %#v\n", FgBlack.EscapeSeq())
// Output: Black Background: "\x1b[40m"
// Black Foreground: "\x1b[30m"
}
func TestColor_EscapeSeq(t *testing.T) {
assert.Equal(t, "\x1b[40m", BgBlack.EscapeSeq())
}
func ExampleColor_HTMLProperty() {
fmt.Printf("Bold: %#v\n", Bold.HTMLProperty())
fmt.Printf("Black Background: %#v\n", BgBlack.HTMLProperty())
fmt.Printf("Black Foreground: %#v\n", FgBlack.HTMLProperty())
// Output: Bold: "class=\"bold\""
// Black Background: "class=\"bg-black\""
// Black Foreground: "class=\"fg-black\""
}
func TestColor_HTMLProperty(t *testing.T) {
assert.Equal(t, "class=\"bold\"", Bold.HTMLProperty())
assert.Equal(t, "class=\"bg-black\"", BgBlack.HTMLProperty())
assert.Equal(t, "class=\"fg-black\"", FgBlack.HTMLProperty())
}
func ExampleColor_Sprint() {
fmt.Printf("%#v\n", BgBlack.Sprint("Black Background"))
fmt.Printf("%#v\n", FgBlack.Sprint("Black Foreground"))
// Output: "\x1b[40mBlack Background\x1b[0m"
// "\x1b[30mBlack Foreground\x1b[0m"
}
func TestColor_Sprint(t *testing.T) {
assert.Equal(t, "\x1b[31mtest true\x1b[0m", FgRed.Sprint("test ", true))
assert.Equal(t, "\x1b[32mtest\x1b[0m\x1b[31mtrue\x1b[0m", FgRed.Sprint("\x1b[32mtest\x1b[0m", true))
assert.Equal(t, "\x1b[32mtest true\x1b[0m", FgRed.Sprint("\x1b[32mtest ", true))
assert.Equal(t, "\x1b[32mtest\x1b[0m\x1b[31m \x1b[0m", FgRed.Sprint("\x1b[32mtest\x1b[0m "))
assert.Equal(t, "\x1b[32mtest\x1b[0m", FgRed.Sprint("\x1b[32mtest\x1b[0m"))
}
func ExampleColor_Sprintf() {
fmt.Printf("%#v\n", BgBlack.Sprintf("%s %s", "Black", "Background"))
fmt.Printf("%#v\n", FgBlack.Sprintf("%s %s", "Black", "Foreground"))
// Output: "\x1b[40mBlack Background\x1b[0m"
// "\x1b[30mBlack Foreground\x1b[0m"
}
func TestColor_Sprintf(t *testing.T) {
assert.Equal(t, "\x1b[31mtest true\x1b[0m", FgRed.Sprintf("test %s", "true"))
}
func ExampleColors_EscapeSeq() {
fmt.Printf("Black Background: %#v\n", Colors{BgBlack}.EscapeSeq())
fmt.Printf("Black Foreground: %#v\n", Colors{FgBlack}.EscapeSeq())
fmt.Printf("Black Background, White Foreground: %#v\n", Colors{BgBlack, FgWhite}.EscapeSeq())
fmt.Printf("Black Foreground, White Background: %#v\n", Colors{FgBlack, BgWhite}.EscapeSeq())
// Output: Black Background: "\x1b[40m"
// Black Foreground: "\x1b[30m"
// Black Background, White Foreground: "\x1b[40;37m"
// Black Foreground, White Background: "\x1b[30;47m"
}
func TestColors_EscapeSeq(t *testing.T) {
assert.Equal(t, "", Colors{}.EscapeSeq())
assert.Equal(t, "\x1b[40;37m", Colors{BgBlack, FgWhite}.EscapeSeq())
}
func ExampleColors_HTMLProperty() {
fmt.Printf("Black Background: %#v\n", Colors{BgBlack}.HTMLProperty())
fmt.Printf("Black Foreground: %#v\n", Colors{FgBlack}.HTMLProperty())
fmt.Printf("Black Background, White Foreground: %#v\n", Colors{BgBlack, FgWhite}.HTMLProperty())
fmt.Printf("Black Foreground, White Background: %#v\n", Colors{FgBlack, BgWhite}.HTMLProperty())
fmt.Printf("Bold Italic Underline Red Text: %#v\n", Colors{Bold, Italic, Underline, FgRed}.HTMLProperty())
// Output: Black Background: "class=\"bg-black\""
// Black Foreground: "class=\"fg-black\""
// Black Background, White Foreground: "class=\"bg-black fg-white\""
// Black Foreground, White Background: "class=\"bg-white fg-black\""
// Bold Italic Underline Red Text: "class=\"bold fg-red italic underline\""
}
func TestColors_HTMLProperty(t *testing.T) {
assert.Equal(t, "", Colors{}.HTMLProperty())
assert.Equal(t, "class=\"bg-black fg-white\"", Colors{BgBlack, FgWhite}.HTMLProperty())
assert.Equal(t, "class=\"bold fg-red\"", Colors{Bold, FgRed}.HTMLProperty())
}
func ExampleColors_Sprint() {
fmt.Printf("%#v\n", Colors{BgBlack}.Sprint("Black Background"))
fmt.Printf("%#v\n", Colors{BgBlack, FgWhite}.Sprint("Black Background, White Foreground"))
fmt.Printf("%#v\n", Colors{FgBlack}.Sprint("Black Foreground"))
fmt.Printf("%#v\n", Colors{FgBlack, BgWhite}.Sprint("Black Foreground, White Background"))
// Output: "\x1b[40mBlack Background\x1b[0m"
// "\x1b[40;37mBlack Background, White Foreground\x1b[0m"
// "\x1b[30mBlack Foreground\x1b[0m"
// "\x1b[30;47mBlack Foreground, White Background\x1b[0m"
}
func TestColors_Sprint(t *testing.T) {
assert.Equal(t, "test true", Colors{}.Sprint("test ", true))
assert.Equal(t, "\x1b[31mtest true\x1b[0m", Colors{FgRed}.Sprint("test ", true))
assert.Equal(t, "\x1b[32mtest\x1b[0m\x1b[31mtrue\x1b[0m", Colors{FgRed}.Sprint("\x1b[32mtest\x1b[0m", true))
assert.Equal(t, "\x1b[32mtest true\x1b[0m", Colors{FgRed}.Sprint("\x1b[32mtest ", true))
assert.Equal(t, "\x1b[32mtest\x1b[0m\x1b[31m \x1b[0m", Colors{FgRed}.Sprint("\x1b[32mtest\x1b[0m "))
assert.Equal(t, "\x1b[32mtest\x1b[0m", Colors{FgRed}.Sprint("\x1b[32mtest\x1b[0m"))
}
func ExampleColors_Sprintf() {
fmt.Printf("%#v\n", Colors{BgBlack}.Sprintf("%s %s", "Black", "Background"))
fmt.Printf("%#v\n", Colors{BgBlack, FgWhite}.Sprintf("%s, %s", "Black Background", "White Foreground"))
fmt.Printf("%#v\n", Colors{FgBlack}.Sprintf("%s %s", "Black", "Foreground"))
fmt.Printf("%#v\n", Colors{FgBlack, BgWhite}.Sprintf("%s, %s", "Black Foreground", "White Background"))
// Output: "\x1b[40mBlack Background\x1b[0m"
// "\x1b[40;37mBlack Background, White Foreground\x1b[0m"
// "\x1b[30mBlack Foreground\x1b[0m"
// "\x1b[30;47mBlack Foreground, White Background\x1b[0m"
}
func TestColors_Sprintf(t *testing.T) {
assert.Equal(t, "test true", Colors{}.Sprintf("test %s", "true"))
assert.Equal(t, "\x1b[31mtest true\x1b[0m", Colors{FgRed}.Sprintf("test %s", "true"))
}

39
helper/text/cursor.go Normal file
View File

@ -0,0 +1,39 @@
package text
import (
"fmt"
)
// Cursor helps move the cursor on the console in multiple directions.
type Cursor rune
const (
// CursorDown helps move the Cursor Down X lines
CursorDown Cursor = 'B'
// CursorLeft helps move the Cursor Left X characters
CursorLeft Cursor = 'D'
// CursorRight helps move the Cursor Right X characters
CursorRight Cursor = 'C'
// CursorUp helps move the Cursor Up X lines
CursorUp Cursor = 'A'
// EraseLine helps erase all characters to the Right of the Cursor in the
// current line
EraseLine Cursor = 'K'
)
// Sprint prints the Escape Sequence to move the Cursor once.
func (c Cursor) Sprint() string {
return fmt.Sprintf("%s%c", EscapeStart, c)
}
// Sprintn prints the Escape Sequence to move the Cursor "n" times.
func (c Cursor) Sprintn(n int) string {
if c == EraseLine {
return c.Sprint()
}
return fmt.Sprintf("%s%d%c", EscapeStart, n, c)
}

View File

@ -0,0 +1,52 @@
package text
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func ExampleCursor_Sprint() {
fmt.Printf("CursorDown : %#v\n", CursorDown.Sprint())
fmt.Printf("CursorLeft : %#v\n", CursorLeft.Sprint())
fmt.Printf("CursorRight: %#v\n", CursorRight.Sprint())
fmt.Printf("CursorUp : %#v\n", CursorUp.Sprint())
fmt.Printf("EraseLine : %#v\n", EraseLine.Sprint())
// Output: CursorDown : "\x1b[B"
// CursorLeft : "\x1b[D"
// CursorRight: "\x1b[C"
// CursorUp : "\x1b[A"
// EraseLine : "\x1b[K"
}
func TestCursor_Sprint(t *testing.T) {
assert.Equal(t, "\x1b[B", CursorDown.Sprint())
assert.Equal(t, "\x1b[D", CursorLeft.Sprint())
assert.Equal(t, "\x1b[C", CursorRight.Sprint())
assert.Equal(t, "\x1b[A", CursorUp.Sprint())
assert.Equal(t, "\x1b[K", EraseLine.Sprint())
}
func ExampleCursor_Sprintn() {
fmt.Printf("CursorDown : %#v\n", CursorDown.Sprintn(5))
fmt.Printf("CursorLeft : %#v\n", CursorLeft.Sprintn(5))
fmt.Printf("CursorRight: %#v\n", CursorRight.Sprintn(5))
fmt.Printf("CursorUp : %#v\n", CursorUp.Sprintn(5))
fmt.Printf("EraseLine : %#v\n", EraseLine.Sprintn(5))
// Output: CursorDown : "\x1b[5B"
// CursorLeft : "\x1b[5D"
// CursorRight: "\x1b[5C"
// CursorUp : "\x1b[5A"
// EraseLine : "\x1b[K"
}
func TestCursor_Sprintn(t *testing.T) {
assert.Equal(t, "\x1b[5B", CursorDown.Sprintn(5))
assert.Equal(t, "\x1b[5D", CursorLeft.Sprintn(5))
assert.Equal(t, "\x1b[5C", CursorRight.Sprintn(5))
assert.Equal(t, "\x1b[5A", CursorUp.Sprintn(5))
assert.Equal(t, "\x1b[K", EraseLine.Sprintn(5))
}

24
helper/text/direction.go Normal file
View File

@ -0,0 +1,24 @@
package text
// Direction defines the overall flow of text. Similar to bidi.Direction, but
// simplified and specific to this package.
type Direction int
// Available Directions.
const (
Default Direction = iota
LeftToRight
RightToLeft
)
// Modifier returns a character to force the given direction for the text that
// follows the modifier.
func (d Direction) Modifier() string {
switch d {
case LeftToRight:
return "\u202a"
case RightToLeft:
return "\u202b"
}
return ""
}

View File

@ -0,0 +1,13 @@
package text
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDirection_Modifier(t *testing.T) {
assert.Equal(t, "", Default.Modifier())
assert.Equal(t, "\u202a", LeftToRight.Modifier())
assert.Equal(t, "\u202b", RightToLeft.Modifier())
}

51
helper/text/escape.go Normal file
View File

@ -0,0 +1,51 @@
package text
import "strings"
// Constants
const (
CSIStartRune = rune(91) // [
CSIStopRune = 'm'
EscapeReset = EscapeStart + "0" + EscapeStop
EscapeStart = "\x1b["
EscapeStartRune = rune(27) // \x1b
EscapeStop = "m"
EscapeStopRune = 'm'
OSIStartRune = rune(93) // ]
OSIStopRune = '\\'
)
type escKind int
const (
escKindUnknown escKind = iota
escKindCSI
escKindOSI
)
type escSeq struct {
isIn bool
content strings.Builder
kind escKind
}
func (e *escSeq) InspectRune(r rune) {
if !e.isIn && r == EscapeStartRune {
e.isIn = true
e.kind = escKindUnknown
e.content.Reset()
e.content.WriteRune(r)
} else if e.isIn {
switch {
case e.kind == escKindUnknown && r == CSIStartRune:
e.kind = escKindCSI
case e.kind == escKindUnknown && r == OSIStartRune:
e.kind = escKindOSI
case e.kind == escKindCSI && r == CSIStopRune || e.kind == escKindOSI && r == OSIStopRune:
e.isIn = false
e.kind = escKindUnknown
}
e.content.WriteRune(r)
}
return
}

12
helper/text/filter.go Normal file
View File

@ -0,0 +1,12 @@
package text
// Filter filters the slice 's' to items which return truth when passed to 'f'.
func Filter(s []string, f func(string) bool) []string {
var out []string
for _, item := range s {
if f(item) {
out = append(out, item)
}
}
return out
}

View File

@ -0,0 +1,30 @@
package text
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func ExampleFilter() {
slice := []string{"Arya Stark", "Bran Stark", "Jon Snow", "Sansa Stark"}
filter := func(item string) bool {
return strings.HasSuffix(item, "Stark")
}
fmt.Printf("%#v\n", Filter(slice, filter))
// Output: []string{"Arya Stark", "Bran Stark", "Sansa Stark"}
}
func TestFilter(t *testing.T) {
slice := []string{"Arya Stark", "Bran Stark", "Jon Snow", "Sansa Stark"}
filter := func(item string) bool {
return strings.HasSuffix(item, "Stark")
}
filteredSlice := Filter(slice, filter)
assert.Equal(t, 3, len(filteredSlice))
assert.NotContains(t, filteredSlice, "Jon Snow")
}

100
helper/text/format.go Normal file
View File

@ -0,0 +1,100 @@
package text
import (
"strings"
"unicode"
)
// Format lets you transform the text in supported methods while keeping escape
// sequences in the string intact and untouched.
type Format int
// Format enumerations
const (
FormatDefault Format = iota // default_Case
FormatLower // lower
FormatTitle // Title
FormatUpper // UPPER
)
// Apply converts the text as directed.
func (tc Format) Apply(text string) string {
switch tc {
case FormatLower:
return strings.ToLower(text)
case FormatTitle:
return toTitle(text)
case FormatUpper:
return toUpper(text)
default:
return text
}
}
func toTitle(text string) string {
prev, inEscSeq := ' ', false
return strings.Map(
func(r rune) rune {
if r == EscapeStartRune {
inEscSeq = true
}
if !inEscSeq {
if isSeparator(prev) {
prev = r
r = unicode.ToUpper(r)
} else {
prev = r
}
}
if inEscSeq && r == EscapeStopRune {
inEscSeq = false
}
return r
},
text,
)
}
func toUpper(text string) string {
inEscSeq := false
return strings.Map(
func(r rune) rune {
if r == EscapeStartRune {
inEscSeq = true
}
if !inEscSeq {
r = unicode.ToUpper(r)
}
if inEscSeq && r == EscapeStopRune {
inEscSeq = false
}
return r
},
text,
)
}
// isSeparator returns true if the given rune is a separator. This function is
// lifted straight out of the standard library @ strings/strings.go.
func isSeparator(r rune) bool {
// ASCII alphanumerics and underscore are not separators
if r <= 0x7F {
switch {
case '0' <= r && r <= '9':
return false
case 'a' <= r && r <= 'z':
return false
case 'A' <= r && r <= 'Z':
return false
case r == '_':
return false
}
return true
}
// Letters and digits are not separators
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return false
}
// Otherwise, all we can do for now is treat spaces as separators.
return unicode.IsSpace(r)
}

View File

@ -0,0 +1,45 @@
package text
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func ExampleFormat_Apply() {
fmt.Printf("FormatDefault: %#v\n", FormatDefault.Apply("jon Snow"))
fmt.Printf("FormatLower : %#v\n", FormatLower.Apply("jon Snow"))
fmt.Printf("FormatTitle : %#v\n", FormatTitle.Apply("jon Snow"))
fmt.Printf("FormatUpper : %#v\n", FormatUpper.Apply("jon Snow"))
fmt.Println()
fmt.Printf("FormatDefault (w/EscSeq): %#v\n", FormatDefault.Apply(Bold.Sprint("jon Snow")))
fmt.Printf("FormatLower (w/EscSeq): %#v\n", FormatLower.Apply(Bold.Sprint("jon Snow")))
fmt.Printf("FormatTitle (w/EscSeq): %#v\n", FormatTitle.Apply(Bold.Sprint("jon Snow")))
fmt.Printf("FormatUpper (w/EscSeq): %#v\n", FormatUpper.Apply(Bold.Sprint("jon Snow")))
// Output: FormatDefault: "jon Snow"
// FormatLower : "jon snow"
// FormatTitle : "Jon Snow"
// FormatUpper : "JON SNOW"
//
// FormatDefault (w/EscSeq): "\x1b[1mjon Snow\x1b[0m"
// FormatLower (w/EscSeq): "\x1b[1mjon snow\x1b[0m"
// FormatTitle (w/EscSeq): "\x1b[1mJon Snow\x1b[0m"
// FormatUpper (w/EscSeq): "\x1b[1mJON SNOW\x1b[0m"
}
func TestFormat_Apply(t *testing.T) {
text := "A big croc0dile; Died - Empty_fanged ツ \u2008."
assert.Equal(t, text, FormatDefault.Apply(text))
assert.Equal(t, "a big croc0dile; died - empty_fanged ツ \u2008.", FormatLower.Apply(text))
assert.Equal(t, "A Big Croc0dile; Died - Empty_fanged ツ \u2008.", FormatTitle.Apply(text))
assert.Equal(t, "A BIG CROC0DILE; DIED - EMPTY_FANGED ツ \u2008.", FormatUpper.Apply(text))
// test with escape sequences
text = Colors{Bold}.Sprint(text)
assert.Equal(t, "\x1b[1mA big croc0dile; Died - Empty_fanged ツ \u2008.\x1b[0m", FormatDefault.Apply(text))
assert.Equal(t, "\x1b[1ma big croc0dile; died - empty_fanged ツ \u2008.\x1b[0m", FormatLower.Apply(text))
assert.Equal(t, "\x1b[1mA Big Croc0dile; Died - Empty_fanged ツ \u2008.\x1b[0m", FormatTitle.Apply(text))
assert.Equal(t, "\x1b[1mA BIG CROC0DILE; DIED - EMPTY_FANGED ツ \u2008.\x1b[0m", FormatUpper.Apply(text))
}

14
helper/text/hyperlink.go Normal file
View File

@ -0,0 +1,14 @@
package text
import "fmt"
func Hyperlink(url, text string) string {
if url == "" {
return text
}
if text == "" {
return url
}
// source https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text)
}

View File

@ -0,0 +1,13 @@
package text
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestHyperlink(t *testing.T) {
assert.Equal(t, "Ghost", Hyperlink("", "Ghost"))
assert.Equal(t, "https://example.com", Hyperlink("https://example.com", ""))
assert.Equal(t, "\x1b]8;;https://example.com\x1b\\Ghost\x1b]8;;\x1b\\", Hyperlink("https://example.com", "Ghost"))
}

219
helper/text/string.go Normal file
View File

@ -0,0 +1,219 @@
package text
import (
"strings"
"unicode/utf8"
"github.com/mattn/go-runewidth"
)
// RuneWidth stuff
var (
rwCondition = runewidth.NewCondition()
)
// InsertEveryN inserts the rune every N characters in the string. For ex.:
// InsertEveryN("Ghost", '-', 1) == "G-h-o-s-t"
// InsertEveryN("Ghost", '-', 2) == "Gh-os-t"
// InsertEveryN("Ghost", '-', 3) == "Gho-st"
// InsertEveryN("Ghost", '-', 4) == "Ghos-t"
// InsertEveryN("Ghost", '-', 5) == "Ghost"
func InsertEveryN(str string, runeToInsert rune, n int) string {
if n <= 0 {
return str
}
sLen := RuneWidthWithoutEscSequences(str)
var out strings.Builder
out.Grow(sLen + (sLen / n))
outLen, eSeq := 0, escSeq{}
for idx, c := range str {
if eSeq.isIn {
eSeq.InspectRune(c)
out.WriteRune(c)
continue
}
eSeq.InspectRune(c)
if !eSeq.isIn && outLen > 0 && (outLen%n) == 0 && idx != sLen {
out.WriteRune(runeToInsert)
}
out.WriteRune(c)
if !eSeq.isIn {
outLen += RuneWidth(c)
}
}
return out.String()
}
// LongestLineLen returns the length of the longest "line" within the
// argument string. For ex.:
// LongestLineLen("Ghost!\nCome back here!\nRight now!") == 15
func LongestLineLen(str string) int {
maxLength, currLength, eSeq := 0, 0, escSeq{}
for _, c := range str {
if eSeq.isIn {
eSeq.InspectRune(c)
continue
}
eSeq.InspectRune(c)
if c == '\n' {
if currLength > maxLength {
maxLength = currLength
}
currLength = 0
} else if !eSeq.isIn {
currLength += RuneWidth(c)
}
}
if currLength > maxLength {
maxLength = currLength
}
return maxLength
}
// OverrideRuneWidthEastAsianWidth can *probably* help with alignment, and
// length calculation issues when dealing with Unicode character-set and a
// non-English language set in the LANG variable.
//
// Set this to 'false' to force the "runewidth" library to pretend to deal with
// English character-set. Be warned that if the text/content you are dealing
// with contains East Asian character-set, this may result in unexpected
// behavior.
//
// References:
// * https://github.com/mattn/go-runewidth/issues/64#issuecomment-1221642154
// * https://github.com/jedib0t/go-pretty/issues/220
// * https://github.com/jedib0t/go-pretty/issues/204
func OverrideRuneWidthEastAsianWidth(val bool) {
rwCondition.EastAsianWidth = val
}
// Pad pads the given string with as many characters as needed to make it as
// long as specified (maxLen). This function does not count escape sequences
// while calculating length of the string. Ex.:
// Pad("Ghost", 0, ' ') == "Ghost"
// Pad("Ghost", 3, ' ') == "Ghost"
// Pad("Ghost", 5, ' ') == "Ghost"
// Pad("Ghost", 7, ' ') == "Ghost "
// Pad("Ghost", 10, '.') == "Ghost....."
func Pad(str string, maxLen int, paddingChar rune) string {
strLen := RuneWidthWithoutEscSequences(str)
if strLen < maxLen {
str += strings.Repeat(string(paddingChar), maxLen-strLen)
}
return str
}
// RepeatAndTrim repeats the given string until it is as long as maxRunes.
// For ex.:
// RepeatAndTrim("", 5) == ""
// RepeatAndTrim("Ghost", 0) == ""
// RepeatAndTrim("Ghost", 5) == "Ghost"
// RepeatAndTrim("Ghost", 7) == "GhostGh"
// RepeatAndTrim("Ghost", 10) == "GhostGhost"
func RepeatAndTrim(str string, maxRunes int) string {
if str == "" || maxRunes == 0 {
return ""
} else if maxRunes == utf8.RuneCountInString(str) {
return str
}
repeatedS := strings.Repeat(str, int(maxRunes/utf8.RuneCountInString(str))+1)
return Trim(repeatedS, maxRunes)
}
// RuneCount is similar to utf8.RuneCountInString, except for the fact that it
// ignores escape sequences while counting. For ex.:
// RuneCount("") == 0
// RuneCount("Ghost") == 5
// RuneCount("\x1b[33mGhost\x1b[0m") == 5
// RuneCount("\x1b[33mGhost\x1b[0") == 5
// Deprecated: in favor of RuneWidthWithoutEscSequences
func RuneCount(str string) int {
return RuneWidthWithoutEscSequences(str)
}
// RuneWidth returns the mostly accurate character-width of the rune. This is
// not 100% accurate as the character width is usually dependent on the
// typeface (font) used in the console/terminal. For ex.:
// RuneWidth('A') == 1
// RuneWidth('ツ') == 2
// RuneWidth('⊙') == 1
// RuneWidth('︿') == 2
// RuneWidth(0x27) == 0
func RuneWidth(r rune) int {
return rwCondition.RuneWidth(r)
}
// RuneWidthWithoutEscSequences is similar to RuneWidth, except for the fact
// that it ignores escape sequences while counting. For ex.:
// RuneWidthWithoutEscSequences("") == 0
// RuneWidthWithoutEscSequences("Ghost") == 5
// RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m") == 5
// RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0") == 5
func RuneWidthWithoutEscSequences(str string) int {
count, eSeq := 0, escSeq{}
for _, c := range str {
if eSeq.isIn {
eSeq.InspectRune(c)
continue
}
eSeq.InspectRune(c)
if !eSeq.isIn {
count += RuneWidth(c)
}
}
return count
}
// Snip returns the given string with a fixed length. For ex.:
// Snip("Ghost", 0, "~") == "Ghost"
// Snip("Ghost", 1, "~") == "~"
// Snip("Ghost", 3, "~") == "Gh~"
// Snip("Ghost", 5, "~") == "Ghost"
// Snip("Ghost", 7, "~") == "Ghost "
// Snip("\x1b[33mGhost\x1b[0m", 7, "~") == "\x1b[33mGhost\x1b[0m "
func Snip(str string, length int, snipIndicator string) string {
if length > 0 {
lenStr := RuneWidthWithoutEscSequences(str)
if lenStr > length {
lenStrFinal := length - RuneWidthWithoutEscSequences(snipIndicator)
return Trim(str, lenStrFinal) + snipIndicator
}
}
return str
}
// Trim trims a string to the given length while ignoring escape sequences. For
// ex.:
// Trim("Ghost", 3) == "Gho"
// Trim("Ghost", 6) == "Ghost"
// Trim("\x1b[33mGhost\x1b[0m", 3) == "\x1b[33mGho\x1b[0m"
// Trim("\x1b[33mGhost\x1b[0m", 6) == "\x1b[33mGhost\x1b[0m"
func Trim(str string, maxLen int) string {
if maxLen <= 0 {
return ""
}
var out strings.Builder
out.Grow(maxLen)
outLen, eSeq := 0, escSeq{}
for _, sChr := range str {
if eSeq.isIn {
eSeq.InspectRune(sChr)
out.WriteRune(sChr)
continue
}
eSeq.InspectRune(sChr)
if eSeq.isIn {
out.WriteRune(sChr)
continue
}
if outLen < maxLen {
outLen++
out.WriteRune(sChr)
continue
}
}
return out.String()
}

282
helper/text/string_test.go Normal file
View File

@ -0,0 +1,282 @@
package text
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func ExampleInsertEveryN() {
fmt.Printf("InsertEveryN(\"Ghost\", '-', 0): %#v\n", InsertEveryN("Ghost", '-', 0))
fmt.Printf("InsertEveryN(\"Ghost\", '-', 1): %#v\n", InsertEveryN("Ghost", '-', 1))
fmt.Printf("InsertEveryN(\"Ghost\", '-', 2): %#v\n", InsertEveryN("Ghost", '-', 2))
fmt.Printf("InsertEveryN(\"Ghost\", '-', 3): %#v\n", InsertEveryN("Ghost", '-', 3))
fmt.Printf("InsertEveryN(\"Ghost\", '-', 4): %#v\n", InsertEveryN("Ghost", '-', 4))
fmt.Printf("InsertEveryN(\"Ghost\", '-', 5): %#v\n", InsertEveryN("Ghost", '-', 5))
fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 0): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 0))
fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 1): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 1))
fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 2): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 2))
fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 3): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 3))
fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 4): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 4))
fmt.Printf("InsertEveryN(\"\\x1b[33mGhost\\x1b[0m\", '-', 5): %#v\n", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 5))
// Output: InsertEveryN("Ghost", '-', 0): "Ghost"
// InsertEveryN("Ghost", '-', 1): "G-h-o-s-t"
// InsertEveryN("Ghost", '-', 2): "Gh-os-t"
// InsertEveryN("Ghost", '-', 3): "Gho-st"
// InsertEveryN("Ghost", '-', 4): "Ghos-t"
// InsertEveryN("Ghost", '-', 5): "Ghost"
// InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 0): "\x1b[33mGhost\x1b[0m"
// InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 1): "\x1b[33mG-h-o-s-t\x1b[0m"
// InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 2): "\x1b[33mGh-os-t\x1b[0m"
// InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 3): "\x1b[33mGho-st\x1b[0m"
// InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 4): "\x1b[33mGhos-t\x1b[0m"
// InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 5): "\x1b[33mGhost\x1b[0m"
}
func TestInsertEveryN(t *testing.T) {
assert.Equal(t, "Ghost", InsertEveryN("Ghost", '-', 0))
assert.Equal(t, "Gツhツoツsツt", InsertEveryN("Ghost", 'ツ', 1))
assert.Equal(t, "G-h-o-s-t", InsertEveryN("Ghost", '-', 1))
assert.Equal(t, "Gh-os-t", InsertEveryN("Ghost", '-', 2))
assert.Equal(t, "Gho-st", InsertEveryN("Ghost", '-', 3))
assert.Equal(t, "Ghos-t", InsertEveryN("Ghost", '-', 4))
assert.Equal(t, "Ghost", InsertEveryN("Ghost", '-', 5))
assert.Equal(t, "\x1b[33mGhost\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 0))
assert.Equal(t, "\x1b[33mGツhツoツsツt\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", 'ツ', 1))
assert.Equal(t, "\x1b[33mG-h-o-s-t\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 1))
assert.Equal(t, "\x1b[33mGh-os-t\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 2))
assert.Equal(t, "\x1b[33mGho-st\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 3))
assert.Equal(t, "\x1b[33mGhos-t\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 4))
assert.Equal(t, "\x1b[33mGhost\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 5))
assert.Equal(t, "G\x1b]8;;http://example.com\x1b\\-h-o-s-t\x1b]8;;\x1b\\", InsertEveryN("G\x1b]8;;http://example.com\x1b\\host\x1b]8;;\x1b\\", '-', 1))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\G-h-o-s-t\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", '-', 1))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\G-h-o-s\x1b]8;;\x1b\\-t", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghos\x1b]8;;\x1b\\t", '-', 1))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gツhツoツsツt\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 'ツ', 1))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghツosツt\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 'ツ', 2))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", '-', 5))
}
func ExampleLongestLineLen() {
fmt.Printf("LongestLineLen(\"\"): %d\n", LongestLineLen(""))
fmt.Printf("LongestLineLen(\"\\n\\n\"): %d\n", LongestLineLen("\n\n"))
fmt.Printf("LongestLineLen(\"Ghost\"): %d\n", LongestLineLen("Ghost"))
fmt.Printf("LongestLineLen(\"Ghostツ\"): %d\n", LongestLineLen("Ghostツ"))
fmt.Printf("LongestLineLen(\"Winter\\nIs\\nComing\"): %d\n", LongestLineLen("Winter\nIs\nComing"))
fmt.Printf("LongestLineLen(\"Mother\\nOf\\nDragons\"): %d\n", LongestLineLen("Mother\nOf\nDragons"))
fmt.Printf("LongestLineLen(\"\\x1b[33mMother\\x1b[0m\\nOf\\nDragons\"): %d\n", LongestLineLen("\x1b[33mMother\x1b[0m\nOf\nDragons"))
// Output: LongestLineLen(""): 0
// LongestLineLen("\n\n"): 0
// LongestLineLen("Ghost"): 5
// LongestLineLen("Ghostツ"): 7
// LongestLineLen("Winter\nIs\nComing"): 6
// LongestLineLen("Mother\nOf\nDragons"): 7
// LongestLineLen("\x1b[33mMother\x1b[0m\nOf\nDragons"): 7
}
func TestLongestLineLen(t *testing.T) {
assert.Equal(t, 0, LongestLineLen(""))
assert.Equal(t, 0, LongestLineLen("\n\n"))
assert.Equal(t, 5, LongestLineLen("Ghost"))
assert.Equal(t, 7, LongestLineLen("Ghostツ"))
assert.Equal(t, 6, LongestLineLen("Winter\nIs\nComing"))
assert.Equal(t, 7, LongestLineLen("Mother\nOf\nDragons"))
assert.Equal(t, 7, LongestLineLen("\x1b[33mMother\x1b[0m\nOf\nDragons"))
assert.Equal(t, 7, LongestLineLen("Mother\nOf\n\x1b]8;;http://example.com\x1b\\Dragons\x1b]8;;\x1b\\"))
}
func TestOverrideRuneWidthEastAsianWidth(t *testing.T) {
originalValue := rwCondition.EastAsianWidth
defer func() {
rwCondition.EastAsianWidth = originalValue
}()
OverrideRuneWidthEastAsianWidth(true)
assert.Equal(t, 2, RuneWidthWithoutEscSequences("╋"))
OverrideRuneWidthEastAsianWidth(false)
assert.Equal(t, 1, RuneWidthWithoutEscSequences("╋"))
// Note for posterity. We want the length of the box drawing character to
// be reported as 1. However, with an environment where LANG is set to
// something like 'zh_CN.UTF-8', the value being returned is 2, which breaks
// text alignment/padding logic in this library.
//
// If a future version of runewidth is able to address this internally and
// return 1 for the above, the function being tested can be marked for
// deprecation.
}
func ExamplePad() {
fmt.Printf("%#v\n", Pad("Ghost", 0, ' '))
fmt.Printf("%#v\n", Pad("Ghost", 3, ' '))
fmt.Printf("%#v\n", Pad("Ghost", 5, ' '))
fmt.Printf("%#v\n", Pad("\x1b[33mGhost\x1b[0m", 7, '-'))
fmt.Printf("%#v\n", Pad("\x1b[33mGhost\x1b[0m", 10, '.'))
// Output: "Ghost"
// "Ghost"
// "Ghost"
// "\x1b[33mGhost\x1b[0m--"
// "\x1b[33mGhost\x1b[0m....."
}
func TestPad(t *testing.T) {
assert.Equal(t, "Ghost", Pad("Ghost", 0, ' '))
assert.Equal(t, "Ghost", Pad("Ghost", 3, ' '))
assert.Equal(t, "Ghost", Pad("Ghost", 5, ' '))
assert.Equal(t, "Ghost ", Pad("Ghost", 7, ' '))
assert.Equal(t, "Ghost.....", Pad("Ghost", 10, '.'))
assert.Equal(t, "\x1b[33mGhost\x1b[0 ", Pad("\x1b[33mGhost\x1b[0", 7, ' '))
assert.Equal(t, "\x1b[33mGhost\x1b[0.....", Pad("\x1b[33mGhost\x1b[0", 10, '.'))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\ ", Pad("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 7, ' '))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\.....", Pad("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 10, '.'))
}
func ExampleRepeatAndTrim() {
fmt.Printf("RepeatAndTrim(\"\", 5): %#v\n", RepeatAndTrim("", 5))
fmt.Printf("RepeatAndTrim(\"Ghost\", 0): %#v\n", RepeatAndTrim("Ghost", 0))
fmt.Printf("RepeatAndTrim(\"Ghost\", 3): %#v\n", RepeatAndTrim("Ghost", 3))
fmt.Printf("RepeatAndTrim(\"Ghost\", 5): %#v\n", RepeatAndTrim("Ghost", 5))
fmt.Printf("RepeatAndTrim(\"Ghost\", 7): %#v\n", RepeatAndTrim("Ghost", 7))
fmt.Printf("RepeatAndTrim(\"Ghost\", 10): %#v\n", RepeatAndTrim("Ghost", 10))
// Output: RepeatAndTrim("", 5): ""
// RepeatAndTrim("Ghost", 0): ""
// RepeatAndTrim("Ghost", 3): "Gho"
// RepeatAndTrim("Ghost", 5): "Ghost"
// RepeatAndTrim("Ghost", 7): "GhostGh"
// RepeatAndTrim("Ghost", 10): "GhostGhost"
}
func TestRepeatAndTrim(t *testing.T) {
assert.Equal(t, "", RepeatAndTrim("", 5))
assert.Equal(t, "", RepeatAndTrim("Ghost", 0))
assert.Equal(t, "Gho", RepeatAndTrim("Ghost", 3))
assert.Equal(t, "Ghost", RepeatAndTrim("Ghost", 5))
assert.Equal(t, "GhostGh", RepeatAndTrim("Ghost", 7))
assert.Equal(t, "GhostGhost", RepeatAndTrim("Ghost", 10))
assert.Equal(t, "───", RepeatAndTrim("─", 3))
}
func ExampleRuneCount() {
fmt.Printf("RuneCount(\"\"): %d\n", RuneCount(""))
fmt.Printf("RuneCount(\"Ghost\"): %d\n", RuneCount("Ghost"))
fmt.Printf("RuneCount(\"Ghostツ\"): %d\n", RuneCount("Ghostツ"))
fmt.Printf("RuneCount(\"\\x1b[33mGhost\\x1b[0m\"): %d\n", RuneCount("\x1b[33mGhost\x1b[0m"))
fmt.Printf("RuneCount(\"\\x1b[33mGhost\\x1b[0\"): %d\n", RuneCount("\x1b[33mGhost\x1b[0"))
// Output: RuneCount(""): 0
// RuneCount("Ghost"): 5
// RuneCount("Ghostツ"): 7
// RuneCount("\x1b[33mGhost\x1b[0m"): 5
// RuneCount("\x1b[33mGhost\x1b[0"): 5
}
func TestRuneCount(t *testing.T) {
assert.Equal(t, 0, RuneCount(""))
assert.Equal(t, 5, RuneCount("Ghost"))
assert.Equal(t, 7, RuneCount("Ghostツ"))
assert.Equal(t, 5, RuneCount("\x1b[33mGhost\x1b[0m"))
assert.Equal(t, 5, RuneCount("\x1b[33mGhost\x1b[0"))
assert.Equal(t, 5, RuneCount("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\"))
}
func ExampleRuneWidth() {
fmt.Printf("RuneWidth('A'): %d\n", RuneWidth('A'))
fmt.Printf("RuneWidth('ツ'): %d\n", RuneWidth('ツ'))
fmt.Printf("RuneWidth('⊙'): %d\n", RuneWidth('⊙'))
fmt.Printf("RuneWidth('︿'): %d\n", RuneWidth('︿'))
fmt.Printf("RuneWidth(rune(27)): %d\n", RuneWidth(rune(27))) // ANSI escape sequence
// Output: RuneWidth('A'): 1
// RuneWidth('ツ'): 2
// RuneWidth('⊙'): 1
// RuneWidth('︿'): 2
// RuneWidth(rune(27)): 0
}
func TestRuneWidth(t *testing.T) {
assert.Equal(t, 1, RuneWidth('A'))
assert.Equal(t, 2, RuneWidth('ツ'))
assert.Equal(t, 1, RuneWidth('⊙'))
assert.Equal(t, 2, RuneWidth('︿'))
assert.Equal(t, 0, RuneWidth(rune(27))) // ANSI escape sequence
}
func ExampleRuneWidthWithoutEscSequences() {
fmt.Printf("RuneWidthWithoutEscSequences(\"\"): %d\n", RuneWidthWithoutEscSequences(""))
fmt.Printf("RuneWidthWithoutEscSequences(\"Ghost\"): %d\n", RuneWidthWithoutEscSequences("Ghost"))
fmt.Printf("RuneWidthWithoutEscSequences(\"Ghostツ\"): %d\n", RuneWidthWithoutEscSequences("Ghostツ"))
fmt.Printf("RuneWidthWithoutEscSequences(\"\\x1b[33mGhost\\x1b[0m\"): %d\n", RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m"))
fmt.Printf("RuneWidthWithoutEscSequences(\"\\x1b[33mGhost\\x1b[0\"): %d\n", RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0"))
// Output: RuneWidthWithoutEscSequences(""): 0
// RuneWidthWithoutEscSequences("Ghost"): 5
// RuneWidthWithoutEscSequences("Ghostツ"): 7
// RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m"): 5
// RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0"): 5
}
func TestRuneWidthWithoutEscSequences(t *testing.T) {
assert.Equal(t, 0, RuneWidthWithoutEscSequences(""))
assert.Equal(t, 5, RuneWidthWithoutEscSequences("Ghost"))
assert.Equal(t, 7, RuneWidthWithoutEscSequences("Ghostツ"))
assert.Equal(t, 5, RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m"))
assert.Equal(t, 5, RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0"))
assert.Equal(t, 5, RuneWidthWithoutEscSequences("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\"))
}
func ExampleSnip() {
fmt.Printf("Snip(\"Ghost\", 0, \"~\"): %#v\n", Snip("Ghost", 0, "~"))
fmt.Printf("Snip(\"Ghost\", 1, \"~\"): %#v\n", Snip("Ghost", 1, "~"))
fmt.Printf("Snip(\"Ghost\", 3, \"~\"): %#v\n", Snip("Ghost", 3, "~"))
fmt.Printf("Snip(\"Ghost\", 5, \"~\"): %#v\n", Snip("Ghost", 5, "~"))
fmt.Printf("Snip(\"Ghost\", 7, \"~\"): %#v\n", Snip("Ghost", 7, "~"))
fmt.Printf("Snip(\"\\x1b[33mGhost\\x1b[0m\", 7, \"~\"): %#v\n", Snip("\x1b[33mGhost\x1b[0m", 7, "~"))
// Output: Snip("Ghost", 0, "~"): "Ghost"
// Snip("Ghost", 1, "~"): "~"
// Snip("Ghost", 3, "~"): "Gh~"
// Snip("Ghost", 5, "~"): "Ghost"
// Snip("Ghost", 7, "~"): "Ghost"
// Snip("\x1b[33mGhost\x1b[0m", 7, "~"): "\x1b[33mGhost\x1b[0m"
}
func TestSnip(t *testing.T) {
assert.Equal(t, "Ghost", Snip("Ghost", 0, "~"))
assert.Equal(t, "~", Snip("Ghost", 1, "~"))
assert.Equal(t, "Gh~", Snip("Ghost", 3, "~"))
assert.Equal(t, "Ghost", Snip("Ghost", 5, "~"))
assert.Equal(t, "Ghost", Snip("Ghost", 7, "~"))
assert.Equal(t, "\x1b[33mGhost\x1b[0m", Snip("\x1b[33mGhost\x1b[0m", 7, "~"))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", Snip("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 7, "~"))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gh\x1b]8;;\x1b\\~", Snip("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 3, "~"))
assert.Equal(t, "\x1b[33m\x1b]8;;http://example.com\x1b\\Gh\x1b]8;;\x1b\\\x1b[0m~", Snip("\x1b[33m\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\\x1b[0m", 3, "~"))
}
func ExampleTrim() {
fmt.Printf("Trim(\"Ghost\", 0): %#v\n", Trim("Ghost", 0))
fmt.Printf("Trim(\"Ghost\", 3): %#v\n", Trim("Ghost", 3))
fmt.Printf("Trim(\"Ghost\", 6): %#v\n", Trim("Ghost", 6))
fmt.Printf("Trim(\"\\x1b[33mGhost\\x1b[0m\", 0): %#v\n", Trim("\x1b[33mGhost\x1b[0m", 0))
fmt.Printf("Trim(\"\\x1b[33mGhost\\x1b[0m\", 3): %#v\n", Trim("\x1b[33mGhost\x1b[0m", 3))
fmt.Printf("Trim(\"\\x1b[33mGhost\\x1b[0m\", 6): %#v\n", Trim("\x1b[33mGhost\x1b[0m", 6))
// Output: Trim("Ghost", 0): ""
// Trim("Ghost", 3): "Gho"
// Trim("Ghost", 6): "Ghost"
// Trim("\x1b[33mGhost\x1b[0m", 0): ""
// Trim("\x1b[33mGhost\x1b[0m", 3): "\x1b[33mGho\x1b[0m"
// Trim("\x1b[33mGhost\x1b[0m", 6): "\x1b[33mGhost\x1b[0m"
}
func TestTrim(t *testing.T) {
assert.Equal(t, "", Trim("Ghost", 0))
assert.Equal(t, "Gho", Trim("Ghost", 3))
assert.Equal(t, "Ghost", Trim("Ghost", 6))
assert.Equal(t, "\x1b[33mGho\x1b[0m", Trim("\x1b[33mGhost\x1b[0m", 3))
assert.Equal(t, "\x1b[33mGhost\x1b[0m", Trim("\x1b[33mGhost\x1b[0m", 6))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gho\x1b]8;;\x1b\\", Trim("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 3))
}

228
helper/text/transformer.go Normal file
View File

@ -0,0 +1,228 @@
package text
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// Transformer related constants
const (
unixTimeMinMilliseconds = int64(10000000000)
unixTimeMinMicroseconds = unixTimeMinMilliseconds * 1000
unixTimeMinNanoSeconds = unixTimeMinMicroseconds * 1000
)
// Transformer related variables
var (
colorsNumberPositive = Colors{FgHiGreen}
colorsNumberNegative = Colors{FgHiRed}
colorsNumberZero = Colors{}
colorsURL = Colors{Underline, FgBlue}
rfc3339Milli = "2006-01-02T15:04:05.000Z07:00"
rfc3339Micro = "2006-01-02T15:04:05.000000Z07:00"
possibleTimeLayouts = []string{
time.RFC3339,
rfc3339Milli, // strfmt.DateTime.String()'s default layout
rfc3339Micro,
time.RFC3339Nano,
}
)
// Transformer helps format the contents of an object to the user's liking.
type Transformer func(val interface{}) string
// NewNumberTransformer returns a number Transformer that:
// * transforms the number as directed by 'format' (ex.: %.2f)
// * colors negative values Red
// * colors positive values Green
func NewNumberTransformer(format string) Transformer {
return func(val interface{}) string {
if valStr := transformInt(format, val); valStr != "" {
return valStr
}
if valStr := transformUint(format, val); valStr != "" {
return valStr
}
if valStr := transformFloat(format, val); valStr != "" {
return valStr
}
return fmt.Sprint(val)
}
}
func transformInt(format string, val interface{}) string {
transform := func(val int64) string {
if val < 0 {
return colorsNumberNegative.Sprintf("-"+format, -val)
}
if val > 0 {
return colorsNumberPositive.Sprintf(format, val)
}
return colorsNumberZero.Sprintf(format, val)
}
if number, ok := val.(int); ok {
return transform(int64(number))
}
if number, ok := val.(int8); ok {
return transform(int64(number))
}
if number, ok := val.(int16); ok {
return transform(int64(number))
}
if number, ok := val.(int32); ok {
return transform(int64(number))
}
if number, ok := val.(int64); ok {
return transform(int64(number))
}
return ""
}
func transformUint(format string, val interface{}) string {
transform := func(val uint64) string {
if val > 0 {
return colorsNumberPositive.Sprintf(format, val)
}
return colorsNumberZero.Sprintf(format, val)
}
if number, ok := val.(uint); ok {
return transform(uint64(number))
}
if number, ok := val.(uint8); ok {
return transform(uint64(number))
}
if number, ok := val.(uint16); ok {
return transform(uint64(number))
}
if number, ok := val.(uint32); ok {
return transform(uint64(number))
}
if number, ok := val.(uint64); ok {
return transform(uint64(number))
}
return ""
}
func transformFloat(format string, val interface{}) string {
transform := func(val float64) string {
if val < 0 {
return colorsNumberNegative.Sprintf("-"+format, -val)
}
if val > 0 {
return colorsNumberPositive.Sprintf(format, val)
}
return colorsNumberZero.Sprintf(format, val)
}
if number, ok := val.(float32); ok {
return transform(float64(number))
}
if number, ok := val.(float64); ok {
return transform(float64(number))
}
return ""
}
// NewJSONTransformer returns a Transformer that can format a JSON string or an
// object into pretty-indented JSON-strings.
func NewJSONTransformer(prefix string, indent string) Transformer {
return func(val interface{}) string {
if valStr, ok := val.(string); ok {
var b bytes.Buffer
if err := json.Indent(&b, []byte(strings.TrimSpace(valStr)), prefix, indent); err == nil {
return string(b.Bytes())
}
} else if b, err := json.MarshalIndent(val, prefix, indent); err == nil {
return string(b)
}
return fmt.Sprintf("%#v", val)
}
}
// NewTimeTransformer returns a Transformer that can format a timestamp (a
// time.Time) into a well-defined time format defined using the provided layout
// (ex.: time.RFC3339).
//
// If a non-nil location value is provided, the time will be localized to that
// location (use time.Local to get localized timestamps).
func NewTimeTransformer(layout string, location *time.Location) Transformer {
return func(val interface{}) string {
rsp := fmt.Sprint(val)
if valTime, ok := val.(time.Time); ok {
rsp = formatTime(valTime, layout, location)
} else {
// cycle through some supported layouts to see if the string form
// of the object matches any of these layouts
for _, possibleTimeLayout := range possibleTimeLayouts {
if valTime, err := time.Parse(possibleTimeLayout, rsp); err == nil {
rsp = formatTime(valTime, layout, location)
break
}
}
}
return rsp
}
}
// NewUnixTimeTransformer returns a Transformer that can format a unix-timestamp
// into a well-defined time format as defined by 'layout'. This can handle
// unix-time in Seconds, MilliSeconds, Microseconds and Nanoseconds.
//
// If a non-nil location value is provided, the time will be localized to that
// location (use time.Local to get localized timestamps).
func NewUnixTimeTransformer(layout string, location *time.Location) Transformer {
transformer := NewTimeTransformer(layout, location)
return func(val interface{}) string {
if unixTime, ok := val.(int64); ok {
return formatTimeUnix(unixTime, transformer)
} else if unixTimeStr, ok := val.(string); ok {
if unixTime, err := strconv.ParseInt(unixTimeStr, 10, 64); err == nil {
return formatTimeUnix(unixTime, transformer)
}
}
return fmt.Sprint(val)
}
}
// NewURLTransformer returns a Transformer that can format and pretty print a string
// that contains a URL (the text is underlined and colored Blue).
func NewURLTransformer(colors ...Color) Transformer {
colorsToUse := colorsURL
if len(colors) > 0 {
colorsToUse = colors
}
return func(val interface{}) string {
return colorsToUse.Sprint(val)
}
}
func formatTime(t time.Time, layout string, location *time.Location) string {
rsp := ""
if t.Unix() > 0 {
if location != nil {
t = t.In(location)
}
rsp = t.Format(layout)
}
return rsp
}
func formatTimeUnix(unixTime int64, timeTransformer Transformer) string {
if unixTime >= unixTimeMinNanoSeconds {
unixTime = unixTime / time.Second.Nanoseconds()
} else if unixTime >= unixTimeMinMicroseconds {
unixTime = unixTime / (time.Second.Nanoseconds() / 1000)
} else if unixTime >= unixTimeMinMilliseconds {
unixTime = unixTime / (time.Second.Nanoseconds() / 1000000)
}
return timeTransformer(time.Unix(unixTime, 0))
}

View File

@ -0,0 +1,233 @@
package text
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestNewNumberTransformer(t *testing.T) {
signColorsMap := map[string]Colors{
"negative": colorsNumberNegative,
"positive": colorsNumberPositive,
"zero": nil,
"nil": nil,
}
colorValuesMap := map[string]map[interface{}]string{
"negative": {
int(-5): "%05d",
int8(-5): "%05d",
int16(-5): "%05d",
int32(-5): "%05d",
int64(-5): "%05d",
float32(-5.55555): "%08.2f",
float64(-5.55555): "%08.2f",
},
"positive": {
int(5): "%05d",
int8(5): "%05d",
int16(5): "%05d",
int32(5): "%05d",
int64(5): "%05d",
uint(5): "%05d",
uint8(5): "%05d",
uint16(5): "%05d",
uint32(5): "%05d",
uint64(5): "%05d",
float32(5.55555): "%08.2f",
float64(5.55555): "%08.2f",
},
"zero": {
int(0): "%05d",
int8(0): "%05d",
int16(0): "%05d",
int32(0): "%05d",
int64(0): "%05d",
uint(0): "%05d",
uint8(0): "%05d",
uint16(0): "%05d",
uint32(0): "%05d",
uint64(0): "%05d",
float32(0.00000): "%08.2f",
float64(0.00000): "%08.2f",
},
"nil": {
nil: "%v",
},
}
for sign, valuesFormatMap := range colorValuesMap {
for value, format := range valuesFormatMap {
transformer := NewNumberTransformer(format)
expected := signColorsMap[sign].Sprintf(format, value)
if sign == "negative" {
expected = strings.Replace(expected, "-0", "-00", 1)
}
actual := transformer(value)
var kind reflect.Kind
if value != nil {
kind = reflect.TypeOf(value).Kind()
}
message := fmt.Sprintf("%s.%s: expected=%v, actual=%v; format=%#v",
sign, kind, expected, actual, format)
assert.Equal(t, expected, actual, message)
}
}
// invalid input
assert.Equal(t, "foo", NewNumberTransformer("%05d")("foo"))
}
type jsonTest struct {
Foo string `json:"foo"`
Bar int32 `json:"bar"`
Baz float64 `json:"baz"`
Nan jsonNestTest `json:"nan"`
}
type jsonNestTest struct {
A string
B int32
C float64
}
func TestNewJSONTransformer(t *testing.T) {
transformer := NewJSONTransformer("", " ")
// instance of a struct
inputObj := jsonTest{
Foo: "fooooooo",
Bar: 13,
Baz: 3.14,
Nan: jsonNestTest{
A: "a",
B: 2,
C: 3.0,
},
}
expectedOutput := `{
"foo": "fooooooo",
"bar": 13,
"baz": 3.14,
"nan": {
"A": "a",
"B": 2,
"C": 3
}
}`
assert.Equal(t, expectedOutput, transformer(inputObj))
// numbers
assert.Equal(t, "1", transformer(int(1)))
assert.Equal(t, "1.2345", transformer(float32(1.2345)))
// slices
assert.Equal(t, "[\n 1,\n 2,\n 3\n]", transformer([]uint{1, 2, 3}))
// strings
assert.Equal(t, "\"foo\"", transformer("foo"))
assert.Equal(t, "\"{foo...\"", transformer("{foo...")) // malformed JSON
// strings with valid JSON
input := "{\"foo\":\"bar\",\"baz\":[1,2,3]}"
expectedOutput = `{
"foo": "bar",
"baz": [
1,
2,
3
]
}`
assert.Equal(t, expectedOutput, transformer(input))
}
func TestNewTimeTransformer(t *testing.T) {
inStr := "2010-11-12T13:14:15-07:00"
inTime, err := time.Parse(time.RFC3339, inStr)
assert.Nil(t, err)
location, err := time.LoadLocation("America/Los_Angeles")
assert.Nil(t, err)
transformer := NewTimeTransformer(time.RFC3339, location)
expected := "2010-11-12T12:14:15-08:00"
assert.Equal(t, expected, transformer(inStr))
assert.Equal(t, expected, transformer(inTime))
for _, possibleTimeLayout := range possibleTimeLayouts {
assert.Equal(t, expected, transformer(inTime.Format(possibleTimeLayout)), possibleTimeLayout)
}
location, err = time.LoadLocation("Asia/Singapore")
assert.Nil(t, err)
transformer = NewTimeTransformer(time.UnixDate, location)
expected = "Sat Nov 13 04:14:15 +08 2010"
assert.Equal(t, expected, transformer(inStr))
assert.Equal(t, expected, transformer(inTime))
for _, possibleTimeLayout := range possibleTimeLayouts {
assert.Equal(t, expected, transformer(inTime.Format(possibleTimeLayout)), possibleTimeLayout)
}
location, err = time.LoadLocation("Europe/London")
assert.Nil(t, err)
transformer = NewTimeTransformer(time.RFC3339, location)
expected = "2010-11-12T20:14:15Z"
assert.Equal(t, expected, transformer(inStr))
assert.Equal(t, expected, transformer(inTime))
for _, possibleTimeLayout := range possibleTimeLayouts {
assert.Equal(t, expected, transformer(inTime.Format(possibleTimeLayout)), possibleTimeLayout)
}
}
func TestNewUnixTimeTransformer(t *testing.T) {
inStr := "2010-11-12T13:14:15-07:00"
inTime, err := time.Parse(time.RFC3339, inStr)
assert.Nil(t, err)
inUnixTime := inTime.Unix()
location, err := time.LoadLocation("America/Los_Angeles")
assert.Nil(t, err)
transformer := NewUnixTimeTransformer(time.RFC3339, location)
expected := "2010-11-12T12:14:15-08:00"
assert.Equal(t, expected, transformer(fmt.Sprint(inUnixTime)), "seconds in string")
assert.Equal(t, expected, transformer(inUnixTime), "seconds")
assert.Equal(t, expected, transformer(inUnixTime*1000), "milliseconds")
assert.Equal(t, expected, transformer(inUnixTime*1000000), "microseconds")
assert.Equal(t, expected, transformer(inUnixTime*1000000000), "nanoseconds")
location, err = time.LoadLocation("Asia/Singapore")
assert.Nil(t, err)
transformer = NewUnixTimeTransformer(time.UnixDate, location)
expected = "Sat Nov 13 04:14:15 +08 2010"
assert.Equal(t, expected, transformer(fmt.Sprint(inUnixTime)), "seconds in string")
assert.Equal(t, expected, transformer(inUnixTime), "seconds")
assert.Equal(t, expected, transformer(inUnixTime*1000), "milliseconds")
assert.Equal(t, expected, transformer(inUnixTime*1000000), "microseconds")
assert.Equal(t, expected, transformer(inUnixTime*1000000000), "nanoseconds")
location, err = time.LoadLocation("Europe/London")
assert.Nil(t, err)
transformer = NewUnixTimeTransformer(time.RFC3339, location)
expected = "2010-11-12T20:14:15Z"
assert.Equal(t, expected, transformer(fmt.Sprint(inUnixTime)), "seconds in string")
assert.Equal(t, expected, transformer(inUnixTime), "seconds")
assert.Equal(t, expected, transformer(inUnixTime*1000), "milliseconds")
assert.Equal(t, expected, transformer(inUnixTime*1000000), "microseconds")
assert.Equal(t, expected, transformer(inUnixTime*1000000000), "nanoseconds")
assert.Equal(t, "0.123456", transformer(float32(0.123456)))
}
func TestNewURLTransformer(t *testing.T) {
url := "https://winter.is.coming"
transformer := NewURLTransformer()
assert.Equal(t, colorsURL.Sprint(url), transformer(url))
transformer2 := NewURLTransformer(FgRed, BgWhite, Bold)
assert.Equal(t, Colors{FgRed, BgWhite, Bold}.Sprint(url), transformer2(url))
assert.Equal(t, colorsURL.Sprint(url), transformer(url))
}

67
helper/text/valign.go Normal file
View File

@ -0,0 +1,67 @@
package text
import "strings"
// VAlign denotes how text is to be aligned vertically.
type VAlign int
// VAlign enumerations
const (
VAlignDefault VAlign = iota // same as VAlignTop
VAlignTop // "top\n\n"
VAlignMiddle // "\nmiddle\n"
VAlignBottom // "\n\nbottom"
)
// Apply aligns the lines vertically. For ex.:
// * VAlignTop.Apply({"Game", "Of", "Thrones"}, 5)
// returns {"Game", "Of", "Thrones", "", ""}
// * VAlignMiddle.Apply({"Game", "Of", "Thrones"}, 5)
// returns {"", "Game", "Of", "Thrones", ""}
// * VAlignBottom.Apply({"Game", "Of", "Thrones"}, 5)
// returns {"", "", "Game", "Of", "Thrones"}
func (va VAlign) Apply(lines []string, maxLines int) []string {
if len(lines) == maxLines {
return lines
} else if len(lines) > maxLines {
maxLines = len(lines)
}
insertIdx := 0
if va == VAlignMiddle {
insertIdx = int(maxLines-len(lines)) / 2
} else if va == VAlignBottom {
insertIdx = maxLines - len(lines)
}
linesOut := strings.Split(strings.Repeat("\n", maxLines-1), "\n")
for idx, line := range lines {
linesOut[idx+insertIdx] = line
}
return linesOut
}
// ApplyStr aligns the string (of 1 or more lines) vertically. For ex.:
// * VAlignTop.ApplyStr("Game\nOf\nThrones", 5)
// returns {"Game", "Of", "Thrones", "", ""}
// * VAlignMiddle.ApplyStr("Game\nOf\nThrones", 5)
// returns {"", "Game", "Of", "Thrones", ""}
// * VAlignBottom.ApplyStr("Game\nOf\nThrones", 5)
// returns {"", "", "Game", "Of", "Thrones"}
func (va VAlign) ApplyStr(text string, maxLines int) []string {
return va.Apply(strings.Split(text, "\n"), maxLines)
}
// HTMLProperty returns the equivalent HTML vertical-align tag property.
func (va VAlign) HTMLProperty() string {
switch va {
case VAlignTop:
return "valign=\"top\""
case VAlignMiddle:
return "valign=\"middle\""
case VAlignBottom:
return "valign=\"bottom\""
default:
return ""
}
}

101
helper/text/valign_test.go Normal file
View File

@ -0,0 +1,101 @@
package text
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func ExampleVAlign_Apply() {
lines := []string{"Game", "Of", "Thrones"}
maxLines := 5
fmt.Printf("VAlignDefault: %#v\n", VAlignDefault.Apply(lines, maxLines))
fmt.Printf("VAlignTop : %#v\n", VAlignTop.Apply(lines, maxLines))
fmt.Printf("VAlignMiddle : %#v\n", VAlignMiddle.Apply(lines, maxLines))
fmt.Printf("VAlignBottom : %#v\n", VAlignBottom.Apply(lines, maxLines))
// Output: VAlignDefault: []string{"Game", "Of", "Thrones", "", ""}
// VAlignTop : []string{"Game", "Of", "Thrones", "", ""}
// VAlignMiddle : []string{"", "Game", "Of", "Thrones", ""}
// VAlignBottom : []string{"", "", "Game", "Of", "Thrones"}
}
func TestVAlign_Apply(t *testing.T) {
assert.Equal(t, []string{"Game", "Of", "Thrones"},
VAlignDefault.Apply([]string{"Game", "Of", "Thrones"}, 1))
assert.Equal(t, []string{"Game", "Of", "Thrones"},
VAlignDefault.Apply([]string{"Game", "Of", "Thrones"}, 3))
assert.Equal(t, []string{"Game", "Of", "Thrones", "", ""},
VAlignDefault.Apply([]string{"Game", "Of", "Thrones"}, 5))
assert.Equal(t, []string{"Game", "Of", "Thrones"},
VAlignTop.Apply([]string{"Game", "Of", "Thrones"}, 1))
assert.Equal(t, []string{"Game", "Of", "Thrones"},
VAlignTop.Apply([]string{"Game", "Of", "Thrones"}, 3))
assert.Equal(t, []string{"Game", "Of", "Thrones", "", ""},
VAlignTop.Apply([]string{"Game", "Of", "Thrones"}, 5))
assert.Equal(t, []string{"Game", "Of", "Thrones"},
VAlignMiddle.Apply([]string{"Game", "Of", "Thrones"}, 1))
assert.Equal(t, []string{"Game", "Of", "Thrones"},
VAlignMiddle.Apply([]string{"Game", "Of", "Thrones"}, 3))
assert.Equal(t, []string{"", "Game", "Of", "Thrones", ""},
VAlignMiddle.Apply([]string{"Game", "Of", "Thrones"}, 5))
assert.Equal(t, []string{"Game", "Of", "Thrones"},
VAlignBottom.Apply([]string{"Game", "Of", "Thrones"}, 1))
assert.Equal(t, []string{"Game", "Of", "Thrones"},
VAlignBottom.Apply([]string{"Game", "Of", "Thrones"}, 3))
assert.Equal(t, []string{"", "", "Game", "Of", "Thrones"},
VAlignBottom.Apply([]string{"Game", "Of", "Thrones"}, 5))
}
func ExampleVAlign_ApplyStr() {
str := "Game\nOf\nThrones"
maxLines := 5
fmt.Printf("VAlignDefault: %#v\n", VAlignDefault.ApplyStr(str, maxLines))
fmt.Printf("VAlignTop : %#v\n", VAlignTop.ApplyStr(str, maxLines))
fmt.Printf("VAlignMiddle : %#v\n", VAlignMiddle.ApplyStr(str, maxLines))
fmt.Printf("VAlignBottom : %#v\n", VAlignBottom.ApplyStr(str, maxLines))
// Output: VAlignDefault: []string{"Game", "Of", "Thrones", "", ""}
// VAlignTop : []string{"Game", "Of", "Thrones", "", ""}
// VAlignMiddle : []string{"", "Game", "Of", "Thrones", ""}
// VAlignBottom : []string{"", "", "Game", "Of", "Thrones"}
}
func TestVAlign_ApplyStr(t *testing.T) {
assert.Equal(t, []string{"Game", "Of", "Thrones", "", ""},
VAlignDefault.ApplyStr("Game\nOf\nThrones", 5))
assert.Equal(t, []string{"Game", "Of", "Thrones", "", ""},
VAlignTop.ApplyStr("Game\nOf\nThrones", 5))
assert.Equal(t, []string{"", "Game", "Of", "Thrones", ""},
VAlignMiddle.ApplyStr("Game\nOf\nThrones", 5))
assert.Equal(t, []string{"", "", "Game", "Of", "Thrones"},
VAlignBottom.ApplyStr("Game\nOf\nThrones", 5))
}
func ExampleVAlign_HTMLProperty() {
fmt.Printf("VAlignDefault: '%s'\n", VAlignDefault.HTMLProperty())
fmt.Printf("VAlignTop : '%s'\n", VAlignTop.HTMLProperty())
fmt.Printf("VAlignMiddle : '%s'\n", VAlignMiddle.HTMLProperty())
fmt.Printf("VAlignBottom : '%s'\n", VAlignBottom.HTMLProperty())
// Output: VAlignDefault: ''
// VAlignTop : 'valign="top"'
// VAlignMiddle : 'valign="middle"'
// VAlignBottom : 'valign="bottom"'
}
func TestVAlign_HTMLProperty(t *testing.T) {
vAligns := map[VAlign]string{
VAlignDefault: "",
VAlignTop: "top",
VAlignMiddle: "middle",
VAlignBottom: "bottom",
}
for vAlign, htmlStyle := range vAligns {
assert.Contains(t, vAlign.HTMLProperty(), htmlStyle)
}
}

266
helper/text/wrap.go Normal file
View File

@ -0,0 +1,266 @@
package text
import (
"strings"
"unicode/utf8"
)
// WrapHard wraps a string to the given length using a newline. Handles strings
// with ANSI escape sequences (such as text color) without breaking the text
// formatting. Breaks all words that go beyond the line boundary.
//
// For examples, refer to the unit-tests or GoDoc examples.
func WrapHard(str string, wrapLen int) string {
if wrapLen <= 0 {
return ""
}
str = strings.Replace(str, "\t", " ", -1)
sLen := utf8.RuneCountInString(str)
if sLen <= wrapLen {
return str
}
out := &strings.Builder{}
out.Grow(sLen + (sLen / wrapLen))
for idx, paragraph := range strings.Split(str, "\n\n") {
if idx > 0 {
out.WriteString("\n\n")
}
wrapHard(paragraph, wrapLen, out)
}
return out.String()
}
// WrapSoft wraps a string to the given length using a newline. Handles strings
// with ANSI escape sequences (such as text color) without breaking the text
// formatting. Tries to move words that go beyond the line boundary to the next
// line.
//
// For examples, refer to the unit-tests or GoDoc examples.
func WrapSoft(str string, wrapLen int) string {
if wrapLen <= 0 {
return ""
}
str = strings.Replace(str, "\t", " ", -1)
sLen := utf8.RuneCountInString(str)
if sLen <= wrapLen {
return str
}
out := &strings.Builder{}
out.Grow(sLen + (sLen / wrapLen))
for idx, paragraph := range strings.Split(str, "\n\n") {
if idx > 0 {
out.WriteString("\n\n")
}
wrapSoft(paragraph, wrapLen, out)
}
return out.String()
}
// WrapText is very similar to WrapHard except for one minor difference. Unlike
// WrapHard which discards line-breaks and respects only paragraph-breaks, this
// function respects line-breaks too.
//
// For examples, refer to the unit-tests or GoDoc examples.
func WrapText(str string, wrapLen int) string {
if wrapLen <= 0 {
return ""
}
var out strings.Builder
sLen := utf8.RuneCountInString(str)
out.Grow(sLen + (sLen / wrapLen))
lineIdx, isEscSeq, lastEscSeq := 0, false, ""
for _, char := range str {
if char == EscapeStartRune {
isEscSeq = true
lastEscSeq = ""
}
if isEscSeq {
lastEscSeq += string(char)
}
appendChar(char, wrapLen, &lineIdx, isEscSeq, lastEscSeq, &out)
if isEscSeq && char == EscapeStopRune {
isEscSeq = false
}
if lastEscSeq == EscapeReset {
lastEscSeq = ""
}
}
if lastEscSeq != "" && lastEscSeq != EscapeReset {
out.WriteString(EscapeReset)
}
return out.String()
}
func appendChar(char rune, wrapLen int, lineLen *int, inEscSeq bool, lastSeenEscSeq string, out *strings.Builder) {
// handle reaching the end of the line as dictated by wrapLen or by finding
// a newline character
if (*lineLen == wrapLen && !inEscSeq && char != '\n') || (char == '\n') {
if lastSeenEscSeq != "" {
// terminate escape sequence and the line; and restart the escape
// sequence in the next line
out.WriteString(EscapeReset)
out.WriteRune('\n')
out.WriteString(lastSeenEscSeq)
} else {
// just start a new line
out.WriteRune('\n')
}
// reset line index to 0th character
*lineLen = 0
}
// if the rune is not a new line, output it
if char != '\n' {
out.WriteRune(char)
// increment the line index if not in the middle of an escape sequence
if !inEscSeq {
*lineLen++
}
}
}
func appendWord(word string, lineIdx *int, lastSeenEscSeq string, wrapLen int, out *strings.Builder) {
inEscSeq := false
for _, char := range word {
if char == EscapeStartRune {
inEscSeq = true
lastSeenEscSeq = ""
}
if inEscSeq {
lastSeenEscSeq += string(char)
}
appendChar(char, wrapLen, lineIdx, inEscSeq, lastSeenEscSeq, out)
if inEscSeq && char == EscapeStopRune {
inEscSeq = false
}
if lastSeenEscSeq == EscapeReset {
lastSeenEscSeq = ""
}
}
}
func extractOpenEscapeSeq(str string) string {
escapeSeq, inEscSeq := "", false
for _, char := range str {
if char == EscapeStartRune {
inEscSeq = true
escapeSeq = ""
}
if inEscSeq {
escapeSeq += string(char)
}
if char == EscapeStopRune {
inEscSeq = false
}
}
if escapeSeq == EscapeReset {
escapeSeq = ""
}
return escapeSeq
}
func terminateLine(wrapLen int, lineLen *int, lastSeenEscSeq string, out *strings.Builder) {
if *lineLen < wrapLen {
out.WriteString(strings.Repeat(" ", wrapLen-*lineLen))
}
// something is already on the line; terminate it
if lastSeenEscSeq != "" {
out.WriteString(EscapeReset)
}
out.WriteRune('\n')
out.WriteString(lastSeenEscSeq)
*lineLen = 0
}
func terminateOutput(lastSeenEscSeq string, out *strings.Builder) {
if lastSeenEscSeq != "" && lastSeenEscSeq != EscapeReset && !strings.HasSuffix(out.String(), EscapeReset) {
out.WriteString(EscapeReset)
}
}
func wrapHard(paragraph string, wrapLen int, out *strings.Builder) {
lineLen, lastSeenEscSeq := 0, ""
words := strings.Fields(paragraph)
for wordIdx, word := range words {
escSeq := extractOpenEscapeSeq(word)
if escSeq != "" {
lastSeenEscSeq = escSeq
}
if lineLen > 0 {
out.WriteRune(' ')
lineLen++
}
wordLen := RuneWidthWithoutEscSequences(word)
if lineLen+wordLen <= wrapLen { // word fits within the line
out.WriteString(word)
lineLen += wordLen
} else { // word doesn't fit within the line; hard-wrap
appendWord(word, &lineLen, lastSeenEscSeq, wrapLen, out)
}
// end of line; but more words incoming
if lineLen == wrapLen && wordIdx < len(words)-1 {
terminateLine(wrapLen, &lineLen, lastSeenEscSeq, out)
}
}
terminateOutput(lastSeenEscSeq, out)
}
func wrapSoft(paragraph string, wrapLen int, out *strings.Builder) {
lineLen, lastSeenEscSeq := 0, ""
words := strings.Fields(paragraph)
for wordIdx, word := range words {
escSeq := extractOpenEscapeSeq(word)
if escSeq != "" {
lastSeenEscSeq = escSeq
}
spacing, spacingLen := wrapSoftSpacing(lineLen)
wordLen := RuneWidthWithoutEscSequences(word)
if lineLen+spacingLen+wordLen <= wrapLen { // word fits within the line
out.WriteString(spacing)
out.WriteString(word)
lineLen += spacingLen + wordLen
} else { // word doesn't fit within the line
lineLen = wrapSoftLastWordInLine(wrapLen, lineLen, lastSeenEscSeq, wordLen, word, out)
}
// end of line; but more words incoming
if lineLen == wrapLen && wordIdx < len(words)-1 {
terminateLine(wrapLen, &lineLen, lastSeenEscSeq, out)
}
}
terminateOutput(lastSeenEscSeq, out)
}
func wrapSoftLastWordInLine(wrapLen int, lineLen int, lastSeenEscSeq string, wordLen int, word string, out *strings.Builder) int {
if lineLen > 0 { // something is already on the line; terminate it
terminateLine(wrapLen, &lineLen, lastSeenEscSeq, out)
}
if wordLen <= wrapLen { // word fits within a single line
out.WriteString(word)
lineLen = wordLen
} else { // word doesn't fit within a single line; hard-wrap
appendWord(word, &lineLen, lastSeenEscSeq, wrapLen, out)
}
return lineLen
}
func wrapSoftSpacing(lineLen int) (string, int) {
spacing, spacingLen := "", 0
if lineLen > 0 {
spacing, spacingLen = " ", 1
}
return spacing, spacingLen
}

147
helper/text/wrap_test.go Normal file
View File

@ -0,0 +1,147 @@
package text
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func ExampleWrapHard() {
str := `The quick brown fox jumped over the lazy dog.
A big crocodile died empty-fanged, gulping horribly in jerking kicking little
motions. Nonchalant old Peter Quinn ruthlessly shot the under-water vermin with
Xavier yelling Zap!`
strWrapped := WrapHard(str, 30)
for idx, line := range strings.Split(strWrapped, "\n") {
fmt.Printf("Line #%02d: '%s'\n", idx+1, line)
}
// Output: Line #01: 'The quick brown fox jumped ove'
// Line #02: 'r the lazy dog.'
// Line #03: ''
// Line #04: 'A big crocodile died empty-fan'
// Line #05: 'ged, gulping horribly in jerki'
// Line #06: 'ng kicking little motions. Non'
// Line #07: 'chalant old Peter Quinn ruthle'
// Line #08: 'ssly shot the under-water verm'
// Line #09: 'in with Xavier yelling Zap!'
}
func TestWrapHard(t *testing.T) {
assert.Equal(t, "", WrapHard("Ghost", 0))
assert.Equal(t, "G\nh\no\ns\nt", WrapHard("Ghost", 1))
assert.Equal(t, "Gh\nos\nt", WrapHard("Ghost", 2))
assert.Equal(t, "Gho\nst", WrapHard("Ghost", 3))
assert.Equal(t, "Ghos\nt", WrapHard("Ghost", 4))
assert.Equal(t, "Ghost", WrapHard("Ghost", 5))
assert.Equal(t, "Ghost", WrapHard("Ghost", 6))
assert.Equal(t, "Jo\nn \nSn\now", WrapHard("Jon\nSnow", 2))
assert.Equal(t, "Jo\nn \nSn\now", WrapHard("Jon\nSnow\n", 2))
assert.Equal(t, "Jon\nSno\nw", WrapHard("Jon\nSnow\n", 3))
assert.Equal(t, "Jon i\ns a S\nnow", WrapHard("Jon is a Snow", 5))
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapHard("\x1b[33mJon\x1b[0m\nSnow", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapHard("\x1b[33mJon\x1b[0m\nSnow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapHard("\x1b[33mJon Snow\x1b[0m", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapHard("\x1b[33mJon Snow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw \x1b[0m", WrapHard("\x1b[33mJon Snow\n\x1b[0m", 3))
complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+"
assert.Equal(t, complexIn, WrapHard(complexIn, 27))
}
func ExampleWrapSoft() {
str := `The quick brown fox jumped over the lazy dog.
A big crocodile died empty-fanged, gulping horribly in jerking kicking little
motions. Nonchalant old Peter Quinn ruthlessly shot the under-water vermin with
Xavier yelling Zap!`
strWrapped := WrapSoft(str, 30)
for idx, line := range strings.Split(strWrapped, "\n") {
fmt.Printf("Line #%02d: '%s'\n", idx+1, line)
}
// Output: Line #01: 'The quick brown fox jumped '
// Line #02: 'over the lazy dog.'
// Line #03: ''
// Line #04: 'A big crocodile died '
// Line #05: 'empty-fanged, gulping horribly'
// Line #06: 'in jerking kicking little '
// Line #07: 'motions. Nonchalant old Peter '
// Line #08: 'Quinn ruthlessly shot the '
// Line #09: 'under-water vermin with Xavier'
// Line #10: 'yelling Zap!'
}
func TestWrapSoft(t *testing.T) {
assert.Equal(t, "", WrapSoft("Ghost", 0))
assert.Equal(t, "G\nh\no\ns\nt", WrapSoft("Ghost", 1))
assert.Equal(t, "Gh\nos\nt", WrapSoft("Ghost", 2))
assert.Equal(t, "Gho\nst", WrapSoft("Ghost", 3))
assert.Equal(t, "Ghos\nt", WrapSoft("Ghost", 4))
assert.Equal(t, "Ghost", WrapSoft("Ghost", 5))
assert.Equal(t, "Ghost", WrapSoft("Ghost", 6))
assert.Equal(t, "Jo\nn \nSn\now", WrapSoft("Jon\nSnow", 2))
assert.Equal(t, "Jo\nn \nSn\now", WrapSoft("Jon\nSnow\n", 2))
assert.Equal(t, "Jon\nSno\nw", WrapSoft("Jon\nSnow\n", 3))
assert.Equal(t, "Jon \nSnow", WrapSoft("Jon\nSnow", 4))
assert.Equal(t, "Jon \nSnow", WrapSoft("Jon\nSnow\n", 4))
assert.Equal(t, "Jon \nis a \nSnow", WrapSoft("Jon is a Snow", 5))
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapSoft("\x1b[33mJon\x1b[0m\nSnow", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapSoft("\x1b[33mJon\x1b[0m\nSnow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapSoft("\x1b[33mJon Snow\x1b[0m", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapSoft("\x1b[33mJon Snow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw \x1b[0m", WrapSoft("\x1b[33mJon Snow\n\x1b[0m", 3))
complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+"
assert.Equal(t, complexIn, WrapSoft(complexIn, 27))
assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m", WrapSoft("\x1b[33mJon Snow\x1b[0m", 4))
assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m\n\x1b[33m???\x1b[0m", WrapSoft("\x1b[33mJon Snow???\x1b[0m", 4))
}
func ExampleWrapText() {
str := `The quick brown fox jumped over the lazy dog.
A big crocodile died empty-fanged, gulping horribly in jerking kicking little
motions. Nonchalant old Peter Quinn ruthlessly shot the under-water vermin with
Xavier yelling Zap!`
strWrapped := WrapText(str, 30)
for idx, line := range strings.Split(strWrapped, "\n") {
fmt.Printf("Line #%02d: '%s'\n", idx+1, line)
}
// Output: Line #01: 'The quick brown fox jumped ove'
// Line #02: 'r the lazy dog.'
// Line #03: ''
// Line #04: 'A big crocodile died empty-fan'
// Line #05: 'ged, gulping horribly in jerki'
// Line #06: 'ng kicking little'
// Line #07: 'motions. Nonchalant old Peter '
// Line #08: 'Quinn ruthlessly shot the unde'
// Line #09: 'r-water vermin with'
// Line #10: 'Xavier yelling Zap!'
}
func TestWrapText(t *testing.T) {
assert.Equal(t, "", WrapText("Ghost", 0))
assert.Equal(t, "G\nh\no\ns\nt", WrapText("Ghost", 1))
assert.Equal(t, "Gh\nos\nt", WrapText("Ghost", 2))
assert.Equal(t, "Gho\nst", WrapText("Ghost", 3))
assert.Equal(t, "Ghos\nt", WrapText("Ghost", 4))
assert.Equal(t, "Ghost", WrapText("Ghost", 5))
assert.Equal(t, "Ghost", WrapText("Ghost", 6))
assert.Equal(t, "Jo\nn\nSn\now", WrapText("Jon\nSnow", 2))
assert.Equal(t, "Jo\nn\nSn\now\n", WrapText("Jon\nSnow\n", 2))
assert.Equal(t, "Jon\nSno\nw\n", WrapText("Jon\nSnow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapText("\x1b[33mJon\x1b[0m\nSnow", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw\n", WrapText("\x1b[33mJon\x1b[0m\nSnow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m", WrapText("\x1b[33mJon Snow\x1b[0m", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m\n\x1b[33m\x1b[0m", WrapText("\x1b[33mJon Snow\n", 3))
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m\n\x1b[33m\x1b[0m", WrapText("\x1b[33mJon Snow\n\x1b[0m", 3))
complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+"
assert.Equal(t, complexIn, WrapText(complexIn, 27))
}