Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/core/inference_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ func (b *ModelRegistryService) GetInferenceServices(listOptions api.ListOptions,
OrderBy: listOptions.OrderBy,
SortOrder: listOptions.SortOrder,
NextPageToken: listOptions.NextPageToken,
FilterQuery: listOptions.FilterQuery,
},
Runtime: runtime,
ParentResourceID: parentResourceID,
Expand Down
186 changes: 186 additions & 0 deletions internal/core/inference_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1092,3 +1092,189 @@ func TestInferenceServiceRoundTrip(t *testing.T) {
assert.Equal(t, "new_value", finalProps["new_prop"].MetadataStringValue.StringValue)
})
}

// TestGetInferenceServicesWithFilterQuery verifies that the filterQuery parameter
// is correctly propagated and applied when listing InferenceServices.
//
// Regression test for: filterQuery silently ignored on GET /inference_services
// Root cause was missing FilterQuery field propagation in GetInferenceServices
// and missing GetRestEntityType() on InferenceServiceListOptions.
func TestGetInferenceServicesWithFilterQuery(t *testing.T) {
_service, cleanup := SetupModelRegistryService(t)
defer cleanup()

// Create shared prerequisites
rm, err := _service.UpsertRegisteredModel(&openapi.RegisteredModel{Name: "filter-test-rm"})
require.NoError(t, err)

env, err := _service.UpsertServingEnvironment(&openapi.ServingEnvironment{Name: "filter-test-env"})
require.NoError(t, err)

// Create inference services with distinct properties for filtering
type svcDef struct {
name string
runtime string
extID string
}
svcDefs := []svcDef{
{"fraud-detector", "tensorflow", "ext-fraud-001"},
{"image-classifier", "pytorch", "ext-image-002"},
{"nlp-pipeline", "tensorflow", "ext-nlp-003"},
{"recommendation-engine", "sklearn", "ext-rec-004"},
}
for _, svc := range svcDefs {
name := svc.name
rt := svc.runtime
eid := svc.extID
_, err := _service.UpsertInferenceService(&openapi.InferenceService{
Name: &name,
ExternalId: &eid,
Runtime: &rt,
ServingEnvironmentId: *env.Id,
RegisteredModelId: *rm.Id,
})
require.NoError(t, err)
}

testCases := []struct {
name string
filterQuery string
expectedCount int
expectedNames []string
}{
{
name: "Filter by exact name",
filterQuery: "name = 'fraud-detector'",
expectedCount: 1,
expectedNames: []string{"fraud-detector"},
},
{
name: "Filter by name pattern",
filterQuery: "name LIKE '%-detector'",
expectedCount: 1,
expectedNames: []string{"fraud-detector"},
},
{
name: "Filter by externalId",
filterQuery: "externalId = 'ext-image-002'",
expectedCount: 1,
expectedNames: []string{"image-classifier"},
},
{
name: "Filter by runtime - tensorflow",
filterQuery: "runtime = 'tensorflow'",
expectedCount: 2,
expectedNames: []string{"fraud-detector", "nlp-pipeline"},
},
{
name: "Filter by runtime - pytorch",
filterQuery: "runtime = 'pytorch'",
expectedCount: 1,
expectedNames: []string{"image-classifier"},
},
{
name: "Complex filter with AND",
filterQuery: "runtime = 'tensorflow' AND name = 'fraud-detector'",
expectedCount: 1,
expectedNames: []string{"fraud-detector"},
},
{
name: "Complex filter with OR",
filterQuery: "runtime = 'pytorch' OR runtime = 'sklearn'",
expectedCount: 2,
expectedNames: []string{"image-classifier", "recommendation-engine"},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pageSize := int32(20)
fq := tc.filterQuery
result, err := _service.GetInferenceServices(api.ListOptions{
PageSize: &pageSize,
FilterQuery: &fq,
}, nil, nil)

require.NoError(t, err)
require.NotNil(t, result)

var matchedNames []string
for _, item := range result.Items {
if slices.Contains(tc.expectedNames, *item.Name) {
matchedNames = append(matchedNames, *item.Name)
}
}

assert.Equal(t, tc.expectedCount, len(matchedNames),
"filterQuery %q: expected %d items, got %d (filter may be silently ignored)",
tc.filterQuery, tc.expectedCount, len(matchedNames))
assert.ElementsMatch(t, tc.expectedNames, matchedNames,
"filterQuery %q: unexpected items returned", tc.filterQuery)
})
}

