Implement action as a Go program
This commit is contained in:
128
pkg/checks/check.go
Normal file
128
pkg/checks/check.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package checks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Check represents a database check with a name, SQL statement, and an explanation.
|
||||
type Check struct {
|
||||
Name string
|
||||
Sql string
|
||||
Explanation string
|
||||
}
|
||||
|
||||
// CheckResult represents a row in the query result with error message, table name, and column name.
|
||||
type CheckResult struct {
|
||||
ErrorMsg string `db:"error_msg"`
|
||||
TableName string `db:"table_name"`
|
||||
ColumnName string `db:"column_name"`
|
||||
}
|
||||
|
||||
// Execute runs the SQL statement of the Check against the provided database and returns the
|
||||
// resulting rows as a slice of CheckResult using sqlx.
|
||||
func (c *Check) Execute(db *sqlx.DB) ([]CheckResult, error) {
|
||||
var results []CheckResult
|
||||
// return results, nil
|
||||
// println(c.Sql)
|
||||
if err := db.Select(&results, c.Sql); err != nil {
|
||||
return nil, fmt.Errorf("failed to execute check '%s': %w", c.Name, err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
var Checks = map[string]Check{
|
||||
"require_not_null": {
|
||||
Name: "require_not_null",
|
||||
Sql: `
|
||||
select 'Column should should be "not null"' as error_msg,
|
||||
table_name,
|
||||
column_name
|
||||
from columns
|
||||
where columns."notnull" = 0
|
||||
and fk_target_column is null
|
||||
and is_primary_key = 0 -- primary keys are automatically not-null, but aren't listed as such in pragma_table_info
|
||||
`,
|
||||
Explanation: "All columns should be marked as `not null` unless they are foreign keys. (Primary keys are\n" +
|
||||
"automatically not-null, and don't need to be specified.)",
|
||||
},
|
||||
// {
|
||||
// Name: "require_default_values",
|
||||
// Sql: `
|
||||
// select 'Column should have a default value' as error_msg,
|
||||
// table_name,
|
||||
// column_name
|
||||
// from columns
|
||||
// where dflt_value is null
|
||||
// and fk_target_column is null
|
||||
// and is_primary_key = 0;
|
||||
// `,
|
||||
// Explanation: "All columns should have a default value specified, unless they are foreign keys or primary keys.",
|
||||
// },
|
||||
"require_strict": {
|
||||
Name: "require_strict",
|
||||
Sql: `
|
||||
select 'Table should be marked "strict"' as error_msg,
|
||||
name as table_name,
|
||||
'' as column_name
|
||||
from tables
|
||||
where strict = 0;
|
||||
`,
|
||||
Explanation: "All tables should be marked as `strict` (must specify column types; types must be int,\n" +
|
||||
"integer, real, text, blob or any). This disallows all 'date' and 'time' column types.\n" +
|
||||
"See more: https://www.sqlite.org/stricttables.html",
|
||||
},
|
||||
"forbid_int_type": {
|
||||
Name: "forbid_int_type",
|
||||
Sql: `
|
||||
select 'Column should use "integer" type instead of "int"' as error_msg,
|
||||
table_name,
|
||||
column_name
|
||||
from columns
|
||||
where column_type like 'int';
|
||||
`,
|
||||
Explanation: "All columns should use `integer` type instead of `int`.",
|
||||
},
|
||||
"require_explicit_primary_key": {
|
||||
Name: "require_explicit_primary_key",
|
||||
Sql: `
|
||||
select 'Table should declare an explicit primary key' as error_msg,
|
||||
tables.name as table_name,
|
||||
'' as column_name
|
||||
from tables
|
||||
where not exists (select 1 from pragma_table_info(tables.name) where pk != 0);
|
||||
`,
|
||||
Explanation: "All tables must have a primary key. If it's rowid, it has to be named explicitly.",
|
||||
},
|
||||
"require_indexes_for_foreign_keys": {
|
||||
Name: "require_indexes_for_foreign_keys",
|
||||
Sql: `
|
||||
with index_info as (
|
||||
select tables.name as table_name,
|
||||
columns.name as column_name
|
||||
from tables
|
||||
join pragma_index_list(tables.name) as indexes
|
||||
join pragma_index_info(indexes.name) as columns
|
||||
|
||||
union
|
||||
|
||||
select table_name,
|
||||
column_name
|
||||
from columns
|
||||
where column_name = 'rowid'
|
||||
and is_primary_key != 0 -- 'pk' is either 0, or the 1-based index of the column within the primary key
|
||||
), foreign_keys as (
|
||||
select * from columns where fk_target_column is not null
|
||||
)
|
||||
select 'Foreign keys should point to indexed columns' as error_msg,
|
||||
foreign_keys.table_name as table_name,
|
||||
foreign_keys.column_name as column_name
|
||||
from foreign_keys
|
||||
left join index_info on foreign_keys.fk_target_table = index_info.table_name
|
||||
and foreign_keys.fk_target_column = index_info.column_name
|
||||
where index_info.column_name is null;
|
||||
`,
|
||||
Explanation: "Columns referenced by foreign keys must have indexes.",
|
||||
},
|
||||
}
|
||||
71
pkg/checks/check_test.go
Normal file
71
pkg/checks/check_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package checks_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"sqlite_lint/pkg/checks"
|
||||
)
|
||||
|
||||
func TestFailureCases(t *testing.T) {
|
||||
test_cases := []struct {
|
||||
sqlFile string
|
||||
expectedFailures []string
|
||||
}{
|
||||
{"../../test_schemas/failure-has-foreign-key-no-index.sql", []string{"require_indexes_for_foreign_keys"}},
|
||||
{"../../test_schemas/failure-has-ints.sql", []string{"forbid_int_type"}},
|
||||
{"../../test_schemas/failure-has-nulls.sql", []string{"require_not_null"}},
|
||||
{"../../test_schemas/failure-no-strict.sql", []string{"require_strict"}},
|
||||
{"../../test_schemas/failure-total.sql", []string{
|
||||
"require_not_null",
|
||||
"require_explicit_primary_key",
|
||||
"forbid_int_type",
|
||||
"require_strict",
|
||||
"require_indexes_for_foreign_keys",
|
||||
}},
|
||||
}
|
||||
|
||||
for _, test_case := range test_cases {
|
||||
db, err := checks.OpenSchema(test_case.sqlFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open database: %v", err)
|
||||
}
|
||||
|
||||
for _, check := range checks.Checks {
|
||||
results, err := check.Execute(db)
|
||||
if err != nil {
|
||||
t.Errorf("failed to execute check '%s': %v", check.Name, err)
|
||||
}
|
||||
|
||||
is_failure := len(results) > 0
|
||||
is_failure_expected := slices.Contains(test_case.expectedFailures, check.Name)
|
||||
|
||||
if is_failure != is_failure_expected {
|
||||
if is_failure_expected {
|
||||
t.Errorf("Expected check '%s' to fail, but it passed: %s", check.Name, test_case.sqlFile)
|
||||
} else {
|
||||
t.Errorf("Expected check '%s' to pass, but it failed: %s", check.Name, test_case.sqlFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessCase(t *testing.T) {
|
||||
db, err := checks.OpenSchema("../../test_schemas/success.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open database: %v", err)
|
||||
}
|
||||
|
||||
for _, check := range checks.Checks {
|
||||
results, err := check.Execute(db)
|
||||
if err != nil {
|
||||
t.Errorf("failed to execute check '%s': %v", check.Name, err)
|
||||
}
|
||||
if len(results) > 0 {
|
||||
t.Errorf("Should have passed, but didn't: %s", "test_schemas/success.sql")
|
||||
}
|
||||
}
|
||||
}
|
||||
52
pkg/checks/target.go
Normal file
52
pkg/checks/target.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package checks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// OpenSchema opens a SQLite database in memory, executes the schema against it, and adds some views
|
||||
func OpenSchema(filepath string) (*sqlx.DB, error) {
|
||||
// Open a SQLite database in memory
|
||||
db, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open in-memory database: %w", err)
|
||||
}
|
||||
|
||||
// Read the SQL file
|
||||
sqlBytes, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read SQL file: %w", err)
|
||||
}
|
||||
|
||||
// Execute the SQL statements
|
||||
db.MustExec(string(sqlBytes))
|
||||
|
||||
// Execute the SQL statements for creating views
|
||||
db.MustExec(`
|
||||
create view tables as
|
||||
select l.*
|
||||
from sqlite_schema s
|
||||
left join pragma_table_list l on s.name = l.name
|
||||
where s.type = 'table';
|
||||
|
||||
|
||||
create view columns as
|
||||
select tables.name as table_name,
|
||||
table_info.name as column_name,
|
||||
table_info.type as column_type,
|
||||
"notnull",
|
||||
dflt_value,
|
||||
pk as is_primary_key,
|
||||
fk."table" as fk_target_table,
|
||||
fk."to" as fk_target_column
|
||||
from tables
|
||||
join pragma_table_info(tables.name) as table_info
|
||||
left join pragma_foreign_key_list(tables.name) as fk on fk."from" = column_name;
|
||||
`)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
Reference in New Issue
Block a user