// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package filter import ( "testing" ) func FuzzFilterParse(f *testing.F) { f.Fuzz(func(t *testing.T, data string) { Parse(data) }) }
package filter import ( "fmt" "strings" "sync" "github.com/alecthomas/participle/v2" "github.com/alecthomas/participle/v2/lexer" ) // Constants for property value types const ( StringValueType = "string_value" DoubleValueType = "double_value" IntValueType = "int_value" BoolValueType = "bool_value" ) // Define the lexer for SQL WHERE clauses var sqlLexer = lexer.MustSimple([]lexer.SimpleRule{ {Name: "whitespace", Pattern: `\s+`}, {Name: "Comment", Pattern: `--[^\r\n]*`}, {Name: "Ident", Pattern: `[a-zA-Z_][a-zA-Z0-9_]*`}, {Name: "Float", Pattern: `[-+]?\d*\.\d+([eE][-+]?\d+)?|[-+]?\d+[eE][-+]?\d+`}, {Name: "Int", Pattern: `[-+]?\d+`}, {Name: "String", Pattern: `'([^'\\]|\\.)*'|"([^"\\]|\\.)*"`}, {Name: "EscapedIdent", Pattern: "`([^`\\\\]|\\\\.)*`"}, {Name: "Operators", Pattern: `>=|<=|!=|<>|=|>|<`}, {Name: "Punct", Pattern: `[().,]`}, }) // Global parser instance - built once, reused everywhere (thread-safe) var ( globalParser *participle.Parser[WhereClause] parserOnce sync.Once ) // initParser builds the parser, called in getParser using sync.Once for thread safety func initParser() { globalParser = participle.MustBuild[WhereClause]( participle.Lexer(sqlLexer), participle.Elide("whitespace", "Comment"), participle.CaseInsensitive("OR", "AND", "LIKE", "ILIKE", "IN", "true", "false", "TRUE", "FALSE"), participle.CaseInsensitive(StringValueType, DoubleValueType, IntValueType, BoolValueType), ) } // getParser returns the singleton parser instance (thread-safe) func getParser() *participle.Parser[WhereClause] { parserOnce.Do(initParser) return globalParser } // Grammar structures for SQL WHERE clauses //nolint:govet type WhereClause struct { Expression *Expression `@@` } //nolint:govet type Expression struct { Or *OrExpression `@@` } //nolint:govet type OrExpression struct { Left *AndExpression `@@` Right []*AndExpression `("OR" @@)*` } //nolint:govet type AndExpression struct { Left *Term `@@` Right []*Term `("AND" @@)*` } //nolint:govet type Term struct { Group *Expression `"(" @@ ")"` Comparison *Comparison `| @@` } //nolint:govet type Comparison struct { Left *PropertyRef `@@` Operator string `@("=" | "!=" | "<>" | ">=" | "<=" | ">" | "<" | "LIKE" | "ILIKE" | "IN")` Right *Value `@@` } //nolint:govet type PropertyRef struct { EscapedName string `@EscapedIdent` Name string `| @Ident` Type string `("." @("string_value" | "double_value" | "int_value" | "bool_value"))?` } //nolint:govet type Value struct { String *string `@String` Integer *int64 `| @Int` Float *float64 `| @Float` Boolean *string `| @("true" | "false" | "TRUE" | "FALSE")` ValueList *ValueList `| @@` } //nolint:govet type ValueList struct { Values []*SingleValue `"(" (@@ ("," @@)*)? ")"` } //nolint:govet type SingleValue struct { String *string `@String` Integer *int64 `| @Int` Float *float64 `| @Float` Boolean *string `| @("true" | "false" | "TRUE" | "FALSE")` } // FilterExpression represents a parsed filter expression (keeping for compatibility) type FilterExpression struct { Left *FilterExpression Right *FilterExpression Operator string Property string Value interface{} IsLeaf bool } // PropertyReference represents a property reference with type information type PropertyReference struct { Name string IsCustom bool ValueType string // StringValueType, DoubleValueType, IntValueType, BoolValueType IsEscaped bool // whether the property name was escaped with backticks } // Parse parses a filter query string and returns the root expression // This function is thread-safe and reuses a singleton parser instance func Parse(input string) (*FilterExpression, error) { if strings.TrimSpace(input) == "" { return nil, nil } parser := getParser() whereClause, err := parser.ParseString("", input) if err != nil { return nil, fmt.Errorf("error parsing filter query: %w", err) } return convertToFilterExpression(whereClause.Expression), nil } // convertToFilterExpression converts the participle AST to our FilterExpression func convertToFilterExpression(expr *Expression) *FilterExpression { return convertOrExpression(expr.Or) } func convertOrExpression(expr *OrExpression) *FilterExpression { left := convertAndExpression(expr.Left) for _, right := range expr.Right { rightExpr := convertAndExpression(right) left = &FilterExpression{ Left: left, Right: rightExpr, Operator: "OR", IsLeaf: false, } } return left } func convertAndExpression(expr *AndExpression) *FilterExpression { left := convertTerm(expr.Left) for _, right := range expr.Right { rightExpr := convertTerm(right) left = &FilterExpression{ Left: left, Right: rightExpr, Operator: "AND", IsLeaf: false, } } return left } func convertTerm(term *Term) *FilterExpression { if term.Group != nil { return convertToFilterExpression(term.Group) } return convertComparison(term.Comparison) } func convertComparison(comp *Comparison) *FilterExpression { propRef := convertPropertyRef(comp.Left, comp.Right) value := convertValue(comp.Right) // Preserve the full property name with type suffix if specified propertyName := propRef.Name if comp.Left.Type != "" { propertyName = propRef.Name + "." + comp.Left.Type } return &FilterExpression{ Property: propertyName, Operator: comp.Operator, Value: value, IsLeaf: true, } } func convertPropertyRef(prop *PropertyRef, value *Value) *PropertyReference { var name string var isEscaped bool if prop.EscapedName != "" { // Remove backticks from escaped name name = strings.Trim(prop.EscapedName, "`") // Handle escape sequences in the name name = strings.ReplaceAll(name, `\.`, `.`) name = strings.ReplaceAll(name, `\\`, `\`) isEscaped = true } else { name = prop.Name isEscaped = false } var valueType string var isCustom bool if prop.Type != "" { // Explicit type specified valueType = prop.Type // We still need to determine if it's custom based on the property mapping // This is a bit tricky since we don't have entity type context here // For now, assume if explicit type is given, it could be either isCustom = true // Will be properly determined later in query builder } else { // Use the new property mapping system - but we need entity type context // For now, use a fallback approach and let the query builder handle it properly isCustom = true // Will be properly determined later in query builder valueType = inferValueType(value) } return &PropertyReference{ Name: name, IsCustom: isCustom, ValueType: valueType, IsEscaped: isEscaped, } } func convertValue(val *Value) interface{} { if val.String != nil { return unquoteStringValue(*val.String) } if val.Integer != nil { return *val.Integer } if val.Float != nil { return *val.Float } if val.Boolean != nil { return strings.ToLower(*val.Boolean) == "true" } if val.ValueList != nil { // Convert list of values to slice var values []any for _, singleVal := range val.ValueList.Values { values = append(values, convertSingleValue(singleVal)) } return values } return nil } func convertSingleValue(val *SingleValue) interface{} { if val.String != nil { return unquoteStringValue(*val.String) } if val.Integer != nil { return *val.Integer } if val.Float != nil { return *val.Float } if val.Boolean != nil { return strings.ToLower(*val.Boolean) == "true" } return nil } // unquoteStringValue removes quotes and handles escape sequences func unquoteStringValue(str string) string { // Remove quotes from string result := strings.Trim(str, `"'`) // Handle escape sequences result = strings.ReplaceAll(result, `\"`, `"`) result = strings.ReplaceAll(result, `\'`, `'`) result = strings.ReplaceAll(result, `\\`, `\`) return result } // inferValueType determines the appropriate value type based on the actual value func inferValueType(val *Value) string { if val.ValueList != nil && len(val.ValueList.Values) > 0 { // For lists, infer type from the first value return inferSingleValueType(val.ValueList.Values[0]) } // Convert Value to SingleValue for type inference singleVal := &SingleValue{ String: val.String, Integer: val.Integer, Float: val.Float, Boolean: val.Boolean, } return inferSingleValueType(singleVal) } // inferSingleValueType determines the appropriate value type for a SingleValue func inferSingleValueType(val *SingleValue) string { if val.String != nil { return StringValueType } if val.Integer != nil { return IntValueType } if val.Float != nil { return DoubleValueType } if val.Boolean != nil { return BoolValueType } return StringValueType // default to string }
package filter // PropertyLocation indicates where a property is stored type PropertyLocation int const ( EntityTable PropertyLocation = iota // Property is a column in the main entity table PropertyTable // Property is stored in the entity's property table Custom // Property is a custom property in the property table ) // PropertyDefinition defines how a property should be handled type PropertyDefinition struct { Location PropertyLocation ValueType string // IntValueType, StringValueType, DoubleValueType, BoolValueType Column string // Database column name (for entity table) or property name (for property table) } // EntityPropertyMap maps property names to their definitions for each entity type type EntityPropertyMap map[string]PropertyDefinition // GetPropertyDefinition returns the property definition for a given entity type and property name func GetPropertyDefinition(entityType EntityType, propertyName string) PropertyDefinition { entityMap := getEntityPropertyMap(entityType) if def, exists := entityMap[propertyName]; exists { return def } // If not found in the map, assume it's a custom property return PropertyDefinition{ Location: Custom, ValueType: StringValueType, // Default to string, will be inferred at runtime Column: propertyName, // Use the property name as-is for custom properties } } // getEntityPropertyMap returns the property mapping for a specific entity type func getEntityPropertyMap(entityType EntityType) EntityPropertyMap { switch entityType { case EntityTypeContext: return contextPropertyMap case EntityTypeArtifact: return artifactPropertyMap case EntityTypeExecution: return executionPropertyMap default: return contextPropertyMap // Default fallback } } // contextPropertyMap defines properties for Context entities // Used by: RegisteredModel, ModelVersion, InferenceService, ServingEnvironment, Experiment, ExperimentRun var contextPropertyMap = EntityPropertyMap{ // Entity table columns (Context table) "id": {EntityTable, IntValueType, "id"}, "name": {EntityTable, StringValueType, "name"}, "externalId": {EntityTable, StringValueType, "external_id"}, "createTimeSinceEpoch": {EntityTable, IntValueType, "create_time_since_epoch"}, "lastUpdateTimeSinceEpoch": {EntityTable, IntValueType, "last_update_time_since_epoch"}, // Properties that are stored in ContextProperty table but are "well-known" (not custom) // These are properties that the application manages, not user-defined custom properties "registeredModelId": {PropertyTable, IntValueType, "registered_model_id"}, "modelVersionId": {PropertyTable, IntValueType, "model_version_id"}, "servingEnvironmentId": {PropertyTable, IntValueType, "serving_environment_id"}, "experimentId": {PropertyTable, IntValueType, "experiment_id"}, "runtime": {PropertyTable, StringValueType, "runtime"}, "desiredState": {PropertyTable, StringValueType, "desired_state"}, "state": {PropertyTable, StringValueType, "state"}, "owner": {PropertyTable, StringValueType, "owner"}, "author": {PropertyTable, StringValueType, "author"}, "status": {PropertyTable, StringValueType, "status"}, "endTimeSinceEpoch": {PropertyTable, StringValueType, "end_time_since_epoch"}, "startTimeSinceEpoch": {PropertyTable, StringValueType, "start_time_since_epoch"}, } var artifactPropertyMap = EntityPropertyMap{ // Entity table columns (Artifact table) "id": {EntityTable, IntValueType, "id"}, "name": {EntityTable, StringValueType, "name"}, "externalId": {EntityTable, StringValueType, "external_id"}, "createTimeSinceEpoch": {EntityTable, IntValueType, "create_time_since_epoch"}, "lastUpdateTimeSinceEpoch": {EntityTable, IntValueType, "last_update_time_since_epoch"}, "uri": {EntityTable, StringValueType, "uri"}, "state": {EntityTable, IntValueType, "state"}, // Properties that are stored in ArtifactProperty table but are "well-known" "modelFormatName": {PropertyTable, StringValueType, "model_format_name"}, "modelFormatVersion": {PropertyTable, StringValueType, "model_format_version"}, "storageKey": {PropertyTable, StringValueType, "storage_key"}, "storagePath": {PropertyTable, StringValueType, "storage_path"}, "serviceAccountName": {PropertyTable, StringValueType, "service_account_name"}, "modelSourceKind": {PropertyTable, StringValueType, "model_source_kind"}, "modelSourceClass": {PropertyTable, StringValueType, "model_source_class"}, "modelSourceGroup": {PropertyTable, StringValueType, "model_source_group"}, "modelSourceId": {PropertyTable, StringValueType, "model_source_id"}, "modelSourceName": {PropertyTable, StringValueType, "model_source_name"}, "value": {PropertyTable, DoubleValueType, "value"}, // For metrics/parameters "timestamp": {PropertyTable, IntValueType, "timestamp"}, // For metrics "step": {PropertyTable, IntValueType, "step"}, // For metrics "parameterType": {PropertyTable, StringValueType, "parameter_type"}, // For parameters "digest": {PropertyTable, StringValueType, "digest"}, // For datasets "sourceType": {PropertyTable, StringValueType, "source_type"}, // For datasets "source": {PropertyTable, StringValueType, "source"}, // For datasets "schema": {PropertyTable, StringValueType, "schema"}, // For datasets "profile": {PropertyTable, StringValueType, "profile"}, // For datasets "experimentId": {PropertyTable, IntValueType, "experiment_id"}, // For all artifacts "experimentRunId": {PropertyTable, IntValueType, "experiment_run_id"}, // For all artifacts } // executionPropertyMap defines properties for Execution entities // Used by: ServeModel var executionPropertyMap = EntityPropertyMap{ // Entity table columns (Execution table) "id": {EntityTable, IntValueType, "id"}, "name": {EntityTable, StringValueType, "name"}, "externalId": {EntityTable, StringValueType, "external_id"}, "createTimeSinceEpoch": {EntityTable, IntValueType, "create_time_since_epoch"}, "lastUpdateTimeSinceEpoch": {EntityTable, IntValueType, "last_update_time_since_epoch"}, "lastKnownState": {EntityTable, IntValueType, "last_known_state"}, // Properties that are stored in ExecutionProperty table but are "well-known" "modelVersionId": {PropertyTable, IntValueType, "model_version_id"}, "inferenceServiceId": {PropertyTable, IntValueType, "inference_service_id"}, "registeredModelId": {PropertyTable, IntValueType, "registered_model_id"}, "servingEnvironmentId": {PropertyTable, IntValueType, "serving_environment_id"}, }
package filter import ( "fmt" "strings" "github.com/kubeflow/model-registry/internal/db/constants" "gorm.io/gorm" ) // EntityType represents the type of entity for proper query building type EntityType string const ( EntityTypeContext EntityType = "context" EntityTypeArtifact EntityType = "artifact" EntityTypeExecution EntityType = "execution" ) // QueryBuilder builds GORM queries from filter expressions // It handles special cases like prefixed names for child entities (e.g., ModelVersion, ExperimentRun) // where names are stored as "parentId:actualName" in the database type QueryBuilder struct { entityType EntityType restEntityType RestEntityType tablePrefix string joinCounter int db *gorm.DB // Added to access naming strategy } // NewQueryBuilderForRestEntity creates a new query builder for the specified REST entity type func NewQueryBuilderForRestEntity(restEntityType RestEntityType) *QueryBuilder { // Get the underlying MLMD entity type entityType := GetMLMDEntityType(restEntityType) var tablePrefix string switch entityType { case EntityTypeContext: tablePrefix = "Context" case EntityTypeArtifact: tablePrefix = "Artifact" case EntityTypeExecution: tablePrefix = "Execution" default: tablePrefix = "Context" // default } return &QueryBuilder{ entityType: entityType, restEntityType: restEntityType, tablePrefix: tablePrefix, joinCounter: 0, } } // BuildQuery builds a GORM query from a filter expression func (qb *QueryBuilder) BuildQuery(db *gorm.DB, expr *FilterExpression) *gorm.DB { if expr == nil { return db } // Store db reference for table name quoting qb.db = db qb.applyDatabaseQuoting() return qb.buildExpression(db, expr) } // applyDatabaseQuoting updates tablePrefix with proper quoting based on database dialect func (qb *QueryBuilder) applyDatabaseQuoting() { if qb.db == nil { return } switch qb.db.Name() { case "mysql": qb.tablePrefix = "`" + qb.tablePrefix + "`" case "postgres": qb.tablePrefix = `"` + qb.tablePrefix + `"` default: // Keep unquoted for other databases } } // quoteTableName quotes a table name based on database dialect func (qb *QueryBuilder) quoteTableName(tableName string) string { if qb.db == nil { return tableName } switch qb.db.Name() { case "mysql": return "`" + tableName + "`" case "postgres": return `"` + tableName + `"` default: return tableName } } // buildExpression recursively builds query conditions from filter expressions func (qb *QueryBuilder) buildExpression(db *gorm.DB, expr *FilterExpression) *gorm.DB { if expr.IsLeaf { return qb.buildLeafExpression(db, expr) } // Handle logical operators (AND, OR) switch expr.Operator { case "AND": leftQuery := qb.buildExpression(db, expr.Left) return qb.buildExpression(leftQuery, expr.Right) case "OR": // For OR conditions, we need to group them properly leftCondition := qb.buildConditionString(expr.Left) rightCondition := qb.buildConditionString(expr.Right) condition := fmt.Sprintf("(%s OR %s)", leftCondition.condition, rightCondition.condition) args := append(leftCondition.args, rightCondition.args...) return db.Where(condition, args...) default: return db } } // conditionResult holds a condition string and its arguments type conditionResult struct { condition string args []any } // buildConditionString builds a condition string from an expression (for OR grouping) func (qb *QueryBuilder) buildConditionString(expr *FilterExpression) conditionResult { if expr.IsLeaf { return qb.buildLeafConditionString(expr) } switch expr.Operator { case "AND": left := qb.buildConditionString(expr.Left) right := qb.buildConditionString(expr.Right) condition := fmt.Sprintf("(%s AND %s)", left.condition, right.condition) args := append(left.args, right.args...) return conditionResult{condition: condition, args: args} case "OR": left := qb.buildConditionString(expr.Left) right := qb.buildConditionString(expr.Right) condition := fmt.Sprintf("(%s OR %s)", left.condition, right.condition) args := append(left.args, right.args...) return conditionResult{condition: condition, args: args} } return conditionResult{condition: "1=1", args: []any{}} } // buildPropertyReference creates a property reference from a filter expression func (qb *QueryBuilder) buildPropertyReference(expr *FilterExpression) *PropertyReference { var propDef PropertyDefinition propertyName := expr.Property // Check if the property has an explicit type suffix (e.g., "budget.double_value") var explicitType string if parts := strings.Split(propertyName, "."); len(parts) == 2 { propertyName = parts[0] explicitType = parts[1] } // Use REST entity type-aware property mapping if available if qb.restEntityType != "" { propDef = GetPropertyDefinitionForRestEntity(qb.restEntityType, propertyName) } else { // Fallback to MLMD entity type only propDef = GetPropertyDefinition(qb.entityType, propertyName) } // For property table properties, use the Column field as the database property name propName := propertyName if propDef.Location == PropertyTable && propDef.Column != "" { propName = propDef.Column } propRef := &PropertyReference{ Name: propName, IsCustom: propDef.Location == Custom, ValueType: propDef.ValueType, } // If explicit type was specified, use it if explicitType != "" { propRef.ValueType = explicitType } else if propRef.IsCustom { // For custom properties without explicit type, infer from value propRef.ValueType = qb.inferValueTypeFromInterface(expr.Value) } return propRef } // buildLeafExpression builds a GORM query for a leaf expression (property comparison) func (qb *QueryBuilder) buildLeafExpression(db *gorm.DB, expr *FilterExpression) *gorm.DB { propRef := qb.buildPropertyReference(expr) return qb.buildPropertyCondition(db, propRef, expr.Operator, expr.Value) } // buildLeafConditionString builds a condition string for a leaf expression func (qb *QueryBuilder) buildLeafConditionString(expr *FilterExpression) conditionResult { propRef := qb.buildPropertyReference(expr) return qb.buildPropertyConditionString(propRef, expr.Operator, expr.Value) } // inferValueTypeFromInterface infers the value type from an any value func (qb *QueryBuilder) inferValueTypeFromInterface(value any) string { switch value.(type) { case string: return StringValueType case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: return IntValueType case float32, float64: return DoubleValueType case bool: return BoolValueType default: return StringValueType // fallback } } // buildPropertyCondition builds a GORM query condition for a property func (qb *QueryBuilder) buildPropertyCondition(db *gorm.DB, propRef *PropertyReference, operator string, value any) *gorm.DB { propDef := GetPropertyDefinition(qb.entityType, propRef.Name) switch propDef.Location { case EntityTable: return qb.buildEntityTablePropertyCondition(db, propRef, operator, value) case PropertyTable, Custom: return qb.buildPropertyTableCondition(db, propRef, operator, value) default: return qb.buildEntityTablePropertyCondition(db, propRef, operator, value) } } // buildPropertyConditionString builds a condition string for a property func (qb *QueryBuilder) buildPropertyConditionString(propRef *PropertyReference, operator string, value any) conditionResult { propDef := GetPropertyDefinition(qb.entityType, propRef.Name) switch propDef.Location { case EntityTable: return qb.buildEntityTablePropertyConditionString(propRef, operator, value) case PropertyTable, Custom: return qb.buildPropertyTableConditionString(propRef, operator, value) default: return qb.buildEntityTablePropertyConditionString(propRef, operator, value) } } // ConvertStateValue converts string state values to integers based on entity type func (qb *QueryBuilder) ConvertStateValue(propertyName string, value any) any { // Only convert for state properties if propertyName == "state" { if strValue, ok := value.(string); ok { switch qb.entityType { case EntityTypeArtifact: if intValue, exists := constants.ArtifactStateMapping[strValue]; exists { return int32(intValue) } // Invalid artifact state - return value that matches no records return int32(-1) // No artifact has state=-1, so this returns empty results case EntityTypeExecution: if intValue, exists := constants.ExecutionStateMapping[strValue]; exists { return int32(intValue) } // Invalid execution state - return value that matches no records return int32(-1) // No execution has state=-1, so this returns empty results case EntityTypeContext: // Context entities (RegisteredModel, ModelVersion, etc.) use string states // These are stored as string properties, so no conversion needed return value } } // If conversion fails or value is not a string, return original value } return value } // buildEntityTablePropertyCondition builds a condition for properties stored in the entity table func (qb *QueryBuilder) buildEntityTablePropertyCondition(db *gorm.DB, propRef *PropertyReference, operator string, value any) *gorm.DB { propDef := GetPropertyDefinition(qb.entityType, propRef.Name) column := fmt.Sprintf("%s.%s", qb.tablePrefix, propDef.Column) // Convert state string values to integers based on entity type value = qb.ConvertStateValue(propRef.Name, value) // Handle prefixed names for child entities if qb.restEntityType != "" && propRef.Name == "name" && isChildEntity(qb.restEntityType) { if strValue, ok := value.(string); ok { // For exact match, convert to LIKE pattern with prefix if operator == "=" { operator = "LIKE" value = "%:" + strValue } else if operator == "LIKE" && !strings.Contains(strValue, ":") { // For LIKE patterns without ':', add prefix handling if !strings.HasPrefix(strValue, "%") { // Pattern like 'pattern%' -> needs prefix wildcard -> '%:pattern%' value = "%:" + strValue } // Pattern like '%something' or '%-beta' -> keep as is // because names are stored as 'parentId:actualName' and '%' will match 'parentId:' } // If pattern already contains ':', assume it's already properly formatted } } // Use cross-database case-insensitive LIKE for ILIKE operator if operator == "ILIKE" { return qb.buildCaseInsensitiveLikeCondition(db, column, value) } condition := qb.buildOperatorCondition(column, operator, value) return db.Where(condition.condition, condition.args...) } // buildEntityTablePropertyConditionString builds a condition string for properties stored in the entity table func (qb *QueryBuilder) buildEntityTablePropertyConditionString(propRef *PropertyReference, operator string, value any) conditionResult { propDef := GetPropertyDefinition(qb.entityType, propRef.Name) column := fmt.Sprintf("%s.%s", qb.tablePrefix, propDef.Column) // Convert state string values to integers based on entity type value = qb.ConvertStateValue(propRef.Name, value) // Handle prefixed names for child entities if qb.restEntityType != "" && propRef.Name == "name" && isChildEntity(qb.restEntityType) { if strValue, ok := value.(string); ok { // For exact match, convert to LIKE pattern with prefix if operator == "=" { operator = "LIKE" value = "%:" + strValue } else if operator == "LIKE" && !strings.Contains(strValue, ":") { // For LIKE patterns without ':', add prefix handling if !strings.HasPrefix(strValue, "%") { // Pattern like 'pattern%' -> needs prefix wildcard -> '%:pattern%' value = "%:" + strValue } // Pattern like '%something' or '%-beta' -> keep as is // because names are stored as 'parentId:actualName' and '%' will match 'parentId:' } // If pattern already contains ':', assume it's already properly formatted } } return qb.buildOperatorCondition(column, operator, value) } // buildPropertyTableCondition builds a condition for properties stored in the property table (requires join) func (qb *QueryBuilder) buildPropertyTableCondition(db *gorm.DB, propRef *PropertyReference, operator string, value any) *gorm.DB { qb.joinCounter++ alias := fmt.Sprintf("prop_%d", qb.joinCounter) // Determine the property table based on entity type var propertyTable string var joinCondition string switch qb.entityType { case EntityTypeContext: propertyTable = qb.quoteTableName("ContextProperty") joinCondition = fmt.Sprintf("%s.context_id = %s.id", alias, qb.tablePrefix) case EntityTypeArtifact: propertyTable = qb.quoteTableName("ArtifactProperty") joinCondition = fmt.Sprintf("%s.artifact_id = %s.id", alias, qb.tablePrefix) case EntityTypeExecution: propertyTable = qb.quoteTableName("ExecutionProperty") joinCondition = fmt.Sprintf("%s.execution_id = %s.id", alias, qb.tablePrefix) } // Join the property table joinClause := fmt.Sprintf("JOIN %s %s ON %s", propertyTable, alias, joinCondition) db = db.Joins(joinClause) // Add conditions for property name db = db.Where(fmt.Sprintf("%s.name = ?", alias), propRef.Name) // Use the specific value type column based on inferred type valueColumn := fmt.Sprintf("%s.%s", alias, propRef.ValueType) // Use cross-database case-insensitive LIKE for ILIKE operator if operator == "ILIKE" { return qb.buildCaseInsensitiveLikeCondition(db, valueColumn, value) } condition := qb.buildOperatorCondition(valueColumn, operator, value) return db.Where(condition.condition, condition.args...) } // buildPropertyTableConditionString builds a condition string for properties stored in the property table func (qb *QueryBuilder) buildPropertyTableConditionString(propRef *PropertyReference, operator string, value any) conditionResult { // This is more complex for OR conditions - we need to handle joins differently // For now, we'll create a subquery-based approach var propertyTable string var joinColumn string switch qb.entityType { case EntityTypeContext: propertyTable = qb.quoteTableName("ContextProperty") joinColumn = "context_id" case EntityTypeArtifact: propertyTable = qb.quoteTableName("ArtifactProperty") joinColumn = "artifact_id" case EntityTypeExecution: propertyTable = qb.quoteTableName("ExecutionProperty") joinColumn = "execution_id" } // Use the specific value type column based on inferred type valueColumn := propRef.ValueType condition := qb.buildOperatorCondition(valueColumn, operator, value) subquery := fmt.Sprintf("EXISTS (SELECT 1 FROM %s WHERE %s.%s = %s.id AND %s.name = ? AND %s)", propertyTable, propertyTable, joinColumn, qb.tablePrefix, propertyTable, condition.condition) args := []any{propRef.Name} args = append(args, condition.args...) return conditionResult{condition: subquery, args: args} } // buildOperatorCondition builds a condition string for an operator func (qb *QueryBuilder) buildOperatorCondition(column string, operator string, value any) conditionResult { switch operator { case "=": return conditionResult{condition: fmt.Sprintf("%s = ?", column), args: []any{value}} case "!=": return conditionResult{condition: fmt.Sprintf("%s != ?", column), args: []any{value}} case ">": return conditionResult{condition: fmt.Sprintf("%s > ?", column), args: []any{value}} case ">=": return conditionResult{condition: fmt.Sprintf("%s >= ?", column), args: []any{value}} case "<": return conditionResult{condition: fmt.Sprintf("%s < ?", column), args: []any{value}} case "<=": return conditionResult{condition: fmt.Sprintf("%s <= ?", column), args: []any{value}} case "LIKE": return conditionResult{condition: fmt.Sprintf("%s LIKE ?", column), args: []any{value}} case "ILIKE": // Cross-database case-insensitive LIKE using UPPER() // This works across MySQL, PostgreSQL, SQLite, and most other databases if strValue, ok := value.(string); ok { return conditionResult{ condition: fmt.Sprintf("UPPER(%s) LIKE UPPER(?)", column), args: []any{strValue}, } } // Fallback to regular LIKE if value is not a string return conditionResult{condition: fmt.Sprintf("%s LIKE ?", column), args: []any{value}} case "IN": // Handle IN operator with array values if valueSlice, ok := value.([]interface{}); ok { if len(valueSlice) == 0 { // Empty list should return false condition return conditionResult{condition: "1 = 0", args: []any{}} } // Create placeholders for each value condition := fmt.Sprintf("%s IN (?%s)", column, strings.Repeat(",?", len(valueSlice)-1)) return conditionResult{condition: condition, args: valueSlice} } // Fallback to single value (shouldn't normally happen with proper parsing) return conditionResult{condition: fmt.Sprintf("%s IN (?)", column), args: []any{value}} default: // Default to equality return conditionResult{condition: fmt.Sprintf("%s = ?", column), args: []any{value}} } } // buildCaseInsensitiveLikeCondition builds a cross-database case-insensitive LIKE condition // This method provides different implementations based on the database type for optimal performance // //nolint:staticcheck // Embedded field access is intentional for database dialect checking func (qb *QueryBuilder) buildCaseInsensitiveLikeCondition(db *gorm.DB, column string, value any) *gorm.DB { if strValue, ok := value.(string); ok { // Check database type for optimal implementation switch db.Dialector.Name() { case "postgres": // PostgreSQL supports ILIKE natively (most efficient) return db.Where(fmt.Sprintf("%s ILIKE ?", column), strValue) case "mysql": // MySQL: use COLLATE for case-insensitive comparison return db.Where(fmt.Sprintf("%s LIKE ? COLLATE utf8mb4_unicode_ci", column), strValue) case "sqlite": // SQLite: LIKE is case-insensitive by default for ASCII characters return db.Where(fmt.Sprintf("%s LIKE ?", column), strValue) default: // Fallback: use UPPER() function (works on most databases) return db.Where(fmt.Sprintf("UPPER(%s) LIKE UPPER(?)", column), strValue) } } // Fallback to regular LIKE if value is not a string return db.Where(fmt.Sprintf("%s LIKE ?", column), value) }
package filter // RestEntityType represents the specific REST API entity type type RestEntityType string const ( // Context-based REST entities RestEntityRegisteredModel RestEntityType = "RegisteredModel" RestEntityModelVersion RestEntityType = "ModelVersion" RestEntityInferenceService RestEntityType = "InferenceService" RestEntityServingEnvironment RestEntityType = "ServingEnvironment" RestEntityExperiment RestEntityType = "Experiment" RestEntityExperimentRun RestEntityType = "ExperimentRun" // Artifact-based REST entities RestEntityModelArtifact RestEntityType = "ModelArtifact" RestEntityDocArtifact RestEntityType = "DocArtifact" RestEntityDataSet RestEntityType = "DataSet" RestEntityMetric RestEntityType = "Metric" RestEntityParameter RestEntityType = "Parameter" // Execution-based REST entities RestEntityServeModel RestEntityType = "ServeModel" ) // isChildEntity returns true if the REST entity type uses prefixed names (parentId:name) func isChildEntity(entityType RestEntityType) bool { // Only top-level entities don't use prefixed names switch entityType { case RestEntityRegisteredModel, RestEntityExperiment, RestEntityServingEnvironment: return false default: // All other entities are child entities that use prefixed names return true } } // RestEntityPropertyMap maps REST entity types to their allowed properties var RestEntityPropertyMap = map[RestEntityType]map[string]bool{ // Context-based entities RestEntityRegisteredModel: { // Common Context properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, // RegisteredModel-specific properties "state": true, "owner": true, // No experiment or serving-specific properties allowed }, RestEntityModelVersion: { // Common Context properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, // ModelVersion-specific properties "registeredModelId": true, "state": true, "author": true, // No experiment or serving-specific properties allowed }, RestEntityInferenceService: { // Common Context properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, // InferenceService-specific properties "registeredModelId": true, "modelVersionId": true, "servingEnvironmentId": true, "runtime": true, "desiredState": true, // No experiment-specific properties allowed }, RestEntityServingEnvironment: { // Common Context properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, // ServingEnvironment-specific properties (minimal) // No inference or experiment-specific properties allowed }, RestEntityExperiment: { // Common Context properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, // Experiment-specific properties "state": true, "owner": true, // No serving or model-specific properties allowed }, RestEntityExperimentRun: { // Common Context properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, // ExperimentRun-specific properties "experimentId": true, "startTimeSinceEpoch": true, "endTimeSinceEpoch": true, "status": true, "state": true, "owner": true, // No serving or model-specific properties allowed }, // Artifact-based entities RestEntityModelArtifact: { // Common Artifact properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, "uri": true, "state": true, // ModelArtifact-specific properties "modelFormatName": true, "modelFormatVersion": true, "storageKey": true, "storagePath": true, "serviceAccountName": true, "modelSourceKind": true, "modelSourceClass": true, "modelSourceGroup": true, "modelSourceId": true, "modelSourceName": true, // Experiment properties (available on all artifacts) "experimentId": true, "experimentRunId": true, // No metric/parameter/dataset-specific properties allowed }, RestEntityDocArtifact: { // Common Artifact properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, "uri": true, "state": true, // Experiment properties (available on all artifacts) "experimentId": true, "experimentRunId": true, // DocArtifact has minimal additional properties // No metric/parameter/dataset-specific properties allowed }, RestEntityDataSet: { // Common Artifact properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, "uri": true, "state": true, // DataSet-specific properties "digest": true, "sourceType": true, "source": true, "schema": true, "profile": true, // Experiment properties (available on all artifacts) "experimentId": true, "experimentRunId": true, // No metric/parameter/model-specific properties allowed }, RestEntityMetric: { // Common Artifact properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, "uri": true, "state": true, // Metric-specific properties "value": true, "timestamp": true, "step": true, // Experiment properties (available on all artifacts) "experimentId": true, "experimentRunId": true, // No parameter/dataset/model-specific properties allowed }, RestEntityParameter: { // Common Artifact properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, "uri": true, "state": true, // Parameter-specific properties "value": true, "parameterType": true, // Experiment properties (available on all artifacts) "experimentId": true, "experimentRunId": true, // No metric/dataset/model-specific properties allowed }, // Execution-based entities RestEntityServeModel: { // Common Execution properties "id": true, "name": true, "externalId": true, "createTimeSinceEpoch": true, "lastUpdateTimeSinceEpoch": true, "lastKnownState": true, // ServeModel-specific properties "modelVersionId": true, "inferenceServiceId": true, "registeredModelId": true, "servingEnvironmentId": true, }, } // GetMLMDEntityType maps REST entity types to their underlying MLMD entity type func GetMLMDEntityType(restEntityType RestEntityType) EntityType { switch restEntityType { case RestEntityRegisteredModel, RestEntityModelVersion, RestEntityInferenceService, RestEntityServingEnvironment, RestEntityExperiment, RestEntityExperimentRun: return EntityTypeContext case RestEntityModelArtifact, RestEntityDocArtifact, RestEntityDataSet, RestEntityMetric, RestEntityParameter: return EntityTypeArtifact case RestEntityServeModel: return EntityTypeExecution default: return EntityTypeContext // Default fallback } } // GetPropertyDefinitionForRestEntity returns property definition for a REST entity type // This function determines the correct data type and storage location for properties func GetPropertyDefinitionForRestEntity(restEntityType RestEntityType, propertyName string) PropertyDefinition { // Check if this is a well-known property for this specific REST entity type allowedProperties, exists := RestEntityPropertyMap[restEntityType] if exists { if _, isWellKnown := allowedProperties[propertyName]; isWellKnown { // Use the well-known property definition mlmdEntityType := GetMLMDEntityType(restEntityType) return GetPropertyDefinition(mlmdEntityType, propertyName) } } // Not a well-known property for this entity type, treat as custom return PropertyDefinition{ Location: Custom, ValueType: StringValueType, // Default, will be inferred at runtime Column: propertyName, // Use the property name as-is for custom properties } }