You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							522 lines
						
					
					
						
							17 KiB
						
					
					
				
			
		
		
		
			
			
			
		
		
	
	
							522 lines
						
					
					
						
							17 KiB
						
					
					
				| package schema | |
| 
 | |
| import ( | |
| 	"encoding/json" | |
| 	"fmt" | |
| 	"strings" | |
| 
 | |
| 	"github.com/linkedin/goavro/v2" | |
| ) | |
| 
 | |
| // CompatibilityLevel defines the schema compatibility level | |
| type CompatibilityLevel string | |
| 
 | |
| const ( | |
| 	CompatibilityNone     CompatibilityLevel = "NONE" | |
| 	CompatibilityBackward CompatibilityLevel = "BACKWARD" | |
| 	CompatibilityForward  CompatibilityLevel = "FORWARD" | |
| 	CompatibilityFull     CompatibilityLevel = "FULL" | |
| ) | |
| 
 | |
| // SchemaEvolutionChecker handles schema compatibility checking and evolution | |
| type SchemaEvolutionChecker struct { | |
| 	// Cache for parsed schemas to avoid re-parsing | |
| 	schemaCache map[string]interface{} | |
| } | |
| 
 | |
| // NewSchemaEvolutionChecker creates a new schema evolution checker | |
| func NewSchemaEvolutionChecker() *SchemaEvolutionChecker { | |
| 	return &SchemaEvolutionChecker{ | |
| 		schemaCache: make(map[string]interface{}), | |
| 	} | |
| } | |
| 
 | |
| // CompatibilityResult represents the result of a compatibility check | |
| type CompatibilityResult struct { | |
| 	Compatible bool | |
| 	Issues     []string | |
| 	Level      CompatibilityLevel | |
| } | |
| 
 | |
| // CheckCompatibility checks if two schemas are compatible according to the specified level | |
| func (checker *SchemaEvolutionChecker) CheckCompatibility( | |
| 	oldSchemaStr, newSchemaStr string, | |
| 	format Format, | |
| 	level CompatibilityLevel, | |
| ) (*CompatibilityResult, error) { | |
| 
 | |
| 	result := &CompatibilityResult{ | |
| 		Compatible: true, | |
| 		Issues:     []string{}, | |
| 		Level:      level, | |
| 	} | |
| 
 | |
| 	if level == CompatibilityNone { | |
| 		return result, nil | |
| 	} | |
| 
 | |
| 	switch format { | |
| 	case FormatAvro: | |
| 		return checker.checkAvroCompatibility(oldSchemaStr, newSchemaStr, level) | |
| 	case FormatProtobuf: | |
| 		return checker.checkProtobufCompatibility(oldSchemaStr, newSchemaStr, level) | |
| 	case FormatJSONSchema: | |
| 		return checker.checkJSONSchemaCompatibility(oldSchemaStr, newSchemaStr, level) | |
| 	default: | |
| 		return nil, fmt.Errorf("unsupported schema format for compatibility check: %s", format) | |
| 	} | |
| } | |
| 
 | |
