@@ -830,27 +830,38 @@ func TestCatalogModelRepository(t *testing.T) {
830830 })
831831
832832 t .Run ("TestNameOrdering" , func (t * testing.T ) {
833- // Create test models with specific names for ordering
834- testModels := []string {
835- "zebra-model" ,
836- "alpha-model" ,
837- "beta-model" ,
838- "gamma-model" ,
839- "delta-model" ,
840- }
841-
842- var savedModels []models.CatalogModel
843- for _ , name := range testModels {
833+ // Create models from two different sources so stored names have source prefixes.
834+ // This validates that sorting is by display name (after the colon) not the raw stored name.
835+ // Without the fix, "source-z:alpha-model" would sort after "source-a:zebra-model".
836+ type modelSpec struct {
837+ displayName string
838+ sourceID string
839+ }
840+ testModels := []modelSpec {
841+ // source-z prefix ensures raw-name sort would put alpha/beta last
842+ {displayName : "alpha-model" , sourceID : "source-z" },
843+ {displayName : "beta-model" , sourceID : "source-z" },
844+ // source-a prefix ensures raw-name sort would put gamma/zebra first
845+ {displayName : "gamma-model" , sourceID : "source-a" },
846+ {displayName : "zebra-model" , sourceID : "source-a" },
847+ {displayName : "delta-model" , sourceID : "source-m" },
848+ // colon in display name: stored as "source-a:epsilon:v2", display name is "epsilon:v2"
849+ {displayName : "epsilon:v2" , sourceID : "source-a" },
850+ }
851+
852+ for _ , spec := range testModels {
844853 catalogModel := & models.CatalogModelImpl {
845854 Attributes : & models.CatalogModelAttributes {
846- Name : apiutils .Of (name ),
847- ExternalID : apiutils .Of (name + "-ext" ),
855+ Name : apiutils .Of (spec .displayName ),
856+ ExternalID : apiutils .Of (spec .displayName + "-ord-ext" ),
857+ },
858+ Properties : & []dbmodels.Properties {
859+ {Name : "source_id" , StringValue : apiutils .Of (spec .sourceID )},
848860 },
849861 }
850862
851- savedModel , err := repo .Save (catalogModel )
863+ _ , err := repo .Save (catalogModel )
852864 require .NoError (t , err )
853- savedModels = append (savedModels , savedModel )
854865 }
855866
856867 // Test NAME ordering ASC
@@ -864,35 +875,48 @@ func TestCatalogModelRepository(t *testing.T) {
864875 require .NoError (t , err )
865876 require .NotNil (t , result )
866877
867- // Extract our test model names from results
878+ // Extract our test model display names from results (repo returns stored namespaced names)
879+ displayNames := map [string ]string {
880+ "source-z:alpha-model" : "alpha-model" ,
881+ "source-z:beta-model" : "beta-model" ,
882+ "source-a:gamma-model" : "gamma-model" ,
883+ "source-a:zebra-model" : "zebra-model" ,
884+ "source-m:delta-model" : "delta-model" ,
885+ // display name contains a colon: stored name is "source-a:epsilon:v2"
886+ "source-a:epsilon:v2" : "epsilon:v2" ,
887+ }
868888 var foundNames []string
869889 for _ , model := range result .Items {
870- name := * model .GetAttributes ().Name
871- if name == "zebra-model" || name == "alpha-model" || name == "beta-model" ||
872- name == "gamma-model" || name == "delta-model" {
873- foundNames = append (foundNames , name )
890+ storedName := * model .GetAttributes ().Name
891+ if displayName , ok := displayNames [storedName ]; ok {
892+ foundNames = append (foundNames , displayName )
874893 }
875894 }
876895
877896 // Verify we found all our test models
878- require .GreaterOrEqual (t , len (foundNames ), 5 , "Should find all test models" )
897+ require .GreaterOrEqual (t , len (foundNames ), 6 , "Should find all test models" )
879898
880- // Verify ASC ordering: alpha < beta < delta < gamma < zebra
899+ // Verify ASC ordering by display name: alpha < beta < delta < epsilon:v2 < gamma < zebra
900+ // Without the fix, source-z models (alpha, beta) would appear after source-a models (gamma, zebra).
901+ // Also verifies multi-colon display names ("epsilon:v2") sort correctly.
881902 alphaIdx := findIndex (foundNames , "alpha-model" )
882903 betaIdx := findIndex (foundNames , "beta-model" )
883904 deltaIdx := findIndex (foundNames , "delta-model" )
905+ epsilonIdx := findIndex (foundNames , "epsilon:v2" )
884906 gammaIdx := findIndex (foundNames , "gamma-model" )
885907 zebraIdx := findIndex (foundNames , "zebra-model" )
886908
887909 require .NotEqual (t , - 1 , alphaIdx , "alpha-model not found" )
888910 require .NotEqual (t , - 1 , betaIdx , "beta-model not found" )
889911 require .NotEqual (t , - 1 , deltaIdx , "delta-model not found" )
912+ require .NotEqual (t , - 1 , epsilonIdx , "epsilon:v2 not found" )
890913 require .NotEqual (t , - 1 , gammaIdx , "gamma-model not found" )
891914 require .NotEqual (t , - 1 , zebraIdx , "zebra-model not found" )
892915
893916 assert .Less (t , alphaIdx , betaIdx , "alpha should come before beta in ASC" )
894917 assert .Less (t , betaIdx , deltaIdx , "beta should come before delta in ASC" )
895- assert .Less (t , deltaIdx , gammaIdx , "delta should come before gamma in ASC" )
918+ assert .Less (t , deltaIdx , epsilonIdx , "delta should come before epsilon:v2 in ASC" )
919+ assert .Less (t , epsilonIdx , gammaIdx , "epsilon:v2 should come before gamma in ASC" )
896920 assert .Less (t , gammaIdx , zebraIdx , "gamma should come before zebra in ASC" )
897921
898922 // Test NAME ordering DESC
@@ -909,22 +933,23 @@ func TestCatalogModelRepository(t *testing.T) {
909933 // Extract our test model names from DESC results
910934 foundNames = []string {}
911935 for _ , model := range result .Items {
912- name := * model .GetAttributes ().Name
913- if name == "zebra-model" || name == "alpha-model" || name == "beta-model" ||
914- name == "gamma-model" || name == "delta-model" {
915- foundNames = append (foundNames , name )
936+ storedName := * model .GetAttributes ().Name
937+ if displayName , ok := displayNames [storedName ]; ok {
938+ foundNames = append (foundNames , displayName )
916939 }
917940 }
918941
919- // Verify DESC ordering: zebra > gamma > delta > beta > alpha
942+ // Verify DESC ordering: zebra > gamma > epsilon:v2 > delta > beta > alpha
920943 alphaIdxDesc := findIndex (foundNames , "alpha-model" )
921944 betaIdxDesc := findIndex (foundNames , "beta-model" )
922945 deltaIdxDesc := findIndex (foundNames , "delta-model" )
946+ epsilonIdxDesc := findIndex (foundNames , "epsilon:v2" )
923947 gammaIdxDesc := findIndex (foundNames , "gamma-model" )
924948 zebraIdxDesc := findIndex (foundNames , "zebra-model" )
925949
926950 assert .Less (t , zebraIdxDesc , gammaIdxDesc , "zebra should come before gamma in DESC" )
927- assert .Less (t , gammaIdxDesc , deltaIdxDesc , "gamma should come before delta in DESC" )
951+ assert .Less (t , gammaIdxDesc , epsilonIdxDesc , "gamma should come before epsilon:v2 in DESC" )
952+ assert .Less (t , epsilonIdxDesc , deltaIdxDesc , "epsilon:v2 should come before delta in DESC" )
928953 assert .Less (t , deltaIdxDesc , betaIdxDesc , "delta should come before beta in DESC" )
929954 assert .Less (t , betaIdxDesc , alphaIdxDesc , "beta should come before alpha in DESC" )
930955 })
@@ -1023,6 +1048,86 @@ func TestCatalogModelRepository(t *testing.T) {
10231048 }
10241049 })
10251050
1051+ t .Run ("TestNameOrderingMultiColonPagination" , func (t * testing.T ) {
1052+ // Regression test: display names containing a colon (e.g. "llama:7b" stored as "source:llama:7b")
1053+ // caused cursor-based pagination to break because SUBSTRING(name FROM STRPOS(name, ':') + 1)
1054+ // returns everything after the first colon (e.g. "llama:7b"), while strings.Cut does the same,
1055+ // keeping cursor values consistent with ORDER BY values.
1056+ multiColonModels := []struct {
1057+ displayName string
1058+ sourceID string
1059+ }{
1060+ {"mc-model:aa" , "source-x" },
1061+ {"mc-model:bb" , "source-x" },
1062+ {"mc-model:cc" , "source-x" },
1063+ {"mc-model:dd" , "source-x" },
1064+ }
1065+
1066+ for _ , spec := range multiColonModels {
1067+ catalogModel := & models.CatalogModelImpl {
1068+ Attributes : & models.CatalogModelAttributes {
1069+ Name : apiutils .Of (spec .displayName ),
1070+ ExternalID : apiutils .Of (spec .displayName + "-mc-ext" ),
1071+ },
1072+ Properties : & []dbmodels.Properties {
1073+ {Name : "source_id" , StringValue : apiutils .Of (spec .sourceID )},
1074+ },
1075+ }
1076+ _ , err := repo .Save (catalogModel )
1077+ require .NoError (t , err )
1078+ }
1079+
1080+ // Paginate through all 4 models 2 at a time; verify ordering is stable across pages.
1081+ listOptions := models.CatalogModelListOptions {
1082+ Pagination : dbmodels.Pagination {
1083+ OrderBy : apiutils .Of ("NAME" ),
1084+ SortOrder : apiutils .Of ("ASC" ),
1085+ PageSize : apiutils .Of (int32 (2 )),
1086+ },
1087+ }
1088+
1089+ var allFound []string
1090+ currentToken := (* string )(nil )
1091+ for range 10 {
1092+ if currentToken != nil {
1093+ listOptions .Pagination .NextPageToken = currentToken
1094+ }
1095+ page , err := repo .List (listOptions )
1096+ require .NoError (t , err )
1097+
1098+ for _ , model := range page .Items {
1099+ name := * model .GetAttributes ().Name
1100+ if strings .HasPrefix (name , "source-x:mc-model:" ) {
1101+ allFound = append (allFound , name )
1102+ }
1103+ }
1104+
1105+ if page .NextPageToken == "" || len (allFound ) >= 4 {
1106+ break
1107+ }
1108+ currentToken = & page .NextPageToken
1109+ }
1110+
1111+ require .GreaterOrEqual (t , len (allFound ), 4 , "Should find all multi-colon models" )
1112+
1113+ // Verify ASC ordering is preserved across pages
1114+ expectedOrder := []string {
1115+ "source-x:mc-model:aa" ,
1116+ "source-x:mc-model:bb" ,
1117+ "source-x:mc-model:cc" ,
1118+ "source-x:mc-model:dd" ,
1119+ }
1120+ lastIndex := - 1
1121+ for _ , expected := range expectedOrder {
1122+ idx := findIndex (allFound , expected )
1123+ assert .NotEqual (t , - 1 , idx , "Should find %s" , expected )
1124+ if idx != - 1 {
1125+ assert .Greater (t , idx , lastIndex , "%s should appear after previous models in ASC order" , expected )
1126+ lastIndex = idx
1127+ }
1128+ }
1129+ })
1130+
10261131 t .Run ("TestDeleteBySource" , func (t * testing.T ) {
10271132 // Setup: Create models with different source IDs
10281133 sourceID1 := "test_source_1"
0 commit comments