mirror of https://github.com/usememos/memos.git
feat(store): add hierarchical tag filtering support
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 <noreply@anthropic.com>
This commit is contained in:
parent
a2bfba6928
commit
5e47f25bf5
|
|
@ -338,13 +338,22 @@ func (r *renderer) renderTagInList(values []ValueExpr) (renderResult, error) {
|
||||||
|
|
||||||
switch r.dialect {
|
switch r.dialect {
|
||||||
case DialectSQLite:
|
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)
|
conditions = append(conditions, expr)
|
||||||
case DialectMySQL:
|
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)
|
conditions = append(conditions, expr)
|
||||||
case DialectPostgres:
|
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)
|
conditions = append(conditions, expr)
|
||||||
default:
|
default:
|
||||||
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
|
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ func TestConvertExprToSQL(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
filter: `tag in ["tag1", "tag2"]`,
|
filter: `tag in ["tag1", "tag2"]`,
|
||||||
want: "(JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?))",
|
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"`, `"tag2"`},
|
args: []any{`"tag1"`, `%"tag1/%`, `"tag2"`, `%"tag2/%`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `!(tag in ["tag1", "tag2"])`,
|
filter: `!(tag in ["tag1", "tag2"])`,
|
||||||
want: "NOT ((JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?)))",
|
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"`, `"tag2"`},
|
args: []any{`"tag1"`, `%"tag1/%`, `"tag2"`, `%"tag2/%`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `content.contains("memos")`,
|
filter: `content.contains("memos")`,
|
||||||
|
|
@ -43,8 +43,8 @@ func TestConvertExprToSQL(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `tag in ['tag1'] || content.contains('hello')`,
|
filter: `tag in ['tag1'] || content.contains('hello')`,
|
||||||
want: "(JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR `memo`.`content` LIKE ?)",
|
want: "((JSON_CONTAINS(JSON_EXTRACT(`memo`.`payload`, '$.tags'), ?) OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?) OR `memo`.`content` LIKE ?)",
|
||||||
args: []any{`"tag1"`, "%hello%"},
|
args: []any{`"tag1"`, `%"tag1/%`, "%hello%"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `1`,
|
filter: `1`,
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ func TestConvertExprToSQL(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
filter: `tag in ["tag1", "tag2"]`,
|
filter: `tag in ["tag1", "tag2"]`,
|
||||||
want: "(memo.payload->'tags' @> jsonb_build_array($1::json) OR memo.payload->'tags' @> jsonb_build_array($2::json))",
|
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"`, `"tag2"`},
|
args: []any{`"tag1"`, `%"tag1/%`, `"tag2"`, `%"tag2/%`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `!(tag in ["tag1", "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)))",
|
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"`, `"tag2"`},
|
args: []any{`"tag1"`, `%"tag1/%`, `"tag2"`, `%"tag2/%`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `content.contains("memos")`,
|
filter: `content.contains("memos")`,
|
||||||
|
|
@ -43,8 +43,8 @@ func TestConvertExprToSQL(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `tag in ['tag1'] || content.contains('hello')`,
|
filter: `tag in ['tag1'] || content.contains('hello')`,
|
||||||
want: "(memo.payload->'tags' @> jsonb_build_array($1::json) OR memo.content ILIKE $2)",
|
want: "((memo.payload->'tags' @> jsonb_build_array($1::json) OR memo.payload->'tags'::text LIKE $2) OR memo.content ILIKE $3)",
|
||||||
args: []any{`"tag1"`, "%hello%"},
|
args: []any{`"tag1"`, `%"tag1/%`, "%hello%"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `1`,
|
filter: `1`,
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ func TestConvertExprToSQL(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
filter: `tag in ["tag1", "tag2"]`,
|
filter: `tag in ["tag1", "tag2"]`,
|
||||||
want: "(JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?)",
|
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"%`, `%"tag2"%`},
|
args: []any{`%"tag1"%`, `%"tag1/%`, `%"tag2"%`, `%"tag2/%`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `!(tag in ["tag1", "tag2"])`,
|
filter: `!(tag in ["tag1", "tag2"])`,
|
||||||
want: "NOT ((JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?))",
|
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"%`, `%"tag2"%`},
|
args: []any{`%"tag1"%`, `%"tag1/%`, `%"tag2"%`, `%"tag2/%`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `content.contains("memos")`,
|
filter: `content.contains("memos")`,
|
||||||
|
|
@ -43,8 +43,8 @@ func TestConvertExprToSQL(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `tag in ['tag1'] || content.contains('hello')`,
|
filter: `tag in ['tag1'] || content.contains('hello')`,
|
||||||
want: "(JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR `memo`.`content` LIKE ?)",
|
want: "((JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ? OR JSON_EXTRACT(`memo`.`payload`, '$.tags') LIKE ?) OR `memo`.`content` LIKE ?)",
|
||||||
args: []any{`%"tag1"%`, "%hello%"},
|
args: []any{`%"tag1"%`, `%"tag1/%`, "%hello%"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: `1`,
|
filter: `1`,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue