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  }