| // checkAvroCompatibility checks Avro schema compatibility | |
| func (checker *SchemaEvolutionChecker) checkAvroCompatibility( | |
| 	oldSchemaStr, newSchemaStr string, | |
| 	level CompatibilityLevel, | |
| ) (*CompatibilityResult, error) { | |
| 
 | |
| 	result := &CompatibilityResult{ | |
| 		Compatible: true, | |
| 		Issues:     []string{}, | |
| 		Level:      level, | |
| 	} | |
| 
 | |
| 	// Parse old schema | |
| 	oldSchema, err := goavro.NewCodec(oldSchemaStr) | |
| 	if err != nil { | |
| 		return nil, fmt.Errorf("failed to parse old Avro schema: %w", err) | |
| 	} | |
| 
 | |
| 	// Parse new schema | |
| 	newSchema, err := goavro.NewCodec(newSchemaStr) | |
| 	if err != nil { | |
| 		return nil, fmt.Errorf("failed to parse new Avro schema: %w", err) | |
| 	} | |
| 
 | |
| 	// Parse schema structures for detailed analysis | |
| 	var oldSchemaMap, newSchemaMap map[string]interface{} | |
| 	if err := json.Unmarshal([]byte(oldSchemaStr), &oldSchemaMap); err != nil { | |
| 		return nil, fmt.Errorf("failed to parse old schema JSON: %w", err) | |
| 	} | |
| 	if err := json.Unmarshal([]byte(newSchemaStr), &newSchemaMap); err != nil { | |
| 		return nil, fmt.Errorf("failed to parse new schema JSON: %w", err) | |
| 	} | |
| 
 | |
| 	// Check compatibility based on level | |
| 	switch level { | |
| 	case CompatibilityBackward: | |
| 		checker.checkAvroBackwardCompatibility(oldSchemaMap, newSchemaMap, result) | |
| 	case CompatibilityForward: | |
| 		checker.checkAvroForwardCompatibility(oldSchemaMap, newSchemaMap, result) | |
| 	case CompatibilityFull: | |
| 		checker.checkAvroBackwardCompatibility(oldSchemaMap, newSchemaMap, result) | |
| 		if result.Compatible { | |
| 			checker.checkAvroForwardCompatibility(oldSchemaMap, newSchemaMap, result) | |
| 		} | |
| 	} | |
| 
 | |
| 	// Additional validation: try to create test data and check if it can be read | |
| 	if result.Compatible { | |
| 		if err := checker.validateAvroDataCompatibility(oldSchema, newSchema, level); err != nil { | |
| 			result.Compatible = false | |
| 			result.Issues = append(result.Issues, fmt.Sprintf("Data compatibility test failed: %v", err)) | |
| 		} | |
| 	} | |
| 
 | |
| 	return result, nil | |
| } | |
| 
 | |
| // checkAvroBackwardCompatibility checks if new schema can read data written with old schema | |
| func (checker *SchemaEvolutionChecker) checkAvroBackwardCompatibility( | |
| 	oldSchema, newSchema map[string]interface{}, | |
| 	result *CompatibilityResult, | |
| ) { | |
| 	// Check if fields were removed without defaults | |
| 	oldFields := checker.extractAvroFields(oldSchema) | |
| 	newFields := checker.extractAvroFields(newSchema) | |
| 
 | |
| 	for fieldName, oldField := range oldFields { | |
| 		if newField, exists := newFields[fieldName]; !exists { | |
| 			// Field was removed - this breaks backward compatibility | |
| 			result.Compatible = false | |
| 			result.Issues = append(result.Issues, | |
| 				fmt.Sprintf("Field '%s' was removed, breaking backward compatibility", fieldName)) | |
| 		} else { | |
| 			// Field exists, check type compatibility | |
| 			if !checker.areAvroTypesCompatible(oldField["type"], newField["type"], true) { | |
| 				result.Compatible = false | |
| 				result.Issues = append(result.Issues, | |
| 					fmt.Sprintf("Field '%s' type changed incompatibly", fieldName)) | |
| 			} | |
| 		} | |
| 	} | |
| 
 | |
| 	// Check if new required fields were added without defaults | |
| 	for fieldName, newField := range newFields { | |
| 		if _, exists := oldFields[fieldName]; !exists { | |
| 			// New field added | |
| 			if _, hasDefault := newField["default"]; !hasDefault { | |
| 				result.Compatible = false | |
| 				result.Issues = append(result.Issues, | |
| 					fmt.Sprintf("New required field '%s' added without default value", fieldName)) | |
| 			} | |
| 		} | |
| 	} | |
| } | |
| 
 | |
