github.com/github/skeema@v1.2.6/linter/linter.go (about) 1 // Package linter handles logic around linting schemas and returning results. 2 package linter 3 4 import ( 5 "fmt" 6 "regexp" 7 "sort" 8 "strconv" 9 "strings" 10 11 "github.com/skeema/skeema/fs" 12 "github.com/skeema/skeema/workspace" 13 "github.com/skeema/tengo" 14 ) 15 16 // Annotation is an error, warning, or notice from linting a single SQL 17 // statement. 18 type Annotation struct { 19 Statement *fs.Statement 20 LineOffset int 21 Summary string 22 Message string 23 Problem string 24 } 25 26 // MessageWithLocation prepends statement location information to a.Message, 27 // if location information is available. Otherwise, it appends the full SQL 28 // statement that the message refers to. 29 func (a *Annotation) MessageWithLocation() string { 30 if a.Statement.File == "" || a.Statement.LineNo == 0 { 31 return fmt.Sprintf("%s [Full SQL: %s]", a.Message, a.Statement.Text) 32 } 33 return fmt.Sprintf("%s: %s", a.Location(), a.Message) 34 } 35 36 // LineNo returns the line number of the annotation within its file. 37 func (a *Annotation) LineNo() int { 38 return a.Statement.LineNo + a.LineOffset 39 } 40 41 // Location returns information on which file and line caused the Annotation 42 // to be generated. This may include character number also, if available. 43 func (a *Annotation) Location() string { 44 // If the LineOffset is 0 (meaning the offending line of the statement could 45 // not be determined, OR it's the first line of the statement), and/or if the 46 // filename isn't available, just use the Statement's location string as-is 47 if a.LineOffset == 0 || a.Statement.File == "" { 48 return a.Statement.Location() 49 } 50 51 // Otherwise, add the offset to the statement's line number. We exclude the 52 // charno in this case because it is relative to the first line of the 53 // statement, which isn't the line that generated the annotation. 54 return fmt.Sprintf("%s:%d", a.Statement.File, a.LineNo()) 55 } 56 57 // Result is a combined set of linter annotations and/or Golang errors found 58 // when linting a directory and its subdirs. 59 type Result struct { 60 Errors []*Annotation // "Errors" in the linting sense, not in the Golang sense 61 Warnings []*Annotation 62 FormatNotices []*Annotation 63 DebugLogs []string 64 Exceptions []error 65 Schemas map[string]*tengo.Schema // Keyed by dir path and optionally schema name 66 } 67 68 // sortByFile implements the sort.Interface for []*Annotation to get a deterministic 69 // sort order for Annotation lists. 70 // Sorting is ordered by file name, line number, and problem name. 71 type sortByFile []*Annotation 72 73 func (a sortByFile) Len() int { return len(a) } 74 func (a sortByFile) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 75 func (a sortByFile) Less(i, j int) bool { 76 if a[i].Statement.File != a[j].Statement.File { 77 return a[i].Statement.File < a[j].Statement.File 78 } else if a[i].LineNo() != a[j].LineNo() { 79 return a[i].LineNo() < a[j].LineNo() 80 } 81 return a[i].Problem < a[j].Problem 82 } 83 84 // Merge combines other into r's value in-place. 85 func (r *Result) Merge(other *Result) { 86 if r == nil || other == nil { 87 return 88 } 89 r.Errors = append(r.Errors, other.Errors...) 90 r.Warnings = append(r.Warnings, other.Warnings...) 91 r.FormatNotices = append(r.FormatNotices, other.FormatNotices...) 92 r.DebugLogs = append(r.DebugLogs, other.DebugLogs...) 93 r.Exceptions = append(r.Exceptions, other.Exceptions...) 94 if r.Schemas == nil { 95 r.Schemas = make(map[string]*tengo.Schema) 96 } 97 for key, value := range other.Schemas { 98 r.Schemas[key] = value 99 } 100 } 101 102 // SortByFile sorts the error, warning and format notice messages according 103 // to the filenames they appear relate to. 104 func (r *Result) SortByFile() { 105 if r == nil { 106 return 107 } 108 sort.Sort(sortByFile(r.Errors)) 109 sort.Sort(sortByFile(r.Warnings)) 110 sort.Sort(sortByFile(r.FormatNotices)) 111 } 112 113 // BadConfigResult returns a *Result containing a single ConfigError in the 114 // Exceptions field. The supplied err will be converted to a ConfigError if it 115 // is not already one. 116 func BadConfigResult(dir *fs.Dir, err error) *Result { 117 if _, ok := err.(ConfigError); !ok { 118 err = toConfigError(dir, err) 119 } 120 return &Result{ 121 Exceptions: []error{err}, 122 } 123 } 124 125 // LintDir lints all logical schemas in dir, returning a combined result. Does 126 // not recurse into subdirs. 127 func LintDir(dir *fs.Dir, wsOpts workspace.Options) *Result { 128 opts, err := OptionsForDir(dir) 129 if err != nil && len(dir.LogicalSchemas) > 0 { 130 return BadConfigResult(dir, err) 131 } 132 133 result := &Result{} 134 for _, logicalSchema := range dir.LogicalSchemas { 135 // ignore-schema is handled relatively simplistically here: skip dir entirely 136 // if any literal schema name matches the pattern, but don't bother 137 // interpretting schema=`shellout` or schema=*, which require an instance. 138 if opts.IgnoreSchema != nil { 139 var foundIgnoredName bool 140 for _, schemaName := range dir.Config.GetSlice("schema", ',', true) { 141 if opts.IgnoreSchema.MatchString(schemaName) { 142 foundIgnoredName = true 143 } 144 } 145 if foundIgnoredName { 146 result.DebugLogs = append(result.DebugLogs, fmt.Sprintf("Skipping schema in %s because ignore-schema='%s'", dir.RelPath(), opts.IgnoreSchema)) 147 return result 148 } 149 } 150 schema, res := ExecLogicalSchema(logicalSchema, wsOpts, opts) 151 if schema != nil { 152 schemaKey := dir.Path 153 if logicalSchema.Name != "" { 154 schemaKey = fmt.Sprintf("%s:%s", schemaKey, logicalSchema.Name) 155 } 156 res.Schemas = map[string]*tengo.Schema{schemaKey: schema} 157 } 158 result.Merge(res) 159 } 160 161 // Add warning annotations for unparseable statements (unless we hit an 162 // exception, in which case skip it to avoid extra noise!) 163 if len(result.Exceptions) == 0 { 164 for _, stmt := range dir.IgnoredStatements { 165 result.Warnings = append(result.Warnings, &Annotation{ 166 Statement: stmt, 167 Summary: "Unable to parse statement", 168 Message: "Ignoring unsupported or unparseable SQL statement", 169 }) 170 } 171 } 172 173 // Make sure the problem messages have a deterministic order. 174 result.SortByFile() 175 176 return result 177 } 178 179 var reSyntaxErrorLine = regexp.MustCompile(`(?s) the right syntax to use near '.*' at line (\d+)`) 180 181 // ExecLogicalSchema is a wrapper around workspace.ExecLogicalSchema. After the 182 // tengo.Schema is obtained and introspected, it is also linted. Any errors 183 // are captured as part of the *Result. However, the schema itself is not yet 184 // placed into the *Result; this is the caller's responsibility. 185 func ExecLogicalSchema(logicalSchema *fs.LogicalSchema, wsOpts workspace.Options, opts Options) (*tengo.Schema, *Result) { 186 result := &Result{} 187 188 // Convert the logical schema from the filesystem into a real schema, using a 189 // workspace 190 schema, statementErrors, err := workspace.ExecLogicalSchema(logicalSchema, wsOpts) 191 if err != nil { 192 result.Exceptions = append(result.Exceptions, err) 193 return nil, result 194 } 195 for _, stmtErr := range statementErrors { 196 if opts.ShouldIgnore(stmtErr.ObjectKey()) { 197 result.DebugLogs = append(result.DebugLogs, fmt.Sprintf("Skipping %s because ignore-table='%s'", stmtErr.ObjectKey(), opts.IgnoreTable)) 198 continue 199 } 200 a := &Annotation{ 201 Statement: stmtErr.Statement, 202 Summary: "SQL statement returned an error", 203 Message: strings.Replace(stmtErr.Err.Error(), "Error executing DDL in workspace: ", "", 1), 204 } 205 // If the error was a syntax error, attempt to capture the correct line 206 if matches := reSyntaxErrorLine.FindStringSubmatch(a.Message); matches != nil { 207 if lineNumber, _ := strconv.Atoi(matches[1]); lineNumber > 0 { 208 a.LineOffset = lineNumber - 1 // convert from 1-based line number to 0-based offset 209 } 210 } 211 result.Errors = append(result.Errors, a) 212 } 213 214 // It's important to check format prior to checking problems. Otherwise, the 215 // relative line offsets for the problem annotations can be incorrect. 216 // Compare each canonical CREATE in the real schema to each CREATE statement 217 // from the filesystem. In cases where they differ, emit a notice to reformat 218 // the file using the canonical version from the DB. 219 for key, instCreateText := range schema.ObjectDefinitions() { 220 fsStmt := logicalSchema.Creates[key] 221 fsBody, fsSuffix := fsStmt.SplitTextBody() 222 if instCreateText != fsBody { 223 if opts.ShouldIgnore(key) { 224 result.DebugLogs = append(result.DebugLogs, fmt.Sprintf("Skipping %s because ignore-table='%s'", key, opts.IgnoreTable)) 225 } else { 226 fsStmt.Text = fmt.Sprintf("%s%s", instCreateText, fsSuffix) 227 result.FormatNotices = append(result.FormatNotices, &Annotation{ 228 Statement: fsStmt, 229 Summary: "SQL statement should be reformatted", 230 }) 231 } 232 } 233 } 234 235 for problemName, severity := range opts.ProblemSeverity { 236 annotations := problems[problemName](schema, logicalSchema, opts) 237 for _, a := range annotations { 238 a.Problem = problemName 239 if opts.ShouldIgnore(a.Statement.ObjectKey()) { 240 result.DebugLogs = append(result.DebugLogs, fmt.Sprintf("Skipping %s because ignore-table='%s'", a.Statement.ObjectKey(), opts.IgnoreTable)) 241 } else if severity == SeverityWarning { 242 result.Warnings = append(result.Warnings, a) 243 } else { 244 result.Errors = append(result.Errors, a) 245 } 246 } 247 } 248 249 return schema, result 250 }