Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,98 @@ foreach (var stmt in tree.Stmts)
// drop: DROP TABLE tmp
```

Any node can be turned back into SQL on its own, which is how to get the text of a single clause or expression:

```csharp
var select = tree.Stmts[0].Stmt.SelectStmt;

Console.WriteLine(select.FromClause[0].Deparse().GetValueOrThrow());
// Output: customers c JOIN orders o ON o.customer_id = c.id

foreach (var table in tree.Descendants<RangeVar>())
{
Console.WriteLine(table.Deparse().GetValueOrThrow());
}
// Output: customers c, orders o
```

`Deparse()` on a node works for statements, expressions, items of a FROM clause, select list and ORDER BY items, WITH clauses and type names. Nodes that are not SQL on their own, such as an `Alias`, return an error.

To control the walk, pass a function. It can skip everything below a node or stop the walk, and each visit tells you which property of its parent the node came from:

```csharp
// Tables used by the query itself, ignoring subqueries inside expressions
var tables = new List<string>();
tree.Walk(visit =>
{
if (visit.Node is SubLink)
return WalkAction.SkipChildren;

if (visit.Node is RangeVar table)
tables.Add(table.Relname);

return WalkAction.Continue; // or WalkAction.Stop to end the walk
});

// visit.FieldName is the parent's property, for example nameof(SelectStmt.WhereClause);
// visit.Index is the position when that property is a list.
```

Each visit also knows where it sits in the tree, which is what most rules need:

```csharp
foreach (var visit in tree.Walk())
{
if (visit.Node is not RangeVar table)
continue;

// The nearest containing node of a type, or null
var insideCte = visit.FindAncestor<CommonTableExpr>() is not null;
var insideSubquery = visit.FindAncestor<SubLink>() is not null;

// The top-level statement the node belongs to
var writes = visit.Statement?.Stmt.Unwrap() is InsertStmt or UpdateStmt or DeleteStmt;

Console.WriteLine($"statement {visit.StatementIndex}: {table.Relname} (cte: {insideCte}, subquery: {insideSubquery}, writes: {writes})");
}
```

`visit.Ancestors` lists every containing node, nearest first.

#### Changing and comparing trees

`Rewrite` walks the tree and lets you keep, replace or remove each node. It changes the tree in place, visiting children before their parents, and the result can be deparsed:

```csharp
var tree = Parser.Parse("SELECT a, secret FROM old_name WHERE kind = 'x'::text").GetValueOrThrow();

tree.Rewrite(visit => visit.Node switch
{
// Point the query at another table
RangeVar { Relname: "old_name" } => NodeEdit.ReplaceWith(new RangeVar { Relname = "new_name", Inh = true, Relpersistence = "p" }),
// Drop a cast, keeping what it wraps
TypeCast cast => NodeEdit.ReplaceWith(cast.Arg),
// Remove an item from a list
ResTarget target when target.Deparse().Value == "secret" => NodeEdit.Remove,
_ => NodeEdit.Keep
});

Console.WriteLine(tree.Deparse().GetValueOrThrow());
// Output: SELECT a FROM new_name WHERE kind = 'x'
```

`EqualsIgnoringLocations` compares two trees, or two nodes, by structure. Queries that differ only in whitespace, comments, keyword case or redundant parentheses are equal:

```csharp
var a = Parser.Parse("SELECT a FROM t WHERE x <> 1").GetValueOrThrow();
var b = Parser.Parse("select a\nfrom t -- note\nwhere (x != 1)").GetValueOrThrow();

Console.WriteLine(a.EqualsIgnoringLocations(b));
// Output: True
```

A few helpers go with these: `Parser.ParseExpression` and `Parser.ParseTypeName` parse a single expression or type name on its own, `node.AsNode()` wraps a node for a property or list that takes any node, and `PgIdentifier.Quote` quotes a name the way PostgreSQL's `quote_ident` does.

`Walk` and `Descendants` visit parents before their children and siblings in field order, which is not always the order of the query text. `GetLocation()` returns a node's location if it has one; see [Offsets and non-ASCII text](#offsets-and-non-ascii-text) for its unit. Only statements record a length, so `GetText` is available for statements and not for other nodes.

### Scan
Expand All @@ -195,6 +287,25 @@ if (result.Error is null)
// { "version": 180006, "tokens": [ { "end": 6, "token": "SELECT", "keywordKind": "RESERVED_KEYWORD" }, { "start": 7, "end": 8, "token": "ICONST" } ] }
```

### ParameterRefs

Find the parameter references (`$1`, `$2`, ...) in a query, with where each one is in the query string. References inside string literals and comments are not parameters and are left out.