| // checkAvroForwardCompatibility checks if old schema can read data written with new schema | |
| func (checker *SchemaEvolutionChecker) checkAvroForwardCompatibility( | |
| 	oldSchema, newSchema map[string]interface{}, | |
| 	result *CompatibilityResult, | |
| ) { | |
| 	// Check if fields were added without defaults in old schema | |
| 	oldFields := checker.extractAvroFields(oldSchema) | |
| 	newFields := checker.extractAvroFields(newSchema) | |
| 
 | |
| 	for fieldName, newField := range newFields { | |
| 		if _, exists := oldFields[fieldName]; !exists { | |
| 			// New field added - for forward compatibility, the new field should have a default | |
| 			// so that old schema can ignore it when reading data written with new schema | |
| 			if _, hasDefault := newField["default"]; !hasDefault { | |
| 				result.Compatible = false | |
| 				result.Issues = append(result.Issues, | |
| 					fmt.Sprintf("New field '%s' cannot be read by old schema (no default)", fieldName)) | |
| 			} | |
| 		} else { | |
| 			// Field exists, check type compatibility (reverse direction) | |
| 			oldField := oldFields[fieldName] | |
| 			if !checker.areAvroTypesCompatible(newField["type"], oldField["type"], false) { | |
| 				result.Compatible = false | |
| 				result.Issues = append(result.Issues, | |
| 					fmt.Sprintf("Field '%s' type change breaks forward compatibility", fieldName)) | |
| 			} | |
| 		} | |
| 	} | |
| 
 | |
| 	// Check if fields were removed | |
| 	for fieldName := range oldFields { | |
| 		if _, exists := newFields[fieldName]; !exists { | |
| 			result.Compatible = false | |
| 			result.Issues = append(result.Issues, | |
| 				fmt.Sprintf("Field '%s' was removed, breaking forward compatibility", fieldName)) | |
| 		} | |
| 	} | |
| } | |
| 
 | |
| // extractAvroFields extracts field information from an Avro schema | |
| func (checker *SchemaEvolutionChecker) extractAvroFields(schema map[string]interface{}) map[string]map[string]interface{} { | |
| 	fields := make(map[string]map[string]interface{}) | |
| 
 | |
| 	if fieldsArray, ok := schema["fields"].([]interface{}); ok { | |
| 		for _, fieldInterface := range fieldsArray { | |
| 			if field, ok := fieldInterface.(map[string]interface{}); ok { | |
| 				if name, ok := field["name"].(string); ok { | |
| 					fields[name] = field | |
| 				} | |
| 			} | |
| 		} | |
| 	} | |
| 
 | |
| 	return fields | |
| } | |
| 
 | |
| // areAvroTypesCompatible checks if two Avro types are compatible | |
| func (checker *SchemaEvolutionChecker) areAvroTypesCompatible(oldType, newType interface{}, backward bool) bool { | |
| 	// Simplified type compatibility check | |
| 	// In a full implementation, this would handle complex types, unions, etc. | |
|  | |
| 	oldTypeStr := fmt.Sprintf("%v", oldType) | |
| 	newTypeStr := fmt.Sprintf("%v", newType) | |
| 
 | |
| 	// Same type is always compatible | |
| 	if oldTypeStr == newTypeStr { | |
| 		return true | |
| 	} | |
| 
 | |
| 	// Check for promotable types (e.g., int -> long, float -> double) | |
| 	if backward { | |
| 		return checker.isPromotableType(oldTypeStr, newTypeStr) | |
| 	} else { | |
| 		return checker.isPromotableType(newTypeStr, oldTypeStr) | |
| 	} | |
| } | |
| 
 | |
| // isPromotableType checks if a type can be promoted to another | |
| func (checker *SchemaEvolutionChecker) isPromotableType(from, to string) bool { | |
| 	promotions := map[string][]string{ | |
| 		"int":    {"long", "float", "double"}, | |
| 		"long":   {"float", "double"}, | |
| 		"float":  {"double"}, | |
| 		"string": {"bytes"}, | |
| 		"bytes":  {"string"}, | |
| 	} | |
| 
 | |
| 	if validPromotions, exists := promotions[from]; exists { | |
| 		for _, validTo := range validPromotions { | |
| 			if to == validTo { | |
| 				return true | |
| 			} | |
| 		} | |
| 	} | |
| 
 | |
| 	return false | |
| } | |
| 
 | |
