Feat v0.5.3 chat polish + integration testing
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-frontend (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-frontend (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Conversation search (db.query search_like, chat-core /search endpoint, sidebar search UI), message pagination polish (scroll preservation, loading spinner), workflow-chat integration package, and multi-user E2E test infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
//
|
||||
// Starlark API:
|
||||
//
|
||||
// rows = db.query("logs", filters={"user_id": "abc"}, order="created_at", limit=50, before={"created_at": "2026-01-01"}, after={"count": 5})
|
||||
// rows = db.query("logs", filters={"user_id": "abc"}, order="created_at", limit=50, before={"created_at": "2026-01-01"}, after={"count": 5}, search_like={"title": "%hello%"})
|
||||
// row = db.insert("logs", {"message": "hello"})
|
||||
// ok = db.update("logs", row_id, {"message": "updated"})
|
||||
// ok = db.delete("logs", row_id)
|
||||
@@ -262,7 +262,54 @@ func (cfg DBModuleConfig) starlarkRangeToSQL(rangeVal starlark.Value, op string,
|
||||
return parts, args, nil
|
||||
}
|
||||
|
||||
// dbQuery implements db.query(table, filters=None, order=None, limit=100, before=None, after=None).
|
||||
// starlarkSearchLikeToSQL converts a Starlark dict of {col: pattern} into
|
||||
// a parenthesized OR group of LIKE (SQLite) / ILIKE (Postgres) clauses.
|
||||
// Example: {"title": "%hello%", "content": "%hello%"} → (title ILIKE $1 OR content ILIKE $2)
|
||||
func (cfg DBModuleConfig) starlarkSearchLikeToSQL(searchVal starlark.Value, startIdx int) (string, []any, error) {
|
||||
if searchVal == starlark.None || searchVal == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
d, ok := searchVal.(*starlark.Dict)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("db: search_like must be a dict, got %s", searchVal.Type())
|
||||
}
|
||||
if d.Len() == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
var parts []string
|
||||
var args []any
|
||||
idx := startIdx
|
||||
|
||||
op := "LIKE" // SQLite — case-insensitive for ASCII by default
|
||||
if cfg.IsPostgres {
|
||||
op = "ILIKE" // Postgres — explicit case-insensitive
|
||||
}
|
||||
|
||||
for _, item := range d.Items() {
|
||||
col, ok := item[0].(starlark.String)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("db: search_like key must be a string, got %s", item[0].Type())
|
||||
}
|
||||
colStr := string(col)
|
||||
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
||||
return "", nil, fmt.Errorf("db: invalid column name %q", colStr)
|
||||
}
|
||||
|
||||
val, err := starlarkToGoValue(item[1])
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("db: search_like value for %q: %w", colStr, err)
|
||||
}
|
||||
|
||||
parts = append(parts, fmt.Sprintf("%s %s %s", colStr, op, cfg.ph(idx)))
|
||||
args = append(args, val)
|
||||
idx++
|
||||
}
|
||||
|
||||
return "(" + strings.Join(parts, " OR ") + ")", args, nil
|
||||
}
|
||||
|
||||
// dbQuery implements db.query(table, filters=None, order=None, limit=100, before=None, after=None, search_like=None).
|
||||
func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var table string
|
||||
@@ -271,6 +318,7 @@ func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *st
|
||||
var limit starlark.Int = starlark.MakeInt(100)
|
||||
var before starlark.Value = starlark.None
|
||||
var after starlark.Value = starlark.None
|
||||
var searchLike starlark.Value = starlark.None
|
||||
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"table", &table,
|
||||
@@ -279,6 +327,7 @@ func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *st
|
||||
"limit?", &limit,
|
||||
"before?", &before,
|
||||
"after?", &after,
|
||||
"search_like?", &searchLike,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -303,6 +352,11 @@ func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
searchClause, searchArgs, err := cfg.starlarkSearchLikeToSQL(searchLike, len(whereArgs)+len(beforeArgs)+len(afterArgs)+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Merge all WHERE conditions
|
||||
var allParts []string
|
||||
var allArgs []any
|
||||
@@ -319,6 +373,10 @@ func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *st
|
||||
allArgs = append(allArgs, beforeArgs...)
|
||||
allParts = append(allParts, afterParts...)
|
||||
allArgs = append(allArgs, afterArgs...)
|
||||
if searchClause != "" {
|
||||
allParts = append(allParts, searchClause)
|
||||
allArgs = append(allArgs, searchArgs...)
|
||||
}
|
||||
|
||||
lim, ok := limit.Int64()
|
||||
if !ok || lim < 1 || lim > 1000 {
|
||||
|
||||
@@ -350,3 +350,83 @@ func TestDBListTables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── search_like ─────────────────────────────
|
||||
|
||||
func TestDBQuerySearchLike(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('a', 'hello world', 'u1')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('b', 'goodbye world', 'u2')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('c', 'nothing here', 'u3')`)
|
||||
|
||||
result, err := execScript(t, cfg, `rows = db.query("logs", search_like={"message": "%hello%"})`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
rowsStr := result.Globals["rows"].String()
|
||||
if !strings.Contains(rowsStr, "hello world") {
|
||||
t.Errorf("expected hello world row, got: %s", rowsStr)
|
||||
}
|
||||
if strings.Contains(rowsStr, "goodbye") || strings.Contains(rowsStr, "nothing") {
|
||||
t.Errorf("unexpected rows in search_like results: %s", rowsStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQuerySearchLikeMultiColumn(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('a', 'alpha', 'u1')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('b', 'beta', 'u-alpha')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('c', 'gamma', 'u3')`)
|
||||
|
||||
// Search across message OR user_id — should match both 'a' (message=alpha) and 'b' (user_id=u-alpha)
|
||||
result, err := execScript(t, cfg, `rows = db.query("logs", search_like={"message": "%alpha%", "user_id": "%alpha%"})`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
rowsStr := result.Globals["rows"].String()
|
||||
if !strings.Contains(rowsStr, "alpha") {
|
||||
t.Errorf("expected alpha match, got: %s", rowsStr)
|
||||
}
|
||||
if strings.Contains(rowsStr, "gamma") {
|
||||
t.Errorf("unexpected gamma row in multi-column search: %s", rowsStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQuerySearchLikeWithFilters(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('a', 'hello from u1', 'u1')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('b', 'hello from u2', 'u2')`)
|
||||
|
||||
// Combine search_like with filters — should only match u1's hello
|
||||
result, err := execScript(t, cfg, `rows = db.query("logs", filters={"user_id": "u1"}, search_like={"message": "%hello%"})`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
rowsStr := result.Globals["rows"].String()
|
||||
if !strings.Contains(rowsStr, "hello from u1") {
|
||||
t.Errorf("expected u1 hello row, got: %s", rowsStr)
|
||||
}
|
||||
if strings.Contains(rowsStr, "hello from u2") {
|
||||
t.Errorf("unexpected u2 row when filter + search_like combined: %s", rowsStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQuerySearchLikeEmptyDict(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
// Empty search_like dict should be a no-op
|
||||
_, err := execScript(t, cfg, `rows = db.query("logs", search_like={})`)
|
||||
if err != nil {
|
||||
t.Fatalf("empty search_like dict should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQuerySearchLikeInvalidColumn(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
_, err := execScript(t, cfg, `rows = db.query("logs", search_like={"bad name": "%x%"})`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid column name in search_like")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid column name") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user