From 5e47f25bf57ab948c3b6487ef49adea16d8b4ff2 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 30 Oct 2025 00:21:53 +0800 Subject: [PATCH] feat(store): add hierarchical tag filtering support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag filters now support hierarchical matching where searching for a tag (e.g., "book") will match both the exact tag and any tags with that prefix (e.g., "book/fiction", "book/non-fiction"). This applies across all database backends (SQLite, MySQL, PostgreSQL) with corresponding test updates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- plugin/filter/render.go | 15 ++++++++++++--- store/db/mysql/memo_filter_test.go | 12 ++++++------ store/db/postgres/memo_filter_test.go | 12 ++++++------ store/db/sqlite/memo_filter_test.go | 12 ++++++------ 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/plugin/filter/render.go b/plugin/filter/render.go index a3543cbe6..01fee5e1b 100644 --- a/plugin/filter/render.go +++ b/plugin/filter/render.go @@ -338,13 +338,22 @@ func (r *renderer) renderTagInList(values []ValueExpr) (renderResult, error) { switch r.dialect { case DialectSQLite: - expr := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s"%%`, str))) + // Support hierarchical tags: match exact tag OR tags with this prefix (e.g., "book" matches "book" and "book/something") + exactMatch := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s"%%`, str))) + prefixMatch := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s/%%`, str))) + expr := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch) conditions = append(conditions, expr) case DialectMySQL: - expr := fmt.Sprintf("JSON_CONTAINS(%s, %s)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str))) + // Support hierarchical tags: match exact tag OR tags with this prefix + exactMatch := fmt.Sprintf("JSON_CONTAINS(%s, %s)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str))) + prefixMatch := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s/%%`, str))) + expr := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch) conditions = append(conditions, expr) case DialectPostgres: - expr := fmt.Sprintf("%s @> jsonb_build_array(%s::json)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str))) + // Support hierarchical tags: match exact tag OR tags with this prefix + exactMatch := fmt.Sprintf("%s @> jsonb_build_array(%s::json)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str))) + prefixMatch := fmt.Sprintf("%s::text LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s/%%`, str))) + expr := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch) conditions = append(conditions, expr) default: return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect) diff --git a/store/db/mysql/memo_filter_test.go b/store/db/mysql/memo_filter_test.go index 58115637b..020f2a28c 100644 --- a/store/db/mysql/memo_filter_test.go +++ b/store/db/mysql/memo_filter_test.go @@ -18,13 +18,13 @@ func TestConvertExprToSQL(t *testing.T) { }{ { filter: `tag in ["tag1", "tag2"]`, - want: "(JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?))", - args: []any{`"tag1"`, `"tag2"`}, + want: "((JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?) OR (JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?))", + args: []any{`"tag1"`, `%"tag1/%`, `"tag2"`, `%"tag2/%`}, }, { filter: `!(tag in ["tag1", "tag2"])`, - want: "NOT ((JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?)))", - args: []any{`"tag1"`, `"tag2"`}, + want: "NOT (((JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?) OR (JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?)))", + args: []any{`"tag1"`, `%"tag1/%`, `"tag2"`, `%"tag2/%`}, }, { filter: `content.contains("memos")`, @@ -43,8 +43,8 @@ func TestConvertExprToSQL(t *testing.T) { }, { filter: `tag in ['tag1'] || content.contains('hello')`, - want: "(JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR `memo`.`content` LIKE ?)", - args: []any{`"tag1"`, "%hello%"}, + want: "((JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?) OR `memo`.`content` LIKE ?)", + args: []any{`"tag1"`, `%"tag1/%`, "%hello%"}, }, { filter: `1`, diff --git a/store/db/postgres/memo_filter_test.go b/store/db/postgres/memo_filter_test.go index fb3b343b6..4f9495299 100644 --- a/store/db/postgres/memo_filter_test.go +++ b/store/db/postgres/memo_filter_test.go @@ -18,13 +18,13 @@ func TestConvertExprToSQL(t *testing.T) { }{ { filter: `tag in ["tag1", "tag2"]`, - want: "(memo.payload->'tags' @> jsonb_build_array($1::json) OR memo.payload->'tags' @> jsonb_build_array($2::json))", - args: []any{`"tag1"`, `"tag2"`}, + want: "((memo.payload->'tags' @> jsonb_build_array($1::json) OR memo.payload->'tags'::text LIKE $2) OR (memo.payload->'tags' @> jsonb_build_array($3::json) OR memo.payload->'tags'::text LIKE $4))", + args: []any{`"tag1"`, `%"tag1/%`, `"tag2"`, `%"tag2/%`}, }, { filter: `!(tag in ["tag1", "tag2"])`, - want: "NOT ((memo.payload->'tags' @> jsonb_build_array($1::json) OR memo.payload->'tags' @> jsonb_build_array($2::json)))", - args: []any{`"tag1"`, `"tag2"`}, + want: "NOT (((memo.payload->'tags' @> jsonb_build_array($1::json) OR memo.payload->'tags'::text LIKE $2) OR (memo.payload->'tags' @> jsonb_build_array($3::json) OR memo.payload->'tags'::text LIKE $4)))", + args: []any{`"tag1"`, `%"tag1/%`, `"tag2"`, `%"tag2/%`}, }, { filter: `content.contains("memos")`, @@ -43,8 +43,8 @@ func TestConvertExprToSQL(t *testing.T) { }, { filter: `tag in ['tag1'] || content.contains('hello')`, - want: "(memo.payload->'tags' @> jsonb_build_array($1::json) OR memo.content ILIKE $2)", - args: []any{`"tag1"`, "%hello%"}, + want: "((memo.payload->'tags' @> jsonb_build_array($1::json) OR memo.payload->'tags'::text LIKE $2) OR memo.content ILIKE $3)", + args: []any{`"tag1"`, `%"tag1/%`, "%hello%"}, }, { filter: `1`, diff --git a/store/db/sqlite/memo_filter_test.go b/store/db/sqlite/memo_filter_test.go index cea5ab558..70581b938 100644 --- a/store/db/sqlite/memo_filter_test.go +++ b/store/db/sqlite/memo_filter_test.go @@ -18,13 +18,13 @@ func TestConvertExprToSQL(t *testing.T) { }{ { filter: `tag in ["tag1", "tag2"]`, - want: "(JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?)", - args: []any{`%"tag1"%`, `%"tag2"%`}, + want: "((JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?) OR (JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?))", + args: []any{`%"tag1"%`, `%"tag1/%`, `%"tag2"%`, `%"tag2/%`}, }, { filter: `!(tag in ["tag1", "tag2"])`, - want: "NOT ((JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?))", - args: []any{`%"tag1"%`, `%"tag2"%`}, + want: "NOT (((JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?) OR (JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?)))", + args: []any{`%"tag1"%`, `%"tag1/%`, `%"tag2"%`, `%"tag2/%`}, }, { filter: `content.contains("memos")`, @@ -43,8 +43,8 @@ func TestConvertExprToSQL(t *testing.T) { }, { filter: `tag in ['tag1'] || content.contains('hello')`, - want: "(JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR `memo`.`content` LIKE ?)", - args: []any{`%"tag1"%`, "%hello%"}, + want: "((JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?) OR `memo`.`content` LIKE ?)", + args: []any{`%"tag1"%`, `%"tag1/%`, "%hello%"}, }, { filter: `1`,