| // validateAvroDataCompatibility validates compatibility by testing with actual data | |
| func (checker *SchemaEvolutionChecker) validateAvroDataCompatibility( | |
| 	oldSchema, newSchema *goavro.Codec, | |
| 	level CompatibilityLevel, | |
| ) error { | |
| 	// Create test data with old schema | |
| 	testData := map[string]interface{}{ | |
| 		"test_field": "test_value", | |
| 	} | |
| 
 | |
| 	// Try to encode with old schema | |
| 	encoded, err := oldSchema.BinaryFromNative(nil, testData) | |
| 	if err != nil { | |
| 		// If we can't create test data, skip validation | |
| 		return nil | |
| 	} | |
| 
 | |
| 	// Try to decode with new schema (backward compatibility) | |
| 	if level == CompatibilityBackward || level == CompatibilityFull { | |
| 		_, _, err := newSchema.NativeFromBinary(encoded) | |
| 		if err != nil { | |
| 			return fmt.Errorf("backward compatibility failed: %w", err) | |
| 		} | |
| 	} | |
| 
 | |
| 	// Try to encode with new schema and decode with old (forward compatibility) | |
| 	if level == CompatibilityForward || level == CompatibilityFull { | |
| 		newEncoded, err := newSchema.BinaryFromNative(nil, testData) | |
| 		if err == nil { | |
| 			_, _, err = oldSchema.NativeFromBinary(newEncoded) | |
| 			if err != nil { | |
| 				return fmt.Errorf("forward compatibility failed: %w", err) | |
| 			} | |
| 		} | |
| 	} | |
| 
 | |
| 	return nil | |
| } | |
| 
 | |
| // checkProtobufCompatibility checks Protobuf schema compatibility | |
| func (checker *SchemaEvolutionChecker) checkProtobufCompatibility( | |
| 	oldSchemaStr, newSchemaStr string, | |
| 	level CompatibilityLevel, | |
| ) (*CompatibilityResult, error) { | |
| 
 | |
| 	result := &CompatibilityResult{ | |
| 		Compatible: true, | |
| 		Issues:     []string{}, | |
| 		Level:      level, | |
| 	} | |
| 
 | |
| 	// For now, implement basic Protobuf compatibility rules | |
| 	// In a full implementation, this would parse .proto files and check field numbers, types, etc. | |
|  | |
| 	// Basic check: if schemas are identical, they're compatible | |
| 	if oldSchemaStr == newSchemaStr { | |
| 		return result, nil | |
| 	} | |
| 
 | |
| 	// For protobuf, we need to parse the schema and check: | |
| 	// - Field numbers haven't changed | |
| 	// - Required fields haven't been removed | |
| 	// - Field types are compatible | |
|  | |
| 	// Simplified implementation - mark as compatible with warning | |
| 	result.Issues = append(result.Issues, "Protobuf compatibility checking is simplified - manual review recommended") | |
| 
 | |
| 	return result, nil | |
| } | |
| 
 | |
