package parser import ( "os" "path/filepath" "strings" "testing" "github.com/hein/pgtidy/pkg/cst" ) func TestParseRoundTripSmall(t *testing.T) { cases := []string{ "", "SELECT 1;", "-- lead\nSELECT 1; SELECT 2;", "CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;", "create or replace function s.g(a int, b text default 'x') returns void language plpgsql as $$ begin end; $$;\n", "INSERT INTO t (a,b) VALUES (1,2); -- trailing\n", } for _, src := range cases { if got := Parse(src).Source(); got != src { t.Errorf("round-trip mismatch\n in: %q\nout: %q", src, got) } } } func TestParseCreateFunctionShape(t *testing.T) { src := "CREATE OR REPLACE FUNCTION resolvespec_login(\n" + " INOUT p_data jsonb\n" + " ,OUT p_success boolean\n" + " ,OUT p_error text\n" + ")\nLANGUAGE plpgsql VOLATILE\nSECURITY DEFINER\nAS\n$$\nbegin end;\n$$;\n" f := Parse(src) if len(f.Items) != 1 { t.Fatalf("want 1 item, got %d", len(f.Items)) } cf, ok := f.Items[0].(*cst.CreateFunction) if !ok { t.Fatalf("want *CreateFunction, got %T", f.Items[0]) } if cf.IsProcedure() { t.Error("should be FUNCTION not PROCEDURE") } if len(cf.Params) != 3 { t.Fatalf("want 3 params, got %d", len(cf.Params)) } // Leading-comma style: in source the comma precedes the next param, but the // parser associates each comma as the separator after the preceding param. if cf.Params[0].Sep == nil || cf.Params[1].Sep == nil || cf.Params[2].Sep != nil { t.Errorf("param separators wrong: %v %v %v", cf.Params[0].Sep != nil, cf.Params[1].Sep != nil, cf.Params[2].Sep != nil) } if cf.Body == nil || !strings.Contains(cf.Body.Text(), "begin") { t.Errorf("body not captured: %+v", cf.Body) } if cf.As == nil { t.Error("AS not captured") } // Options should include a LANGUAGE clause, a VOLATILE clause and a SECURITY clause. var langs, vols, secs int for _, cl := range cf.Options { if len(cl) == 0 { continue } switch { case cl[0].Is("language"): langs++ case cl[0].Is("volatile"): vols++ case cl[0].Is("security"): secs++ } } if langs != 1 || vols != 1 || secs != 1 { t.Errorf("clause split wrong: language=%d volatile=%d security=%d", langs, vols, secs) } } func TestParseFallbackRaw(t *testing.T) { f := Parse("SELECT a, b FROM t WHERE c = 1;") if len(f.Items) != 1 { t.Fatalf("want 1 item, got %d", len(f.Items)) } if _, ok := f.Items[0].(*cst.Raw); !ok { t.Fatalf("want *Raw, got %T", f.Items[0]) } } // TestCorpusRoundTrip proves the parser is lossless on real-world input: the // reconstructed source must equal the original byte-for-byte. func TestCorpusRoundTrip(t *testing.T) { dir := filepath.Join("..", "..", "testdata", "corpus") entries, err := os.ReadDir(dir) if err != nil { t.Skipf("no corpus dir: %v", err) } var seen, fns int for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") { continue } seen++ data, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { t.Fatal(err) } src := string(data) f := Parse(src) if got := f.Source(); got != src { t.Errorf("%s: round-trip mismatch (len in=%d out=%d)", e.Name(), len(src), len(got)) } for _, it := range f.Items { if _, ok := it.(*cst.CreateFunction); ok { fns++ } } } if seen == 0 { t.Skip("no corpus files") } t.Logf("round-tripped %d corpus files; structured %d CREATE FUNCTION/PROCEDURE", seen, fns) }