```csharp
using PgSqlParser;

var query = "SELECT * FROM t WHERE a = $1 AND b = $2 AND note <> '$3'";
var parameters = Parser.ParameterRefs(query).GetValueOrThrow();

foreach (var parameter in parameters)
{
Console.WriteLine($"${parameter.Number} at {parameter.Start}..{parameter.End}");
}
// Output: $1 at 26..28, $2 at 37..39
```

When a parameter is cast in the query, `TypeName` holds the type: `$1::int` gives `int`, and `CAST($2 AS numeric(10,2))` gives `numeric(10, 2)`. It is null for a parameter that is not cast.

### ParsePlpgsql

Parse PL/pgSQL function bodies and return a JSON representation
Expand Down Expand Up @@ -466,6 +577,20 @@ Deeply nested queries, such as a long chain of operators without parentheses (`a

Protobuf's own recursive operations on a parse tree, such as `ToString()`, `Clone()` and `Equals()`, run on your thread. On a very deeply nested tree they can still overflow a small stack.

## What's new in 2.1

All additions; nothing from 2.0 changes.

- **Deparse a single node**: `node.Deparse()` turns one clause, expression or table reference back into SQL.
- **More control when walking**: skip a subtree or stop, and each visit knows its ancestors, the property it came from and its top-level statement.
- **Change trees**: `Rewrite` keeps, replaces or removes nodes in place.
- **Compare trees**: `EqualsIgnoringLocations` treats queries that differ only in formatting as equal.
- **Parse fragments**: `ParseExpression` and `ParseTypeName`.
- **Parameters**: `ParameterRefs` finds each `$n`, where it is, and the type it is cast to.
- **Identifier quoting**: `PgIdentifier.Quote`, following PostgreSQL's `quote_ident`.

See [Navigating the parse tree](#navigating-the-parse-tree) and [ParameterRefs](#parameterrefs).

## What's new in 2.0

- **PostgreSQL 18**: built on libpg_query 18.1.0, which uses the PostgreSQL 18.6 parser and includes its security fix for `pg_query_normalize`.
Expand Down
21 changes: 21 additions & 0 deletions src/PackageSmokeTest/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ void Check(string name, bool ok)
Check("Descendants", walked.Descendants<RangeVar>().Select(table => table.Relname).SequenceEqual(["données"]));
Check("Unwrap", walked.Stmts[0].Stmt.Unwrap() is SelectStmt);
Check("GetText", walked.Stmts[1].GetText(query) == "SELECT 2");
Check("DeparseNode", walked.Stmts[0].Stmt.SelectStmt.WhereClause.Deparse().Value == "id = 1");
var skipped = 0;
walked.Walk(visit => { skipped++; return visit.Node is SelectStmt ? WalkAction.SkipChildren : WalkAction.Continue; });
Check("Walk with a visitor", skipped == 4 && walked.Walk().First().FieldName == "Stmts");
var tableVisit = walked.Walk().First(visit => visit.Node is RangeVar);
Check("Ancestors and statement", tableVisit.FindAncestor<SelectStmt>() is not null && tableVisit.StatementIndex == 0);
Check("ParameterRefs cast type", Parser.ParameterRefs("SELECT $1::int").Value?[0].TypeName == "int");
var edited = Parser.Parse("SELECT a, b FROM old_name WHERE c = 'x'::text").Value!;
edited.Rewrite(visit => visit.Node switch
{
RangeVar => NodeEdit.ReplaceWith(new RangeVar { Relname = "new_name", Inh = true, Relpersistence = "p" }),
TypeCast cast => NodeEdit.ReplaceWith(cast.Arg),
ResTarget when visit.Index == 1 => NodeEdit.Remove,
_ => NodeEdit.Keep
});
Check("Rewrite", edited.Deparse().Value == "SELECT a FROM new_name WHERE c = 'x'");
Check("EqualsIgnoringLocations", edited.EqualsIgnoringLocations(Parser.Parse("select a\nfrom new_name where (c = 'x')").Value));
Check("ParseExpression", Parser.ParseExpression("(a > 0)").Value?.Deparse().Value == "a > 0");
Check("ParseTypeName", Parser.ParseTypeName("character varying(20)").Value?.Deparse().Value == "varchar(20)");
Check("PgIdentifier", PgIdentifier.Quote("public", "order") == "public.\"order\"");
Check("ParameterRefs", Parser.ParameterRefs("SELECT $1, '$2', $3").Value?.Select(p => p.Number).SequenceEqual([1, 3]) == true);
Check("ParsePlpgsqlFunctions", Parser.ParsePlpgsqlFunctions(
"CREATE FUNCTION f() RETURNS int AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql").Value?[0].Queries().SequenceEqual(["1"]) == true);
Check("Error", Parser.Parse("SELECT FROM WHERE").Error?.Message?.StartsWith("syntax error") == true);
Expand Down
Loading
Loading