commit b32eafb43d94104db73cac7066b3ecb9713974cb Author: GogaCoder Date: Sun Dec 29 16:35:38 2024 +0700 feat: null safety diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/gormlint.iml b/.idea/gormlint.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/gormlint.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..fcfb4ea --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..52adf57 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..c8980ef --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/cmd/gormlint/main.go b/cmd/gormlint/main.go new file mode 100644 index 0000000..ec6dfcc --- /dev/null +++ b/cmd/gormlint/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "golang.org/x/tools/go/analysis/singlechecker" + "gormlint/nullSafetyCheck" +) + +func main() { + singlechecker.Main(nullSafetyCheck.NullSafetyAnalyzer) +} diff --git a/common/nullSafetyCheck.go b/common/nullSafetyCheck.go new file mode 100644 index 0000000..ae9516f --- /dev/null +++ b/common/nullSafetyCheck.go @@ -0,0 +1,68 @@ +package common + +import ( + "errors" + "github.com/fatih/structtag" + "go/ast" + "golang.org/x/tools/go/analysis" +) + +func isPointerType(typeExpr ast.Expr) bool { + isPointer := false + if _, ok := typeExpr.(*ast.StarExpr); ok { + isPointer = true + } + return isPointer +} + +func isGormValueNullable(tags *structtag.Tags) (*bool, error) { + gormTag, err := tags.Get("gorm") + if gormTag == nil { + return nil, nil + } + + gormTag.Options = append([]string{gormTag.Name}, gormTag.Options...) + + if err != nil { + return nil, nil + } + + nullTagExist := gormTag.HasOption("null") + notNullTagExist := gormTag.HasOption("not null") + + if nullTagExist == notNullTagExist && nullTagExist == true { + return nil, errors.New(`tags "null" and "not null" are specified at one field`) + } + + if nullTagExist { + return PointerOf(true), nil + } else if notNullTagExist { + return PointerOf(false), nil + } else { + return PointerOf(false), nil + } +} + +func CheckFieldNullConsistency(pass analysis.Pass, field ast.Field, structName string, structTags string) { + tags, err := structtag.Parse(structTags) + if err != nil { + pass.Reportf(field.Pos(), "Invalid structure tag: %s", err) + } + if tags == nil { + return + } + + isFieldNullable := isPointerType(field.Type) + isColumnNullable, err := isGormValueNullable(tags) + + if err != nil { + pass.Reportf(field.Pos(), "Null safety error: %s", err) + } + if isColumnNullable == nil { + return + } + + if isFieldNullable != *isColumnNullable { + pass.Reportf(field.Pos(), "Null safety error in \"%s\" model, field \"%s\": column nullable policy doesn't match to tag nullable policy", structName, field.Names[0].Name) + } +} diff --git a/common/pointerOf.go b/common/pointerOf.go new file mode 100644 index 0000000..fe67e5c --- /dev/null +++ b/common/pointerOf.go @@ -0,0 +1,5 @@ +package common + +func PointerOf[T any](v T) *T { + return &v +} diff --git a/common/unnamedChecks.go b/common/unnamedChecks.go new file mode 100644 index 0000000..7c63ee7 --- /dev/null +++ b/common/unnamedChecks.go @@ -0,0 +1,18 @@ +package common + +import ( + "go/ast" + "golang.org/x/tools/go/analysis" +) + +func CheckUnnamedModel(pass analysis.Pass, typeSpec ast.TypeSpec) { + if typeSpec.Name == nil { + pass.Reportf(typeSpec.Pos(), "Unnamed model\n") + } +} + +func CheckUnnamedField(pass analysis.Pass, structName string, field ast.Field) { + if len(field.Names) == 0 { + pass.Reportf(field.Pos(), "Struct \"%s\" has unnamed field", structName) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..32917e9 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module gormlint + +go 1.23.2 + +require golang.org/x/tools v0.28.0 + +require ( + github.com/fatih/color v1.18.0 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/mod v0.22.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ae0c01c --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= diff --git a/models/testdata.go b/models/testdata.go new file mode 100644 index 0000000..da4b465 --- /dev/null +++ b/models/testdata.go @@ -0,0 +1,26 @@ +package models + +type Order struct { + Id uint `gorm:"primaryKey"` + Status string + ProductTypeId uint + ProductType ProductType + ProductAmount uint + Description string + CustomerId uint `gorm:"null;foreignKey:CustomerId;"` + Customer Customer + CreatedAt int64 `gorm:"autoCreateTime"` + DeadlineDate int64 +} + +type ProductType struct { + Id uint `gorm:"primaryKey"` + Name string +} + +type Customer struct { + Id uint `gorm:"primaryKey"` + Title string + Contact string + Orders []Order `gorm:"foreignKey:CustomerId"` +} diff --git a/nullSafetyCheck/nullSafetyCheck.go b/nullSafetyCheck/nullSafetyCheck.go new file mode 100644 index 0000000..a550ec1 --- /dev/null +++ b/nullSafetyCheck/nullSafetyCheck.go @@ -0,0 +1,44 @@ +package nullSafetyCheck + +import ( + "go/ast" + "golang.org/x/tools/go/analysis" + "gormlint/common" + "strings" +) + +var NullSafetyAnalyzer = &analysis.Analyzer{ + Name: "nullSafety", + Doc: "reports inconsistency of nullable values", + Run: run, +} + +func run(pass *analysis.Pass) (any, error) { + for _, file := range pass.Files { + ast.Inspect(file, func(node ast.Node) bool { + typeSpec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + structure, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return true + } + + common.CheckUnnamedModel(*pass, *typeSpec) + + for _, field := range structure.Fields.List { + common.CheckUnnamedField(*pass, typeSpec.Name.Name, *field) + if field.Tag != nil { + tagWithoutQuotes := field.Tag.Value[1 : len(field.Tag.Value)-1] + tagWithoutSemicolons := strings.ReplaceAll(tagWithoutQuotes, ";", ",") + common.CheckFieldNullConsistency(*pass, *field, typeSpec.Name.Name, tagWithoutSemicolons) + } else { + // TODO: check necessary tags for some fields + } + } + return false + }) + } + return nil, nil +}