t.Run("Invalid filter syntax returns error", func(t *testing.T) {
invalidFilter := "invalid <<<syntax"
result, err := _service.GetInferenceServices(api.ListOptions{
FilterQuery: &invalidFilter,
}, nil, nil)

assert.Error(t, err, "invalid filter syntax should return an error, not silently ignore the filter")
assert.Nil(t, result)
})

t.Run("Filter with no matches returns empty list", func(t *testing.T) {
fq := "runtime = 'nonexistent-runtime'"
result, err := _service.GetInferenceServices(api.ListOptions{
FilterQuery: &fq,
}, nil, nil)

require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, 0, len(result.Items), "filter with no matches should return empty items")
assert.Equal(t, int32(0), result.Size)
})

t.Run("Filter combined with pagination", func(t *testing.T) {
fq := "runtime = 'tensorflow'"
pageSize := int32(1)

firstPage, err := _service.GetInferenceServices(api.ListOptions{
PageSize: &pageSize,
FilterQuery: &fq,
}, nil, nil)
require.NoError(t, err)
assert.Equal(t, 1, len(firstPage.Items))
assert.NotEmpty(t, firstPage.NextPageToken,
"should have a next page token for 2 tensorflow services with pageSize=1")

secondPage, err := _service.GetInferenceServices(api.ListOptions{
PageSize: &pageSize,
FilterQuery: &fq,
NextPageToken: &firstPage.NextPageToken,
}, nil, nil)
require.NoError(t, err)
assert.Equal(t, 1, len(secondPage.Items))
assert.NotEqual(t, firstPage.Items[0].Id, secondPage.Items[0].Id,
"each page should return a different item")
})

t.Run("Filter scoped to serving environment", func(t *testing.T) {
fq := "runtime = 'tensorflow'"
pageSize := int32(20)

result, err := _service.GetInferenceServices(api.ListOptions{
PageSize: &pageSize,
FilterQuery: &fq,
}, env.Id, nil)

require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, 2, len(result.Items),
"environment-scoped filter should return 2 tensorflow services")
for _, item := range result.Items {
assert.Equal(t, "tensorflow", *item.Runtime,
"all returned items should match the runtime filter")
}
})
}
1 change: 1 addition & 0 deletions internal/core/serve_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ func (b *ModelRegistryService) GetServeModels(listOptions api.ListOptions, infer
OrderBy: listOptions.OrderBy,
SortOrder: listOptions.SortOrder,
NextPageToken: listOptions.NextPageToken,
FilterQuery: listOptions.FilterQuery,
},
InferenceServiceID: inferenceServiceID,
})
Expand Down
129 changes: 129 additions & 0 deletions internal/core/serve_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1288,3 +1288,132 @@ func TestUpsertServeModel_HFModelValidation(t *testing.T) {
assert.Equal(t, "non-hf-serve-model", *result.Name)
})
}

