Compare commits

...

9 Commits

Author SHA1 Message Date
b37121a218 rename: module 2025-03-18 03:45:42 +07:00
ffbeccc4fd feat: delete constraint for belongs to and has one 2025-03-18 03:29:00 +07:00
4536be4d10 feat: more clear output 2025-03-18 03:23:15 +07:00
6e5e676e84 fix: logic 2025-03-18 01:36:42 +07:00
4089f0b084 feat: has one 2025-03-18 00:23:52 +07:00
fefc7a701b fix: invalid relations detect 2025-03-18 00:04:23 +07:00
9d14fa7c57 feat: one-to-many, belongs-to 2025-03-17 23:47:01 +07:00
db9ef0f935 fix: test 2025-03-17 13:15:03 +07:00
60e0a64a24 feat: many-to-one check, constrints check 2025-03-17 13:13:35 +07:00
17 changed files with 405 additions and 45 deletions

29
.idea/watcherTasks.xml generated Normal file
View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="true">
<option name="arguments" value="run --disable=typecheck $FileDir$" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="go" />
<option name="immediateSync" value="false" />
<option name="name" value="golangci-lint" />
<option name="output" value="" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="golangci-lint" />
<option name="runOnExternalChanges" value="false" />
<option name="scopeName" value="Project Files" />
<option name="trackOnlyRoot" value="true" />
<option name="workingDir" value="$ProjectFileDir$" />
<envs>
<env name="GOROOT" value="$GOROOT$" />
<env name="GOPATH" value="$GOPATH$" />
<env name="PATH" value="$GoBinDirs$" />
</envs>
</TaskOptions>
</component>
</project>

BIN
cmd/gormlint/gormlint Executable file

Binary file not shown.

View File

@@ -1,9 +1,9 @@
package main package main
import ( import (
"github.com/kuzgoga/gormlint/nullSafetyCheck"
"github.com/kuzgoga/gormlint/relationsCheck"
"golang.org/x/tools/go/analysis/multichecker" "golang.org/x/tools/go/analysis/multichecker"
"gormlint/nullSafetyCheck"
"gormlint/relationsCheck"
) )
func main() { func main() {

View File

@@ -8,3 +8,21 @@ func (model *Model) HasPrimaryKey() bool {
} }
return false return false
} }
func (model *Model) HasField(name string) bool {
for _, field := range model.Fields {
if field.Name == name {
return true
}
}
return false
}
func (model *Model) PrimaryKey() bool {
for _, field := range model.Fields {
if field.Tags.HasOption("primaryKey") {
return true
}
}
return false
}

View File

@@ -64,6 +64,11 @@ func ParseModels(pass *analysis.Pass, models *map[string]Model) {
model.Fields[structField.Name] = structField model.Fields[structField.Name] = structField
(*models)[model.Name] = model (*models)[model.Name] = model
} }
if _, exist := model.Fields["Id"]; !exist {
pass.Reportf(model.Pos, "Id field should be presented model \"%s\"", model.Name)
}
return false return false
}) })
} }

2
go.mod
View File

@@ -1,4 +1,4 @@
module gormlint module github.com/kuzgoga/gormlint
go 1.23.2 go 1.23.2

View File

@@ -1,9 +1,9 @@
package nullSafetyCheck package nullSafetyCheck
import ( import (
"github.com/kuzgoga/gormlint/common"
"go/ast" "go/ast"
"golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis"
"gormlint/common"
"strings" "strings"
) )

View File

@@ -0,0 +1,19 @@
package relationsCheck
import (
"github.com/kuzgoga/gormlint/common"
)
func IsBelongsTo(field common.Field, model common.Model, relatedModel common.Model) bool {
references := field.Tags.GetParamOr("references", "Id")
foreignKey := field.Tags.GetParamOr("foreignKey", field.Name+"Id")
if !model.HasField(foreignKey) {
return false
}
if !relatedModel.HasField(references) {
return false
}
return true
}

View File

@@ -0,0 +1,18 @@
package relationsCheck
import "github.com/kuzgoga/gormlint/common"
func IsHasOne(field common.Field, model common.Model, relatedModel common.Model) bool {
foreignKey := field.Tags.GetParamOr("foreignKey", model.Name+"Id")
references := field.Tags.GetParamOr("references", "Id")
if !relatedModel.HasField(foreignKey) {
return false
}
if !model.HasField(references) {
return false
}
return true
}

View File

