diff --git a/tools/dbutils/index.go b/tools/dbutils/index.go new file mode 100644 index 00000000..96ca8307 --- /dev/null +++ b/tools/dbutils/index.go @@ -0,0 +1,106 @@ +package dbutils + +import ( + "regexp" + "strings" + + "github.com/pocketbase/pocketbase/tools/tokenizer" +) + +var ( + indexRegex = regexp.MustCompile(`(?im)create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?([\w\"\'\[\]\.]*)\s+on\s+([\w\"\'\[\]\.]*)\s+\(([\s\S]*)\)(?:\s*where\s+([\s\S]*))?`) + indexColumnRegex = regexp.MustCompile(`(?im)^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?$`) +) + +// IndexColumn represents a single parsed SQL index column. +type IndexColumn struct { + Name string `json:"name"` // identifier or expression + Collate string `json:"collate"` + Sort string `json:"sort"` +} + +// Index represents a single parsed SQL CREATE INDEX expression. +type Index struct { + Unique bool `json:"unique"` + Optional bool `json:"optional"` + SchemaName string `json:"schemaName"` + IndexName string `json:"indexName"` + TableName string `json:"tableName"` + Columns []IndexColumn `json:"columns"` + Where string `json:"where"` +} + +// IsValid checks if the current Index contains the minimum required fields to be considered valid. +func (idx Index) IsValid() bool { + return idx.IndexName != "" && idx.TableName != "" && len(idx.Columns) > 0 +} + +// ParseIndex parses the provided `CREATE INDEX` SQL string into Index struct. +func ParseIndex(createIndexExpr string) Index { + result := Index{} + + matches := indexRegex.FindStringSubmatch(createIndexExpr) + if len(matches) != 7 { + return result + } + + trimChars := "`\"'[]\r\n\t\f\v " + + // Unique + // --- + result.Unique = strings.TrimSpace(matches[1]) != "" + + // Optional (aka. "IF NOT EXISTS") + // --- + result.Optional = strings.TrimSpace(matches[2]) != "" + + // SchemaName and IndexName + // --- + nameTk := tokenizer.NewFromString(matches[3]) + nameTk.Separators('.') + + nameParts, _ := nameTk.ScanAll() + if len(nameParts) == 2 { + result.SchemaName = strings.Trim(nameParts[0], trimChars) + result.IndexName = strings.Trim(nameParts[1], trimChars) + } else { + result.IndexName = strings.Trim(nameParts[0], trimChars) + } + + // TableName + // --- + result.TableName = strings.Trim(matches[4], trimChars) + + // Columns + // --- + columnsTk := tokenizer.NewFromString(matches[5]) + columnsTk.Separators(',') + + rawColumns, _ := columnsTk.ScanAll() + + result.Columns = make([]IndexColumn, 0, len(rawColumns)) + + for _, col := range rawColumns { + colMatches := indexColumnRegex.FindStringSubmatch(col) + if len(colMatches) != 4 { + continue + } + + trimmedName := strings.Trim(colMatches[1], trimChars) + if trimmedName == "" { + continue + } + + result.Columns = append(result.Columns, IndexColumn{ + Name: trimmedName, + Collate: strings.TrimSpace(colMatches[2]), + Sort: strings.ToUpper(colMatches[3]), + }) + } + + // WHERE expression + // --- + result.Where = strings.TrimSpace(matches[6]) + + return result +} diff --git a/tools/dbutils/index_test.go b/tools/dbutils/index_test.go new file mode 100644 index 00000000..5f8c7f51 --- /dev/null +++ b/tools/dbutils/index_test.go @@ -0,0 +1,142 @@ +package dbutils_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/pocketbase/pocketbase/tools/dbutils" +) + +func TestParseIndex(t *testing.T) { + scenarios := []struct { + index string + expected dbutils.Index + }{ + // invalid + { + `invalid`, + dbutils.Index{}, + }, + // simple + { + `create index indexname on tablename (col1)`, + dbutils.Index{ + IndexName: "indexname", + TableName: "tablename", + Columns: []dbutils.IndexColumn{ + {Name: "col1"}, + }, + }, + }, + // all fields + { + `CREATE UNIQUE INDEX IF NOT EXISTS "schemaname".[indexname] on 'tablename' ( + col1, + json_extract("col2", "$.a") asc, + "col3" collate NOCASE, + "col4" collate RTRIM desc + ) where test = 1`, + dbutils.Index{ + Unique: true, + Optional: true, + SchemaName: "schemaname", + IndexName: "indexname", + TableName: "tablename", + Columns: []dbutils.IndexColumn{ + {Name: "col1"}, + {Name: `json_extract("col2", "$.a")`, Sort: "ASC"}, + {Name: `col3`, Collate: "NOCASE"}, + {Name: `col4`, Collate: "RTRIM", Sort: "DESC"}, + }, + Where: "test = 1", + }, + }, + } + + for i, s := range scenarios { + result := dbutils.ParseIndex(s.index) + + resultRaw, err := json.Marshal(result) + if err != nil { + t.Fatalf("[%d] %v", i, err) + } + + expectedRaw, err := json.Marshal(s.expected) + if err != nil { + t.Fatalf("[%d] %v", i, err) + } + + if !bytes.Equal(resultRaw, expectedRaw) { + t.Errorf("[%d] Expected \n%s \ngot \n%s", i, expectedRaw, resultRaw) + } + } +} + +func TestIndexIsValid(t *testing.T) { + scenarios := []struct { + name string + index dbutils.Index + expected bool + }{ + { + "empty", + dbutils.Index{}, + false, + }, + { + "no index name", + dbutils.Index{ + TableName: "table", + Columns: []dbutils.IndexColumn{{Name: "col"}}, + }, + false, + }, + { + "no table name", + dbutils.Index{ + IndexName: "index", + Columns: []dbutils.IndexColumn{{Name: "col"}}, + }, + false, + }, + { + "no columns", + dbutils.Index{ + IndexName: "index", + TableName: "table", + }, + false, + }, + { + "min valid", + dbutils.Index{ + IndexName: "index", + TableName: "table", + Columns: []dbutils.IndexColumn{{Name: "col"}}, + }, + true, + }, + { + "all fields", + dbutils.Index{ + Optional: true, + Unique: true, + SchemaName: "schema", + IndexName: "index", + TableName: "table", + Columns: []dbutils.IndexColumn{{Name: "col"}}, + Where: "test = 1 OR test = 2", + }, + true, + }, + } + + for _, s := range scenarios { + result := s.index.IsValid() + + if result != s.expected { + t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result) + } + } +}