Skip to content

Commit 9e9931d

Browse files
SteveLauCclaude
andauthored
feat: extract BuildFuzzinessQueryClauses as public API (#266)
* feat: extract BuildFuzzinessQueryClauses as public API * Extract the fuzziness query construction logic from buildFuzzinessQuery() into a reusable public function. This allows clients to build custom hybrid or semantic searches while reusing the framework's fuzziness query logic. * Refactor buildFuzzinessQuery() to use BuildFuzzinessQueryClauses() * Add SetFuzzinessBuilt() setter to allow clients to skip built-in fuzziness query construction when handling it themselves Co-Authored-By: Claude <noreply@anthropic.com> * refactor: error out if fuzziness is invalid * chore: release notes * test: test BuildFuzzinessQueryClauses() --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf4250e commit 9e9931d

3 files changed

Lines changed: 196 additions & 28 deletions

File tree

core/orm/query.go

Lines changed: 61 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ func (q *QueryBuilder) FuzzinessVal() int {
207207
return q.fuzziness
208208
}
209209

210+
// SetFuzzinessBuilt marks whether the fuzziness query has been built.
211+
// By default, the ORM handles fuzziness query construction automatically.
212+
// Use this method to skip the built-in fuzziness logic when you want to
213+
// handle it yourself.
214+
func (q *QueryBuilder) SetFuzzinessBuilt(built bool) {
215+
q.builtFuzziness = built
216+
}
217+
210218
func (q *QueryBuilder) IncludesVal() []string {
211219
return q.includes
212220
}
@@ -703,18 +711,23 @@ func getClausesByType(c *Clause, typ BoolType) []*Clause {
703711
return nil
704712
}
705713
}
706-
func (q *QueryBuilder) buildFuzzinessQuery() {
707-
if q.builtFuzziness {
708-
return
709-
}
710-
q.builtFuzziness = true
711714

712-
queryStr := q.QueryVal()
713-
if queryStr == "" {
714-
return
715+
// BuildFuzzinessQueryClauses builds query clauses based on fuzziness level.
716+
//
717+
// Parameters:
718+
// - queryStr: the search query string (e.g., "mysql" or "title^2:mysql")
719+
// - fuzziness: fuzziness level (0-5)
720+
// - defaultFields: default fields to search when no explicit field is specified
721+
// in the queryStr
722+
//
723+
// Returns a slice of Clause objects and an error if fuzziness is invalid:
724+
// - Single-element slice: use directly (e.g., q.Must(clauses[0]))
725+
// - Multiple-element slice: wrap in ShouldQuery (e.g., q.Must(ShouldQuery(clauses...)))
726+
func BuildFuzzinessQueryClauses(queryStr string, fuzziness int, defaultFields []string) ([]*Clause, error) {
727+
if fuzziness < 0 || fuzziness > 5 {
728+
return nil, fmt.Errorf("invalid fuzziness value: %d, must be between 0 and 5", fuzziness)
715729
}
716730

717-
fuzzinessVal := q.FuzzinessVal()
718731
field, value := parseQuery(queryStr)
719732

720733
// Case 1: Explicit field is provided (possibly with ^boost)
@@ -732,43 +745,42 @@ func (q *QueryBuilder) buildFuzzinessQuery() {
732745
// Helper to apply overall boost
733746
boost := func(base float32) float32 { return base * fieldBoost }
734747

735-
switch fuzzinessVal {
748+
switch fuzziness {
736749
case 0, 1:
737-
q.Must(MatchQuery(field, value).SetBoost(boost(1)))
750+
return []*Clause{MatchQuery(field, value).SetBoost(boost(1))}, nil
738751
case 2:
739-
q.Must(ShouldQuery(
752+
return []*Clause{
740753
MatchQuery(field, value).SetBoost(boost(5)),
741754
PrefixQuery(field, value).SetBoost(boost(2)),
742-
))
755+
}, nil
743756
case 3:
744-
q.Must(ShouldQuery(
757+
return []*Clause{
745758
MatchQuery(field, value).SetBoost(boost(5)),
746759
PrefixQuery(field, value).SetBoost(boost(3)),
747760
MatchPhraseQuery(field, value, 0).SetBoost(boost(2)),
748-
))
761+
}, nil
749762
case 4:
750-
q.Must(ShouldQuery(
763+
return []*Clause{
751764
MatchQuery(field, value).SetBoost(boost(5)),
752765
PrefixQuery(field, value).SetBoost(boost(3)),
753766
MatchPhraseQuery(field, value, 1).SetBoost(boost(2)),
754767
FuzzyQuery(field, value, 1).SetBoost(boost(1)),
755-
))
768+
}, nil
756769
case 5:
757-
q.Must(ShouldQuery(
770+
return []*Clause{
758771
MatchQuery(field, value).SetBoost(boost(5)),
759772
PrefixQuery(field, value).SetBoost(boost(3)),
760773
MatchPhraseQuery(field, value, 2).SetBoost(boost(2)),
761774
FuzzyQuery(field, value, 2).SetBoost(boost(1)),
762-
))
775+
}, nil
763776
}
764-
return
777+
return nil, fmt.Errorf("invalid fuzziness value: %d", fuzziness)
765778
}
766779

767780
// Case 2: No specific field, use default query fields
768781
if value == "" {
769782
value = queryStr
770783
}
771-
defaultFields := q.DefaultQueryFieldsVal()
772784
shouldClauses := make([]*Clause, 0, len(defaultFields)*4)
773785

774786
for _, rawField := range defaultFields {
@@ -785,7 +797,7 @@ func (q *QueryBuilder) buildFuzzinessQuery() {
785797

786798
boost := func(base float32) float32 { return base * fieldBoost }
787799

788-
switch fuzzinessVal {
800+
switch fuzziness {
789801
case 0, 1:
790802
shouldClauses = append(shouldClauses,
791803
MatchQuery(field, value).SetBoost(boost(1)),
@@ -818,12 +830,33 @@ func (q *QueryBuilder) buildFuzzinessQuery() {
818830
}
819831
}
820832

821-
if len(shouldClauses) > 0 {
822-
if len(shouldClauses) == 1 {
823-
q.Must(shouldClauses[0])
824-
} else {
825-
q.Must(ShouldQuery(shouldClauses...))
826-
}
833+
return shouldClauses, nil
834+
}
835+
836+
func (q *QueryBuilder) buildFuzzinessQuery() {
837+
if q.builtFuzziness {
838+
return
839+
}
840+
q.builtFuzziness = true
841+
842+
queryStr := q.QueryVal()
843+
if queryStr == "" {
844+
return
845+
}
846+
847+
fuzzinessVal := q.FuzzinessVal()
848+
defaultFields := q.DefaultQueryFieldsVal()
849+
850+
clauses, err := BuildFuzzinessQueryClauses(queryStr, fuzzinessVal, defaultFields)
851+
if err != nil {
852+
// Should be unreachable as fuzzinessVal is guaranteed to be valid (0-5)
853+
panic(fmt.Sprintf("buildFuzzinessQuery failed: %v", err))
854+
}
855+
856+
if len(clauses) == 1 {
857+
q.Must(clauses[0])
858+
} else if len(clauses) > 1 {
859+
q.Must(ShouldQuery(clauses...))
827860
}
828861
}
829862

core/orm/query_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ func TestBoolQueryWrapping(t *testing.T) {
7171
if len(boolClause.MustClauses) != 2 {
7272
t.Errorf("Invalid BoolQuery: %+v", boolClause)
7373
}
74+
// Assert clause types
75+
assert.Equal(t, QueryTerm, boolClause.MustClauses[0].Operator)
76+
assert.Equal(t, QueryTerm, boolClause.MustClauses[1].Operator)
7477
}
7578

7679
func TestMustQuery(t *testing.T) {
@@ -304,3 +307,134 @@ func TestSimplify_NoOpEmptyClause(t *testing.T) {
304307
t.Errorf("Expected clause with field 'name', got %s", simplified.Field)
305308
}
306309
}
310+
311+
func TestBuildFuzzinessQueryClauses(t *testing.T) {
312+
tests := []struct {
313+
name string
314+
queryStr string
315+
fuzziness int
316+
defaultFields []string
317+
wantClauseLen int
318+
wantClauseTypes []QueryType
319+
}{
320+
{
321+
name: "fuzziness 0 - explicit field",
322+
queryStr: "title:mysql",
323+
fuzziness: 0,
324+
defaultFields: []string{"title", "summary"},
325+
wantClauseLen: 1,
326+
wantClauseTypes: []QueryType{QueryMatch},
327+
},
328+
{
329+
name: "fuzziness 1 - explicit field",
330+
queryStr: "title:mysql",
331+
fuzziness: 1,
332+
defaultFields: []string{"title", "summary"},
333+
wantClauseLen: 1,
334+
wantClauseTypes: []QueryType{QueryMatch},
335+
},
336+
{
337+
name: "fuzziness 2 - explicit field",
338+
queryStr: "title:mysql",
339+
fuzziness: 2,
340+
defaultFields: []string{"title", "summary"},
341+
wantClauseLen: 2,
342+
wantClauseTypes: []QueryType{QueryMatch, QueryPrefix},
343+
},
344+
{
345+
name: "fuzziness 3 - explicit field",
346+
queryStr: "title:mysql",
347+
fuzziness: 3,
348+
defaultFields: []string{"title", "summary"},
349+
wantClauseLen: 3,
350+
wantClauseTypes: []QueryType{QueryMatch, QueryPrefix, QueryMatchPhrase},
351+
},
352+
{
353+
name: "fuzziness 4 - explicit field",
354+
queryStr: "title:mysql",
355+
fuzziness: 4,
356+
defaultFields: []string{"title", "summary"},
357+
wantClauseLen: 4,
358+
wantClauseTypes: []QueryType{QueryMatch, QueryPrefix, QueryMatchPhrase, QueryFuzzy},
359+
},
360+
{
361+
name: "fuzziness 5 - explicit field",
362+
queryStr: "title:mysql",
363+
fuzziness: 5,
364+
defaultFields: []string{"title", "summary"},
365+
wantClauseLen: 4,
366+
wantClauseTypes: []QueryType{QueryMatch, QueryPrefix, QueryMatchPhrase, QueryFuzzy},
367+
},
368+
{
369+
name: "fuzziness 3 - default fields",
370+
queryStr: "mysql",
371+
fuzziness: 3,
372+
defaultFields: []string{"title", "summary"},
373+
wantClauseLen: 6,
374+
wantClauseTypes: []QueryType{
375+
QueryMatch, QueryPrefix, QueryMatchPhrase, // title
376+
QueryMatch, QueryPrefix, QueryMatchPhrase, // summary
377+
},
378+
},
379+
{
380+
name: "fuzziness 3 - with field boost",
381+
queryStr: "title^2:mysql",
382+
fuzziness: 3,
383+
defaultFields: []string{"title"},
384+
wantClauseLen: 3,
385+
wantClauseTypes: []QueryType{QueryMatch, QueryPrefix, QueryMatchPhrase},
386+
},
387+
}
388+
389+
for _, tt := range tests {
390+
t.Run(tt.name, func(t *testing.T) {
391+
clauses, err := BuildFuzzinessQueryClauses(tt.queryStr, tt.fuzziness, tt.defaultFields)
392+
assert.NoError(t, err)
393+
assert.Equal(t, tt.wantClauseLen, len(clauses))
394+
// Assert clause types
395+
for i, clause := range clauses {
396+
assert.Equal(t, tt.wantClauseTypes[i], clause.Operator, "clause %d type mismatch", i)
397+
}
398+
})
399+
}
400+
}
401+
402+
func TestBuildFuzzinessQueryClauses_InvalidFuzziness(t *testing.T) {
403+
tests := []struct {
404+
name string
405+
fuzziness int
406+
}{
407+
{name: "fuzziness -1", fuzziness: -1},
408+
{name: "fuzziness 6", fuzziness: 6},
409+
{name: "fuzziness 100", fuzziness: 100},
410+
}
411+
412+
for _, tt := range tests {
413+
t.Run(tt.name, func(t *testing.T) {
414+
clauses, err := BuildFuzzinessQueryClauses("mysql", tt.fuzziness, []string{"title"})
415+
assert.Error(t, err)
416+
assert.Nil(t, clauses)
417+
assert.Contains(t, err.Error(), "invalid fuzziness value")
418+
})
419+
}
420+
}
421+
422+
func TestBuildFuzzinessQueryClauses_EmptyQuery(t *testing.T) {
423+
// Empty query string with default fields still generates clauses
424+
clauses, err := BuildFuzzinessQueryClauses("", 3, []string{"title"})
425+
assert.NoError(t, err)
426+
// fuzziness 3 generates 3 clauses per field (Match, Prefix, MatchPhrase)
427+
assert.Equal(t, 3, len(clauses))
428+
// Assert clause types
429+
expectedTypes := []QueryType{QueryMatch, QueryPrefix, QueryMatchPhrase}
430+
for i, clause := range clauses {
431+
assert.Equal(t, expectedTypes[i], clause.Operator, "clause %d type mismatch", i)
432+
}
433+
}
434+
435+
func TestBuildFuzzinessQueryClauses_EmptyQueryNoDefaultFields(t *testing.T) {
436+
// Empty query string with no default fields returns empty clauses
437+
clauses, err := BuildFuzzinessQueryClauses("", 3, []string{})
438+
assert.NoError(t, err)
439+
assert.Equal(t, 0, len(clauses))
440+
}

docs/content.en/docs/release-notes/_index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Information about release notes of INFINI Framework is provided here.
1313
### 🚀 Features
1414
- feat: support team-based scope for sharing services #258
1515
- feat: add semantic, hybrid, and nested query support #265
16+
- feat: extract BuildFuzzinessQueryClauses as public API #266
1617

1718
### 🐛 Bug fix
1819
### ✈️ Improvements

0 commit comments

Comments
 (0)