Compare commits
5 Commits
f98f8416e8
...
v0.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
| de11a0214b | |||
| 936df98cc4 | |||
| 56b264fcb3 | |||
| 99b57c88b0 | |||
| 6cf0b66dd5 |
@@ -1,13 +1,13 @@
|
|||||||
# A dev container.
|
# A dev container.
|
||||||
|
|
||||||
FROM alpine:3.22
|
FROM alpine:3.24
|
||||||
|
|
||||||
RUN apk add build-base git go sqlite vim shellcheck curl jq sudo bash docker
|
RUN apk add build-base git go sqlite vim shellcheck curl jq sudo bash docker
|
||||||
|
|
||||||
# Busybox `less` doesn't appear to support colors (makes git diff lose color)
|
# Busybox `less` doesn't appear to support colors (makes git diff lose color)
|
||||||
RUN apk add less
|
RUN apk add less
|
||||||
|
|
||||||
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v2.0.2
|
RUN curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b /usr/local/bin v2.14.0
|
||||||
RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
|
RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
|
||||||
|
|
||||||
COPY etc/group /etc/group
|
COPY etc/group /etc/group
|
||||||
|
|||||||
@@ -296,9 +296,8 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
updatePairs := make([]string, 0, len(tbl.Columns))
|
updatePairs := make([]string, 0, len(tbl.Columns))
|
||||||
|
|
||||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||||
hasSoftDelete := tbl.HasSoftDelete()
|
|
||||||
|
|
||||||
// Assemble data for building SQL "insert" and "update" strings
|
// Assemble SQL "insert" string
|
||||||
for _, col := range tbl.Columns {
|
for _, col := range tbl.Columns {
|
||||||
if col.Name == "rowid" {
|
if col.Name == "rowid" {
|
||||||
continue
|
continue
|
||||||
@@ -309,23 +308,20 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
val = fmt.Sprintf("nullif(%s, 0)", val)
|
val = fmt.Sprintf("nullif(%s, 0)", val)
|
||||||
}
|
}
|
||||||
insertVals = append(insertVals, val)
|
insertVals = append(insertVals, val)
|
||||||
// created_at should not be updated after creation
|
|
||||||
if col.Name == "created_at" && hasCreatedAt {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Soft-delete state is managed by SoftDeleteXyz(), not by updates
|
|
||||||
if hasSoftDelete && (col.Name == "is_deleted" || col.Name == "deleted_at") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !col.IsPrimaryKey { // Don't try to update primary key columns (mainly for w/o rowid tables)
|
|
||||||
updatePairs = append(updatePairs, col.Name+"="+val)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t",
|
insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t",
|
||||||
tbl.TableName,
|
tbl.TableName,
|
||||||
strings.Join(insertCols, ", "),
|
strings.Join(insertCols, ", "),
|
||||||
strings.Join(insertVals, ", "),
|
strings.Join(insertVals, ", "),
|
||||||
)
|
)
|
||||||
|
// ...and "update" string
|
||||||
|
for _, col := range tbl.GetUpdateableColumns() {
|
||||||
|
val := ":" + col.Name
|
||||||
|
if col.IsNullableForeignKey() {
|
||||||
|
val = fmt.Sprintf("nullif(%s, 0)", val)
|
||||||
|
}
|
||||||
|
updatePairs = append(updatePairs, col.Name+"="+val)
|
||||||
|
}
|
||||||
updateStmt := fmt.Sprintf("\n\t\t update %s\n\t\t set %s\n\t\t where rowid = :rowid\n\t\t",
|
updateStmt := fmt.Sprintf("\n\t\t update %s\n\t\t set %s\n\t\t where rowid = :rowid\n\t\t",
|
||||||
tbl.TableName,
|
tbl.TableName,
|
||||||
strings.Join(updatePairs, ",\n\t\t "),
|
strings.Join(updatePairs, ",\n\t\t "),
|
||||||
|
|||||||
@@ -67,26 +67,18 @@ func SampleValue(c pkgschema.Column, offset int) ast.Expr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTestFields returns the columns for which the test generator should assign its own
|
// UpdateTestFields returns the columns for which the test generator should assign its own
|
||||||
// sample values, in the MakeXyz() factory and in the create/update test: the same columns
|
// sample values in MakeXyz() and the create/update test. It starts with Table.GetUpdateableColumns()
|
||||||
// that GenerateSaveItemFunc's "update" branch writes to, minus foreign keys (which can't be
|
// and removes fields the generated test should not assign directly: foreign keys and auto-managed
|
||||||
// given plausible values here), the auto-managed "created_at"/"updated_at" columns, and the
|
// updated_at.
|
||||||
// soft-delete columns (which SoftDeleteXyz() manages).
|
|
||||||
func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) {
|
func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) {
|
||||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
_, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||||
hasSoftDelete := tbl.HasSoftDelete()
|
for _, c := range tbl.GetUpdateableColumns() {
|
||||||
for _, c := range tbl.Columns {
|
if c.IsForeignKey {
|
||||||
if c.Name == "rowid" || c.IsPrimaryKey || c.IsForeignKey {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.Name == "created_at" && hasCreatedAt {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if c.Name == "updated_at" && hasUpdatedAt {
|
if c.Name == "updated_at" && hasUpdatedAt {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if hasSoftDelete && (c.Name == "is_deleted" || c.Name == "deleted_at") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ret = append(ret, c)
|
ret = append(ret, c)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -486,6 +478,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
makeDeepEqual(testObj, testObj2),
|
makeDeepEqual(testObj, testObj2),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if len(tbl.GetUpdateableColumns()) > 0 { // Skip if no mutable fields
|
||||||
stmts = append(stmts,
|
stmts = append(stmts,
|
||||||
BlankLine(),
|
BlankLine(),
|
||||||
Comment("Update"),
|
Comment("Update"),
|
||||||
@@ -518,6 +511,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
// if deep.Equal(...) {...}
|
// if deep.Equal(...) {...}
|
||||||
makeDeepEqual(testObj, testObj2),
|
makeDeepEqual(testObj, testObj2),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
indexGets, hasIndexedGets := []ast.Stmt{
|
indexGets, hasIndexedGets := []ast.Stmt{
|
||||||
BlankLine(),
|
BlankLine(),
|
||||||
|
|||||||
51
pkg/db/vsc.go
Normal file
51
pkg/db/vsc.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const separator = "\x1f"
|
||||||
|
|
||||||
|
type VSC []string
|
||||||
|
|
||||||
|
// MarshalJSON replaces `nil` with `[]` for hygiene.
|
||||||
|
func (vs VSC) MarshalJSON() ([]byte, error) {
|
||||||
|
if vs == nil {
|
||||||
|
vs = VSC{}
|
||||||
|
}
|
||||||
|
return json.Marshal([]string(vs)) //nolint: wrapcheck // Passthrough
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs VSC) Value() (driver.Value, error) {
|
||||||
|
fields := make([]string, len(vs))
|
||||||
|
for i, v := range vs {
|
||||||
|
fields[i] = strings.ReplaceAll(v, separator, "")
|
||||||
|
}
|
||||||
|
return strings.Join(fields, separator), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrWrongVSCType = errors.New("incompatible DB type for VSC")
|
||||||
|
|
||||||
|
func (vs *VSC) Scan(src any) error {
|
||||||
|
var s string
|
||||||
|
switch v := src.(type) {
|
||||||
|
case string:
|
||||||
|
s = v
|
||||||
|
case []byte:
|
||||||
|
s = string(v)
|
||||||
|
case nil:
|
||||||
|
*vs = nil
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrWrongVSCType
|
||||||
|
}
|
||||||
|
if s == "" {
|
||||||
|
*vs = VSC{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*vs = strings.Split(s, separator)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
48
pkg/db/vsc_test.go
Normal file
48
pkg/db/vsc_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package db_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVSCDBRoundTrip(t *testing.T) {
|
||||||
|
conn, err := sqlx.Open("sqlite3", ":memory:")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, conn.Close()) })
|
||||||
|
|
||||||
|
_, err = conn.Exec(`create table vsc_items (id integer primary key, items text not null)`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
type vscRow struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Items db.VSC `db:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []vscRow{
|
||||||
|
{ID: 1, Items: db.VSC{"email"}},
|
||||||
|
{ID: 2, Items: db.VSC{"email", "profile"}},
|
||||||
|
{ID: 3, Items: db.VSC{}},
|
||||||
|
} {
|
||||||
|
_, err = conn.NamedExec(`insert into vsc_items (id, items) values (:id, :items)`, want)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got vscRow
|
||||||
|
require.NoError(t, conn.Get(&got, `select id, items from vsc_items where id = ?`, want.ID))
|
||||||
|
require.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
t.Run("nil gets upgrade to empty list", func(t *testing.T) {
|
||||||
|
want := vscRow{ID: 4, Items: nil}
|
||||||
|
_, err = conn.NamedExec(`insert into vsc_items (id, items) values (:id, :items)`, want)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got vscRow
|
||||||
|
require.NoError(t, conn.Get(&got, `select id, items from vsc_items where id = ?`, want.ID))
|
||||||
|
require.NotNil(t, got.Items)
|
||||||
|
require.Equal(t, db.VSC{}, got.Items)
|
||||||
|
})
|
||||||
|
}
|
||||||
76
pkg/generic/mutexmap.go
Normal file
76
pkg/generic/mutexmap.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package generic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MutexMap[K comparable, V any] struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
contents map[K]V
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMutexMap[K comparable, V any]() *MutexMap[K, V] {
|
||||||
|
return &MutexMap[K, V]{contents: make(map[K]V)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Set(key K, value V) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.contents[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Get(key K) (V, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
v, ok := m.contents[key]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Len() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return len(m.contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upd atomically updates the value for key. `fn` is used to determine how to update an existing
|
||||||
|
// value, if there is one. It will be passed the existing value, and should return `(newValue V, shouldReplace bool)`.
|
||||||
|
//
|
||||||
|
// Returns the value that ends up associated with key after the call and whether an update occurred.
|
||||||
|
func (m *MutexMap[K, V]) Upd(key K, fn func(prev V) (next V, shouldReplace bool)) (V, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
prev := m.contents[key]
|
||||||
|
if next, shouldReplace := fn(prev); shouldReplace {
|
||||||
|
m.contents[key] = next
|
||||||
|
return next, true
|
||||||
|
}
|
||||||
|
return prev, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Del removes key from the map. Returns true if the key was present.
|
||||||
|
func (m *MutexMap[K, V]) Del(key K) bool { //nolint: boolnames // Return value is incidental, not the main purpose of the function
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if _, ok := m.contents[key]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(m.contents, key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Cpy() map[K]V {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
cp := make(map[K]V, len(m.contents))
|
||||||
|
maps.Copy(cp, m.contents)
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Pop(key K) (ret V, isOk bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
ret, isOk = m.contents[key]
|
||||||
|
delete(m.contents, key)
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -76,3 +76,21 @@ func (t Table) HasSoftDelete() bool {
|
|||||||
}
|
}
|
||||||
return hasIsDeleted && hasDeletedAt
|
return hasIsDeleted && hasDeletedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUpdateableColumns returns the columns that should be written to by an "update" operation.
|
||||||
|
func (t Table) GetUpdateableColumns() (ret []Column) {
|
||||||
|
hasCreatedAt, _ := t.HasAutoTimestamps()
|
||||||
|
for _, col := range t.Columns {
|
||||||
|
if col.Name == "rowid" || col.IsPrimaryKey {
|
||||||
|
continue // Rowid / primary key is never updateable
|
||||||
|
}
|
||||||
|
if col.Name == "created_at" && hasCreatedAt {
|
||||||
|
continue // Update shouldn't write `created_at`
|
||||||
|
}
|
||||||
|
if t.HasSoftDelete() && (col.Name == "is_deleted" || col.Name == "deleted_at") {
|
||||||
|
continue // Soft-delete state is managed by SoftDeleteXyz(), not by updates
|
||||||
|
}
|
||||||
|
ret = append(ret, col)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
34
pkg/schema/table_test.go
Normal file
34
pkg/schema/table_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTableGetUpdateableColumns(t *testing.T) {
|
||||||
|
tbl := Table{
|
||||||
|
Columns: []Column{
|
||||||
|
{Name: "rowid"},
|
||||||
|
{Name: "version", Type: "integer", IsPrimaryKey: true},
|
||||||
|
{Name: "created_at", Type: "integer"},
|
||||||
|
{Name: "updated_at", Type: "integer"},
|
||||||
|
{Name: "is_deleted", Type: "integer"},
|
||||||
|
{Name: "deleted_at", Type: "integer"},
|
||||||
|
{Name: "owner_id", Type: "integer", IsForeignKey: true},
|
||||||
|
{Name: "name", Type: "text"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gotColumns := tbl.GetUpdateableColumns()
|
||||||
|
got := make([]string, 0, len(gotColumns))
|
||||||
|
for _, col := range gotColumns {
|
||||||
|
got = append(got, col.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"updated_at", "owner_id", "name"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("GetUpdateableColumns() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("GetUpdateableColumns() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user