// TestGetServeModelsWithFilterQuery verifies that the filterQuery parameter
// is correctly propagated and applied when listing ServeModels.
//
// Regression test for: filterQuery silently ignored on GET /serve_models
// Root cause was missing FilterQuery field propagation in GetServeModels.
func TestGetServeModelsWithFilterQuery(t *testing.T) {
_service, cleanup := SetupModelRegistryService(t)
defer cleanup()

// Create prerequisites
rm, err := _service.UpsertRegisteredModel(&openapi.RegisteredModel{Name: "sm-filter-rm"})
require.NoError(t, err)

env, err := _service.UpsertServingEnvironment(&openapi.ServingEnvironment{Name: "sm-filter-env"})
require.NoError(t, err)

mv, err := _service.UpsertModelVersion(&openapi.ModelVersion{
Name: "sm-filter-mv",
RegisteredModelId: *rm.Id,
}, rm.Id)
require.NoError(t, err)

infSvc, err := _service.UpsertInferenceService(&openapi.InferenceService{
Name: new("sm-filter-isvc"),
ServingEnvironmentId: *env.Id,
RegisteredModelId: *rm.Id,
})
require.NoError(t, err)

// Create serve models with distinct names
smDefs := []struct {
name string
extID string
state openapi.ExecutionState
}{
{"serve-model-alpha", "ext-alpha-001", openapi.EXECUTIONSTATE_RUNNING},
{"serve-model-beta", "ext-beta-002", openapi.EXECUTIONSTATE_COMPLETE},
{"serve-model-gamma", "ext-gamma-003", openapi.EXECUTIONSTATE_RUNNING},
}

for _, sm := range smDefs {
name := sm.name
eid := sm.extID
state := sm.state
_, err := _service.UpsertServeModel(&openapi.ServeModel{
Name: &name,
ExternalId: &eid,
ModelVersionId: *mv.Id,
LastKnownState: &state,
}, infSvc.Id)
require.NoError(t, err)
}

testCases := []struct {
name string
filterQuery string
expectedCount int
expectedNames []string
}{
{
name: "Filter by exact name",
filterQuery: "name = 'serve-model-alpha'",
expectedCount: 1,
expectedNames: []string{"serve-model-alpha"},
},
{
name: "Filter by name pattern",
filterQuery: "name LIKE 'serve-model-%'",
expectedCount: 3,
expectedNames: []string{"serve-model-alpha", "serve-model-beta", "serve-model-gamma"},
},
{
name: "Filter by externalId",
filterQuery: "externalId = 'ext-beta-002'",
expectedCount: 1,
expectedNames: []string{"serve-model-beta"},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pageSize := int32(20)
fq := tc.filterQuery
result, err := _service.GetServeModels(api.ListOptions{
PageSize: &pageSize,
FilterQuery: &fq,
}, infSvc.Id)

require.NoError(t, err)
require.NotNil(t, result)

var matchedNames []string
for _, item := range result.Items {
if slices.Contains(tc.expectedNames, *item.Name) {
matchedNames = append(matchedNames, *item.Name)
}
}

assert.Equal(t, tc.expectedCount, len(matchedNames),
"filterQuery %q: expected %d items, got %d (filter may be silently ignored)",
tc.filterQuery, tc.expectedCount, len(matchedNames))
assert.ElementsMatch(t, tc.expectedNames, matchedNames,
"filterQuery %q: unexpected items returned", tc.filterQuery)
})
}

t.Run("Invalid filter syntax returns error", func(t *testing.T) {
invalidFilter := "invalid <<<syntax"
result, err := _service.GetServeModels(api.ListOptions{
FilterQuery: &invalidFilter,
}, infSvc.Id)

assert.Error(t, err, "invalid filter syntax should return an error, not silently ignore the filter")
assert.Nil(t, result)
})

t.Run("Filter with no matches returns empty list", func(t *testing.T) {
fq := "name = 'nonexistent-serve-model'"
result, err := _service.GetServeModels(api.ListOptions{
FilterQuery: &fq,
}, infSvc.Id)

require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, 0, len(result.Items), "filter with no matches should return empty items")
assert.Equal(t, int32(0), result.Size)
})
}
8 changes: 8 additions & 0 deletions internal/db/models/inference_service.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package models

import "github.com/kubeflow/hub/internal/db/filter"

type InferenceServiceListOptions struct {
Pagination
Name *string
Expand All @@ -8,6 +10,12 @@ type InferenceServiceListOptions struct {
Runtime *string
}

// GetRestEntityType implements the FilterApplier interface.
// Required by ApplyFilterQuery in generic_repository.go to apply filterQuery expressions.
func (i *InferenceServiceListOptions) GetRestEntityType() filter.RestEntityType {
return filter.RestEntityInferenceService
}

type InferenceServiceAttributes struct {
Name *string
ExternalID *string
Expand Down
Loading