<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nur Zaman</title>
    <description>The latest articles on DEV Community by Nur Zaman (@nur-zaman).</description>
    <link>https://dev.to/nur-zaman</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4093802%2F7e78c73e-f40d-4963-bb3f-069429573524.jpg</url>
      <title>DEV Community: Nur Zaman</title>
      <link>https://dev.to/nur-zaman</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nur-zaman"/>
    <language>en</language>
    <item>
      <title>Using an AST to validate AI-generated PostgreSQL before it runs</title>
      <dc:creator>Nur Zaman</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:35:14 +0000</pubDate>
      <link>https://dev.to/nur-zaman/using-an-ast-to-validate-ai-generated-postgresql-before-it-runs-1o47</link>
      <guid>https://dev.to/nur-zaman/using-an-ast-to-validate-ai-generated-postgresql-before-it-runs-1o47</guid>
      <description>&lt;p&gt;If an LLM is generating PostgreSQL in your application, there is one moment worth treating separately: after the model returns SQL, but before your code calls &lt;code&gt;db.query()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Prompt rules are useful. They can make the model more likely to produce the sort of query you want. They do not decide which tables the application is allowed to read, whether multiple statements are acceptable, or whether a function call should run.&lt;/p&gt;

&lt;p&gt;I have been working on &lt;a href="https://github.com/nur-zaman/sql-guard" rel="noopener noreferrer"&gt;sql-guard&lt;/a&gt;, a TypeScript package for that gap. It parses PostgreSQL into an abstract syntax tree (AST), checks the tree against an explicit policy, and rejects anything it cannot validate confidently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I did not want to check SQL with regex
&lt;/h2&gt;

&lt;p&gt;SQL is structured. A query may have joins, subqueries, aliases, unions, and common table expressions (CTEs). Checking raw text can catch an obvious keyword, but it cannot reliably answer what the query actually does.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;removed&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;
  &lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three examples contain &lt;code&gt;SELECT&lt;/code&gt;, but they are not equivalent. The second has two statements. The third uses a data-modifying CTE. A validator needs to understand the query structure rather than look for a few strings.&lt;/p&gt;

&lt;p&gt;An AST makes that possible. It lets the validator inspect statement types, source tables, function calls, and nested expressions. It also means an alias or CTE name cannot conceal the base table being read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy is the important part
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sql-guard&lt;/code&gt; is built around allowlists. You state what a particular feature may use, and the validator checks the generated SQL against that list.&lt;/p&gt;

&lt;p&gt;Here is a small policy for an assistant that can look at users and orders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sql-guard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;allowedTables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public.users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public.orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;allowedFunctions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;count&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lower&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT lower(u.email) FROM public.users AS u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Do not execute the SQL.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Execute only after result.ok is true.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default policy allows one &lt;code&gt;SELECT&lt;/code&gt; statement and no function calls. Tables and functions have to be allowed explicitly. Multi-statement input is disabled by default.&lt;/p&gt;

&lt;p&gt;That makes the initial policy intentionally strict. A request for &lt;code&gt;public.secret_users&lt;/code&gt;, &lt;code&gt;information_schema.tables&lt;/code&gt;, or &lt;code&gt;pg_catalog.pg_read_file(...)&lt;/code&gt; is denied unless the policy names it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few details that matter in real queries
&lt;/h2&gt;

&lt;p&gt;Unqualified names such as &lt;code&gt;SELECT * FROM users&lt;/code&gt; can be ambiguous. By default, &lt;code&gt;sql-guard&lt;/code&gt; expects schema-qualified names. You can choose a simple default schema for a single-schema application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;defaultSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;allowedTables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or you can provide a resolver when the application needs more control over how names map to schema-qualified tables.&lt;/p&gt;

&lt;p&gt;Functions need the same care. An unqualified &lt;code&gt;lower(...)&lt;/code&gt; and &lt;code&gt;pg_catalog.current_database(...)&lt;/code&gt; are separate policy entries. This lets a feature allow exactly the form it intends to use.&lt;/p&gt;

&lt;p&gt;The package returns structured violations and an error code rather than just &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt;. An application can log a rejected query without attempting to execute it. If a rejected query should interrupt the request immediately, &lt;code&gt;assertSafeSql()&lt;/code&gt; throws a &lt;code&gt;SqlValidationError&lt;/code&gt; with the same details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits in a database security model
&lt;/h2&gt;

&lt;p&gt;This check is only about query shape. It does not sanitize values, execute SQL, evaluate row-level security, decide column-level permissions, or notice runtime schema changes.&lt;/p&gt;

&lt;p&gt;The surrounding controls still matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parameterize user-controlled values.&lt;/li&gt;
&lt;li&gt;Use a database role with the narrowest practical permissions.&lt;/li&gt;
&lt;li&gt;Keep row-level security and application authorization in place.&lt;/li&gt;
&lt;li&gt;Validate generated SQL against a feature-specific policy before running it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any one of those controls fails, the others should limit the damage. The SQL validator is one small boundary in that chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project links
&lt;/h2&gt;

&lt;p&gt;The package currently supports PostgreSQL and requires Node.js 18 or later.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://sql-guard.nurzaman.dev" rel="noopener noreferrer"&gt;Playground&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/nur-zaman/sql-guard" rel="noopener noreferrer"&gt;Source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.npmjs.com/package/sql-guard" rel="noopener noreferrer"&gt;npm package&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>security</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