| // checkJSONSchemaCompatibility checks JSON Schema compatibility | |
| func (checker *SchemaEvolutionChecker) checkJSONSchemaCompatibility( | |
| 	oldSchemaStr, newSchemaStr string, | |
| 	level CompatibilityLevel, | |
| ) (*CompatibilityResult, error) { | |
| 
 | |
| 	result := &CompatibilityResult{ | |
| 		Compatible: true, | |
| 		Issues:     []string{}, | |
| 		Level:      level, | |
| 	} | |
| 
 | |
| 	// Parse JSON schemas | |
| 	var oldSchema, newSchema map[string]interface{} | |
| 	if err := json.Unmarshal([]byte(oldSchemaStr), &oldSchema); err != nil { | |
| 		return nil, fmt.Errorf("failed to parse old JSON schema: %w", err) | |
| 	} | |
| 	if err := json.Unmarshal([]byte(newSchemaStr), &newSchema); err != nil { | |
| 		return nil, fmt.Errorf("failed to parse new JSON schema: %w", err) | |
| 	} | |
| 
 | |
| 	// Check compatibility based on level | |
| 	switch level { | |
| 	case CompatibilityBackward: | |
| 		checker.checkJSONSchemaBackwardCompatibility(oldSchema, newSchema, result) | |
| 	case CompatibilityForward: | |
| 		checker.checkJSONSchemaForwardCompatibility(oldSchema, newSchema, result) | |
| 	case CompatibilityFull: | |
| 		checker.checkJSONSchemaBackwardCompatibility(oldSchema, newSchema, result) | |
| 		if result.Compatible { | |
| 			checker.checkJSONSchemaForwardCompatibility(oldSchema, newSchema, result) | |
| 		} | |
| 	} | |
| 
 | |
| 	return result, nil | |
| } | |
| 
 | |
| // checkJSONSchemaBackwardCompatibility checks JSON Schema backward compatibility | |
| func (checker *SchemaEvolutionChecker) checkJSONSchemaBackwardCompatibility( | |
| 	oldSchema, newSchema map[string]interface{}, | |
| 	result *CompatibilityResult, | |
| ) { | |
| 	// Check if required fields were added | |
| 	oldRequired := checker.extractJSONSchemaRequired(oldSchema) | |
| 	newRequired := checker.extractJSONSchemaRequired(newSchema) | |
| 
 | |
| 	for _, field := range newRequired { | |
| 		if !contains(oldRequired, field) { | |
| 			result.Compatible = false | |
| 			result.Issues = append(result.Issues, | |
| 				fmt.Sprintf("New required field '%s' breaks backward compatibility", field)) | |
| 		} | |
| 	} | |
| 
 | |
| 	// Check if properties were removed | |
| 	oldProperties := checker.extractJSONSchemaProperties(oldSchema) | |
| 	newProperties := checker.extractJSONSchemaProperties(newSchema) | |
| 
 | |
| 	for propName := range oldProperties { | |
| 		if _, exists := newProperties[propName]; !exists { | |
| 			result.Compatible = false | |
| 			result.Issues = append(result.Issues, | |
| 				fmt.Sprintf("Property '%s' was removed, breaking backward compatibility", propName)) | |
| 		} | |
| 	} | |
| } | |
| 
 | |
| // checkJSONSchemaForwardCompatibility checks JSON Schema forward compatibility | |
| func (checker *SchemaEvolutionChecker) checkJSONSchemaForwardCompatibility( | |
| 	oldSchema, newSchema map[string]interface{}, | |
| 	result *CompatibilityResult, | |
| ) { | |
| 	// Check if required fields were removed | |
| 	oldRequired := checker.extractJSONSchemaRequired(oldSchema) | |
| 	newRequired := checker.extractJSONSchemaRequired(newSchema) | |
| 
 | |
| 	for _, field := range oldRequired { | |
| 		if !contains(newRequired, field) { | |
| 			result.Compatible = false | |
| 			result.Issues = append(result.Issues, | |
| 				fmt.Sprintf("Required field '%s' was removed, breaking forward compatibility", field)) | |
| 		} | |
| 	} | |
| 
 | |
| 	// Check if properties were added | |
| 	oldProperties := checker.extractJSONSchemaProperties(oldSchema) | |
| 	newProperties := checker.extractJSONSchemaProperties(newSchema) | |
| 
 | |
| 	for propName := range newProperties { | |
| 		if _, exists := oldProperties[propName]; !exists { | |
| 			result.Issues = append(result.Issues, | |
| 				fmt.Sprintf("New property '%s' added - ensure old schema can handle it", propName)) | |
| 		} | |
| 	} | |
| } | |
| 
 | |