@@ -0,0 +1,70 @@
package relationsCheck
import (
"github.com/kuzgoga/gormlint/common"
"go/token"
"go/types"
"golang.org/x/tools/go/analysis"
"strings"
)
var alreadyReported = make(map[token.Pos]bool)
func checkManyToOne(pass *analysis.Pass, nestedField common.Field, model common.Model, relatedModel common.Model) bool {
/* Return true, if found problems */
foreignKey := nestedField.Tags.GetParamOr("foreignKey", model.Name+"Id")
references := nestedField.Tags.GetParamOr("references", "Id")
if alreadyReported[nestedField.Pos] {
return true
}
if !relatedModel.HasField(foreignKey) {
pass.Reportf(
nestedField.Pos,
"Expected foreignKey `%s` in model `%s` for 1:M relation with model `%s`",
foreignKey,
relatedModel.Name,
model.Name,
)
alreadyReported[nestedField.Pos] = true
return true
}
if !model.HasField(references) {
pass.Reportf(
nestedField.Pos,
"Expected references `%s` in model `%s` for 1:M relation with model `%s`",
references,
model.Name,
relatedModel.Name,
)
alreadyReported[nestedField.Pos] = true
return true
}
foreignKeyField := relatedModel.Fields[foreignKey]
referencesField := model.Fields[references]
foreignKeyType := types.ExprString(foreignKeyField.Type)
referencesType := types.ExprString(referencesField.Type)
if alreadyReported[foreignKeyField.Pos] || alreadyReported[referencesField.Pos] {
return true
}
if !strings.Contains(foreignKeyType, "int") && !alreadyReported[foreignKeyField.Pos] {
// TODO: process UUID as foreign key type
pass.Reportf(foreignKeyField.Pos, "Foreign key `%s` has invalid type", foreignKeyType)
alreadyReported[foreignKeyField.Pos] = true
return true
}
if !strings.Contains(referencesType, "int") && !alreadyReported[referencesField.Pos] {
pass.Reportf(referencesField.Pos, "References key `%s` has invalid type", referencesType)
alreadyReported[referencesField.Pos] = true
return true
}
return false
}

View File

@@ -0,0 +1,42 @@
package relationsCheck
import (
"fmt"
"github.com/kuzgoga/gormlint/common"
"go/types"
"golang.org/x/tools/go/analysis"
)
func findBackReferenceInOneToMany(model common.Model, relatedModel common.Model) *common.Field {
for _, field := range relatedModel.Fields {
if !common.IsSlice(field.Type) {
continue
}
if field.Tags.HasParam("many2many") {
continue
}
baseType := common.ResolveBaseType(field.Type)
if baseType == nil {
continue
}
if *baseType == model.Name {
return &field
}
}
return nil
}
func isOneToMany(pass *analysis.Pass, model common.Model, relatedModel common.Model) bool {
backReference := findBackReferenceInOneToMany(model, relatedModel)
if backReference == nil {
return false
}
fmt.Println("Found back reference")
fmt.Printf("Backref type: %s\n", types.ExprString(backReference.Type))
fmt.Printf("Model: %s\n", model.Name)
fmt.Printf("Related model: %s\n", relatedModel.Name)
if checkManyToOne(pass, *backReference, relatedModel, model) {
return false
}
return true
}

View File

@@ -0,0 +1,29 @@
package relationsCheck
import (
"github.com/kuzgoga/gormlint/common"
"golang.org/x/tools/go/analysis"
"strings"
)
func CheckCascadeDelete(pass *analysis.Pass, field common.Field) bool {
if !field.Tags.HasParam("constraint") {
pass.Reportf(field.Pos, "field %s should have a delete constraint", field.Name)
return true
}
constraintValue := field.Tags.GetParam("constraint").Value
pair := strings.Split(constraintValue, ":")
trigger, value := pair[0], pair[1]
if strings.ToLower(trigger) == "OnDelete" && strings.ToUpper(value) != "CASCADE" {
pass.Reportf(field.Pos, "field have invalid constraint on `OnDelete trigger`")
return true
}
if field.Tags.HasParam("OnDelete") {
if strings.ToUpper(field.Tags.GetParam("OnDelete").Value) != "CASCADE" {
pass.Reportf(field.Pos, "field have invalid constraint on `OnDelete` trigger")
return true
}
}
return false
}

View File

