filer: escape LIKE wildcards in SQL list prefixes (#11757)

* filer: escape LIKE wildcards in SQL list prefixes

ListDirectoryPrefixedEntries bound prefix+"%" straight into a LIKE
clause, so '_' and '%' in object names (e.g. Langfuse keys like
call_03_...) acted as wildcards: wrong results and a ~40x slower
range scan because (dirhash, name) can no longer be narrowed.

Escape '!'/'%'/'_' in the prefix and declare ESCAPE '!' in the
generated list SQL so every abstract_sql backend (mysql, mysql2,
postgres, postgres2, sqlite) matches literally with identical
semantics. '!' is used instead of '\' because SQLite shares
SqlGenMysql but would reject the MySQL-style '\\' literal, and
'\' would still need escaping itself.

* filer: escape LIKE wildcards in YDB list prefixes

Same wildcard-in-prefix bug as the abstract_sql backends: YQL LIKE
treated '_' and '%' in the prefix as wildcards. Apply the shared
LikePattern helper and declare ESCAPE '!' (YQL allows any escape
char except '%', '_' and '\').

* test: cover literal '_'/'%'/'!' prefixes in sqlite filer listing

End-to-end regression test through SqliteStore against a real
database; runs in CI under the sqlite build tag.
This commit is contained in:
Chris Lu authored and GitHub committed 2026-10-15 16:51:15 -07:00
1 parent 103328e70f
commit a5ccefb661
9 files changed
+127 -8

No files matched your search

@@ -0,0 +1,75 @@
//go:build sqlite
package sqlite
import (
"context"
"database/sql"
"testing"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/filer/mysql"
"github.com/seaweedfs/seaweedfs/weed/util"
_ "modernc.org/sqlite"
)
func TestListDirectoryPrefixedEntriesEscapesLikeWildcards(t *testing.T) {
db, err := sql.Open("sqlite", "file::memory:?cache=shared")
if err != nil {
t.Fatal(err)
}
defer db.Close()
_, err = db.Exec(`CREATE TABLE filemeta (dirhash BIGINT, name VARCHAR(1000), directory TEXT, meta BLOB, PRIMARY KEY (dirhash, name))`)
if err != nil {
t.Fatal(err)
}
store := &SqliteStore{}
store.SqlGenerator = &mysql.SqlGenMysql{}
store.DB = db
ctx := context.Background()
names := []string{
"call_03_KNlHPt6grkZMe4WZVwQi9710", // target: literal _ prefix
"callX03XKNlZZZ", // would match if _ were a wildcard
"call_99_other",
"100%_done", // % and _ in the name
"100aXdone", // would match prefix "100%_" if unescaped
"bang!file",
}
for _, n := range names {
entry := &filer.Entry{FullPath: util.FullPath("/dir/" + n)}
if err := store.InsertEntry(ctx, entry); err != nil {
t.Fatalf("insert %s: %v", n, err)
}
}
list := func(prefix string) []string {
var got []string
_, err := store.ListDirectoryPrefixedEntries(ctx, "/dir", "", false, 100, prefix, func(e *filer.Entry) (bool, error) {
got = append(got, e.Name())
return true, nil
})
if err != nil {
t.Fatalf("list %q: %v", prefix, err)
}
return got
}
if got := list("call_03_"); len(got) != 1 || got[0] != "call_03_KNlHPt6grkZMe4WZVwQi9710" {
t.Fatalf("prefix with _: got %v", got)
}
if got := list("call_"); len(got) != 2 {
t.Fatalf("prefix call_: got %v", got)
}
if got := list("100%_"); len(got) != 1 || got[0] != "100%_done" {
t.Fatalf("prefix with %%: got %v", got)
}
if got := list("bang!"); len(got) != 1 || got[0] != "bang!file" {
t.Fatalf("prefix with !: got %v", got)
}
if got := list(""); len(got) != len(names) {
t.Fatalf("empty prefix: got %v", got)
}
}