| // extractJSONSchemaRequired extracts required fields from JSON Schema | |
| func (checker *SchemaEvolutionChecker) extractJSONSchemaRequired(schema map[string]interface{}) []string { | |
| 	if required, ok := schema["required"].([]interface{}); ok { | |
| 		var fields []string | |
| 		for _, field := range required { | |
| 			if fieldStr, ok := field.(string); ok { | |
| 				fields = append(fields, fieldStr) | |
| 			} | |
| 		} | |
| 		return fields | |
| 	} | |
| 	return []string{} | |
| } | |
| 
 | |
| // extractJSONSchemaProperties extracts properties from JSON Schema | |
| func (checker *SchemaEvolutionChecker) extractJSONSchemaProperties(schema map[string]interface{}) map[string]interface{} { | |
| 	if properties, ok := schema["properties"].(map[string]interface{}); ok { | |
| 		return properties | |
| 	} | |
| 	return make(map[string]interface{}) | |
| } | |
| 
 | |
| // contains checks if a slice contains a string | |
| func contains(slice []string, item string) bool { | |
| 	for _, s := range slice { | |
| 		if s == item { | |
| 			return true | |
| 		} | |
| 	} | |
| 	return false | |
| } | |
| 
 | |
| // GetCompatibilityLevel returns the compatibility level for a subject | |
| func (checker *SchemaEvolutionChecker) GetCompatibilityLevel(subject string) CompatibilityLevel { | |
| 	// In a real implementation, this would query the schema registry | |
| 	// For now, return a default level | |
| 	return CompatibilityBackward | |
| } | |
| 
 | |
| // SetCompatibilityLevel sets the compatibility level for a subject | |
| func (checker *SchemaEvolutionChecker) SetCompatibilityLevel(subject string, level CompatibilityLevel) error { | |
| 	// In a real implementation, this would update the schema registry | |
| 	return nil | |
| } | |
| 
 | |
| // CanEvolve checks if a schema can be evolved according to the compatibility rules | |
| func (checker *SchemaEvolutionChecker) CanEvolve( | |
| 	subject string, | |
| 	currentSchemaStr, newSchemaStr string, | |
| 	format Format, | |
| ) (*CompatibilityResult, error) { | |
| 
 | |
| 	level := checker.GetCompatibilityLevel(subject) | |
| 	return checker.CheckCompatibility(currentSchemaStr, newSchemaStr, format, level) | |
| } | |
| 
 | |
| // SuggestEvolution suggests how to evolve a schema to maintain compatibility | |
| func (checker *SchemaEvolutionChecker) SuggestEvolution( | |
| 	oldSchemaStr, newSchemaStr string, | |
| 	format Format, | |
| 	level CompatibilityLevel, | |
| ) ([]string, error) { | |
| 
 | |
| 	suggestions := []string{} | |
| 
 | |
| 	result, err := checker.CheckCompatibility(oldSchemaStr, newSchemaStr, format, level) | |
| 	if err != nil { | |
| 		return nil, err | |
| 	} | |
| 
 | |
| 	if result.Compatible { | |
| 		suggestions = append(suggestions, "Schema evolution is compatible") | |
| 		return suggestions, nil | |
| 	} | |
| 
 | |
| 	// Analyze issues and provide suggestions | |
| 	for _, issue := range result.Issues { | |
| 		if strings.Contains(issue, "required field") && strings.Contains(issue, "added") { | |
| 			suggestions = append(suggestions, "Add default values to new required fields") | |
| 		} | |
| 		if strings.Contains(issue, "removed") { | |
| 			suggestions = append(suggestions, "Consider deprecating fields instead of removing them") | |
| 		} | |
| 		if strings.Contains(issue, "type changed") { | |
| 			suggestions = append(suggestions, "Use type promotion or union types for type changes") | |
| 		} | |
| 	} | |
| 
 | |
| 	if len(suggestions) == 0 { | |
| 		suggestions = append(suggestions, "Manual schema review required - compatibility issues detected") | |
| 	} | |
| 
 | |
| 	return suggestions, nil | |
| }
 |