@@ -2,8 +2,8 @@ package relationsCheck
import ( import (
"fmt" "fmt"
"github.com/kuzgoga/gormlint/common"
"golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis"
"gormlint/common"
"slices" "slices"
) )
@@ -14,76 +14,126 @@ var RelationsAnalyzer = &analysis.Analyzer{
Run: run, Run: run,
} }
func CheckTypesOfM2M(pass *analysis.Pass, modelName string, relatedModelName string, relationName string, reference common.Field, backReference common.Field) { func CheckTypesOfM2M(pass *analysis.Pass, modelName string, relatedModelName string, relationName string, reference common.Field, backReference common.Field) bool {
if !common.IsSlice(reference.Type) { if !common.IsSlice(reference.Type) {
pass.Reportf(reference.Pos, "M2M relation `%s` with bad type `%s` (should be a slice)", relationName, reference.Type) pass.Reportf(reference.Pos, "M2M relation `%s` with bad type `%s` (should be a slice)", relationName, reference.Type)
return return true
} }
if !common.IsSlice(backReference.Type) { if !common.IsSlice(backReference.Type) {
pass.Reportf(backReference.Pos, "M2M relation `%s` with bad type `%s` (should be a slice)", relationName, backReference.Type) pass.Reportf(backReference.Pos, "M2M relation `%s` with bad type `%s` (should be a slice)", relationName, backReference.Type)
return return true
} }
referenceBaseType := common.ResolveBaseType(reference.Type) referenceBaseType := common.ResolveBaseType(reference.Type)
if referenceBaseType == nil { if referenceBaseType == nil {
pass.Reportf(reference.Pos, "Failed to resolve field type: `%s`", reference.Type) pass.Reportf(reference.Pos, "Failed to resolve field type: `%s`", reference.Type)
return return true
} }
backReferenceBaseType := common.ResolveBaseType(backReference.Type) backReferenceBaseType := common.ResolveBaseType(backReference.Type)
if backReferenceBaseType == nil { if backReferenceBaseType == nil {
pass.Reportf(reference.Pos, "Failed to resolve type: `%s`", reference.Type) pass.Reportf(reference.Pos, "Failed to resolve type: `%s`", reference.Type)
return return true
} }
if *backReferenceBaseType != modelName { if *backReferenceBaseType != modelName {
pass.Reportf(backReference.Pos, "Invalid type `%s` in M2M relation (use []*%s or self-reference)", *backReferenceBaseType, modelName) pass.Reportf(backReference.Pos, "Invalid type `%s` in M2M relation (use []*%s or self-reference)", *backReferenceBaseType, modelName)
return return true
} }
if *referenceBaseType != relatedModelName { if *referenceBaseType != relatedModelName {
pass.Reportf(reference.Pos, "Invalid type `%s` in M2M relation (use []*%s or self-reference)", *referenceBaseType, relatedModelName) pass.Reportf(reference.Pos, "Invalid type `%s` in M2M relation (use []*%s or self-reference)", *referenceBaseType, relatedModelName)
return true
} }
return false
} }
func CheckMany2Many(pass *analysis.Pass, models map[string]common.Model) { func CheckMany2Many(pass *analysis.Pass, models map[string]common.Model) {
// TODO: unexpected duplicated relations var processedRelations []string
var knownModels []string
for _, model := range models { for _, model := range models {
for _, field := range model.Fields { for _, field := range model.Fields {
m2mRelation := field.Tags.GetParam("many2many") m2mRelation := field.Tags.GetParam("many2many")
if m2mRelation != nil { if m2mRelation != nil {
if slices.Contains(processedRelations, m2mRelation.Value) {
continue
}
processedRelations = append(processedRelations, m2mRelation.Value)
relatedModel := common.GetModelFromType(field.Type, models) relatedModel := common.GetModelFromType(field.Type, models)
if relatedModel == nil { if relatedModel == nil {
pass.Reportf(field.Pos, "Failed to resolve related model type") pass.Reportf(field.Pos, "Failed to resolve related model type")
return continue
} }
backReference := common.FindBackReferenceInM2M(m2mRelation.Value, *relatedModel) backReference := common.FindBackReferenceInM2M(m2mRelation.Value, *relatedModel)
if backReference != nil { if backReference != nil {
if slices.Contains(knownModels, relatedModel.Name) { if CheckTypesOfM2M(pass, model.Name, relatedModel.Name, m2mRelation.Value, field, *backReference) {
continue
}
// Проверка каскадного удаления и других параметров
if CheckCascadeDelete(pass, field) {
continue continue
} else {
knownModels = append(knownModels, model.Name)
knownModels = append(knownModels, relatedModel.Name)
} }
CheckTypesOfM2M(pass, model.Name, relatedModel.Name, m2mRelation.Value, field, *backReference)
// TODO: check foreign key and references
fmt.Printf("Found M2M relation between \"%s\" and \"%s\"\n", model.Name, relatedModel.Name)
} else { } else {
// Check self-reference // Обработка самоссылки
if model.Name == relatedModel.Name { if model.Name == relatedModel.Name {
CheckTypesOfM2M(pass, model.Name, relatedModel.Name, m2mRelation.Value, field, field) if CheckTypesOfM2M(pass, model.Name, relatedModel.Name, m2mRelation.Value, field, field) {
} else { continue
if !relatedModel.HasPrimaryKey() {
fmt.Printf("%#v\n", relatedModel)
pass.Reportf(field.Pos, "Can't build M2M relation `%s`, primary key on `%s` model is absont", m2mRelation.Value, relatedModel.Name)
} }
} else {
pass.Reportf(field.Pos, "M2M relation `%s` missing back-reference in model `%s`", m2mRelation.Value, relatedModel.Name)
}
if CheckCascadeDelete(pass, field) {
continue
} }
// Here you can forbid M2M relations without back-reference
// TODO: process m2m without backref
} }
} else { }
// TODO: check [] and process m:1 }
}
}
func CheckOneToMany(pass *analysis.Pass, models map[string]common.Model) {
for _, model := range models {
for _, field := range model.Fields {
if common.IsSlice(field.Type) {
continue
}
if field.Tags.HasParam("many2many") {
continue
}
baseType := common.ResolveBaseType(field.Type)
if baseType == nil {
pass.Reportf(field.Pos, "Failed to resolve field base type: `%s`", field.Type)
continue
}
relatedModel := common.GetModelFromType(field.Type, models)
if relatedModel == nil {
continue
}
foundOneToMany := isOneToMany(pass, model, *relatedModel)
if foundOneToMany {
fmt.Printf("Found 1:M relation in model `%s` with model `%s`\n", model.Name, *baseType)
}
foundBelongsTo := IsBelongsTo(field, model, *relatedModel)
hasOne := IsHasOne(field, model, *relatedModel)
if !foundOneToMany {
if foundBelongsTo {
fmt.Printf("`%s` belongs `%s`\n", *baseType, model.Name)
if CheckCascadeDelete(pass, field) {
return
}
} else if hasOne {
fmt.Printf("`%s` has one `%s` \n", model.Name, relatedModel.Name)
if CheckCascadeDelete(pass, field) {
return
}
} else {
pass.Reportf(field.Pos, "Invalid relation in field `%s`", field.Name)
}
} }
} }
} }
@@ -93,6 +143,6 @@ func run(pass *analysis.Pass) (any, error) {
models := make(map[string]common.Model) models := make(map[string]common.Model)
common.ParseModels(pass, &models) common.ParseModels(pass, &models)
CheckMany2Many(pass, models) CheckMany2Many(pass, models)
CheckOneToMany(pass, models)
return nil, nil return nil, nil
} }

View File

@@ -1,12 +1,11 @@
package tests package tests
import ( import (
"github.com/kuzgoga/gormlint/nullSafetyCheck"
"golang.org/x/tools/go/analysis/analysistest" "golang.org/x/tools/go/analysis/analysistest"
"gormlint/nullSafetyCheck"
"testing" "testing"
) )
func TestNullSafety(t *testing.T) { func TestNullSafety(t *testing.T) {
t.Parallel()
analysistest.Run(t, analysistest.TestData(), nullSafetyCheck.NullSafetyAnalyzer, "null_safety") analysistest.Run(t, analysistest.TestData(), nullSafetyCheck.NullSafetyAnalyzer, "null_safety")
} }

View File

@@ -1,12 +1,11 @@
package tests package tests
import ( import (
"github.com/kuzgoga/gormlint/relationsCheck"
"golang.org/x/tools/go/analysis/analysistest" "golang.org/x/tools/go/analysis/analysistest"
"gormlint/relationsCheck"
"testing" "testing"
) )
func TestRelationsCheck(t *testing.T) { func TestRelationsCheck(t *testing.T) {
t.Parallel()
analysistest.Run(t, analysistest.TestData(), relationsCheck.RelationsAnalyzer, "relations_check") analysistest.Run(t, analysistest.TestData(), relationsCheck.RelationsAnalyzer, "relations_check")
} }

View File

@@ -4,25 +4,74 @@ package relations_check
type Library struct { type Library struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Books []*Book `gorm:"many2many:library_book;"` Books []*Book `gorm:"many2many:library_book;constraint:OnDelete:CASCADE;"`
} }
type Book struct { type Book struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Libraries []*Library `gorm:"many2many:library_book;"` Libraries []*Library `gorm:"many2many:library_book;constraint:OnDelete:CASCADE;"`
} }
type Employee struct { type Employee struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Subordinates []*Employee `gorm:"many2many:employee_subordinates;"` // self-reference Subordinates []*Employee `gorm:"many2many:employee_subordinates;constraint:OnDelete:CASCADE;"` // self-reference
} }
type Publisher struct { type Publisher struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Writers []*Writer `gorm:"many2many:publisher_books;"` Writers []*Writer `gorm:"many2many:publisher_books;constraint:OnDelete:CASCADE;"`
} }
type Writer struct { type Writer struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Publishers []Publisher `gorm:"many2many:publisher_books;"` Publishers []Publisher `gorm:"many2many:publisher_books;constraint:OnDelete:CASCADE;"`
}
// One-to-many
type Comment struct {
Id uint `gorm:"primaryKey"`
CommentatorId uint
Commentator Commentator
}
type Commentator struct {
Id uint `gorm:"primaryKey"`
Comments []Comment `gorm:"foreignKey:CommentatorId;references:Id;constraint:OnDelete:CASCADE;"`
}
type Post struct {
Id uint `gorm:"primaryKey"`
Files []*File `gorm:"constraint:OnDelete:CASCADE;"`
}
type File struct {
Id uint `gorm:"primaryKey"`
PostId uint
Post Post
}
type Consumer struct {
Id uint `gorm:"primaryKey"`
Name string
ShoppingCart ShoppingCart // want "Invalid relation in field `ShoppingCart`"
}
type ShoppingCart struct {
Id uint `gorm:"primaryKey"`
SerializedItems string
}
// Has one
type Hotel struct {
Id uint `gorm:"primaryKey"`
Office // want "field Office should have a delete constraint"
}
type Office struct {
Id uint `gorm:"primaryKey"`
Name string
Address string
HotelId uint
} }

View File

@@ -2,20 +2,53 @@ package relations_check
type Student struct { type Student struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Courses []Course `gorm:"many2many:student_courses;"` Courses []Course `gorm:"many2many:student_courses;constraint:OnDelete:CASCADE;"`
} }
type Course struct { type Course struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Students []Course `gorm:"many2many:student_courses;"` // want "Invalid type `Course` in M2M relation \\(use \\[\\]\\*Student or self-reference\\)" Students []Course `gorm:"many2many:student_courses;constraint:OnDelete:CASCADE"` // want "Invalid type `Course` in M2M relation \\(use \\[\\]\\*Student or self-reference\\)"
} }
type Author struct { type Author struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Articles []Article `gorm:"many2many:author_articles;"` Articles []Article `gorm:"many2many:author_articles;constraint:OnDelete:CASCADES;"`
} }
type Article struct { type Article struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Authors Author `gorm:"many2many:author_articles;"` // want "M2M relation `author_articles` with bad type `Author` \\(should be a slice\\)" Authors Author `gorm:"many2many:author_articles;constraint:OnDelete:CASCADE;"` // want "M2M relation `author_articles` with bad type `Author` \\(should be a slice\\)"
}
type Kuzbass struct {
Id uint `gorm:"primaryKey"`
Cities []City // want "Expected foreignKey `KuzbassId` in model `City` for 1:M relation with model `Kuzbass`"
}
type City struct {
Id uint `gorm:"primaryKey"`
Kuzbass Kuzbass // want "Invalid relation in field `Kuzbass`"
}
type Federation struct { // want "Id field should be presented model \"Federation\""
Lands []Land `gorm:"constraint:OnDelete:CASCADE;"`
}
type Land struct {
Id uint `gorm:"primaryKey"`
FederationId uint
}
// Belongs to
type Owner struct {
Id uint `gorm:"primaryKey"`
Name string
CompanyId int
Company Company `gorm:"constraint:OnDelete:CASCADE;"`
}
type Company struct {
Id int
Name string
} }