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
63 changes: 46 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Parse SQL into a plain Perl data structure and back again
# SYNOPSIS

```perl
use SQL::AST::Simple qw( parse unparse );
use SQL::AST::Simple qw( parse unparse parse_expr unparse_expr );

my $ast = parse('SELECT a, b FROM t WHERE a > 1', dialect => 'postgresql');

Expand All @@ -14,15 +14,20 @@ my $ast = parse('SELECT a, b FROM t WHERE a > 1', dialect => 'postgresql');
$ast->[0]{Query}{body}{Select}{from}[0]{relation}{Table}{name}[0]{Identifier}{value} = 'u';

say unparse($ast); # SELECT a, b FROM u WHERE a > 1

# Expressions can be handled on their own, without a statement around them:
$ast->[0]{Query}{body}{Select}{selection} = parse_expr('a > 1 AND b = 2');
say unparse_expr($ast->[0]{Query}{body}{Select}{selection}); # a > 1 AND b = 2
```

# DESCRIPTION

This module bundles the Rust
[sqlparser](https://github.com/apache/datafusion-sqlparser-rs) crate and
exposes exactly two operations: turning SQL text into the parser's abstract
syntax tree as an ordinary Perl data structure, and turning such a data
structure back into SQL text. There is no object layer; the tree is what
This module provides Perl bindings for the Rust
[sqlparser](https://crates.io/crates/sqlparser) crate. It
exposes two operations: turning SQL text into the parser's abstract syntax
tree as an ordinary Perl data structure, and turning such a data structure
back into SQL text. Each comes in a form for whole statements and a form
for a lone expression. There is no object layer; the tree is what
the crate's serde serialization produces, decoded from JSON. That keeps
the module small and makes every node the crate knows about available
without any wrapping, at the cost of a somewhat verbose structure.
Expand Down Expand Up @@ -54,6 +59,23 @@ Options:
`duckdb`, `databricks`, `hive`, `spark` (or `sparksql`) and
`teradata`.

## parse\_expr

```perl
my $expr = parse_expr($sql);
my $expr = parse_expr($sql, dialect => $name);
```

Parses `$sql` as a single expression, such as the condition of a `WHERE`
clause, and returns it as a hash reference. The whole of `$sql` must be
consumed by the expression; a leading `WHERE` keyword or anything left
over after the expression is an error. Takes the same `dialect` option
as ["parse"](#parse).

The result is exactly what appears inside a statement wherever the crate
expects an expression, so it can be spliced into a tree from ["parse"](#parse),
for instance as the `selection` of a `SELECT`.

## unparse

```perl
Expand All @@ -73,6 +95,17 @@ Options:
If true, statements are formatted with indentation and newlines rather
than on a single line, and are joined with `";\n"`.

## unparse\_expr

```perl
my $sql = unparse_expr($expr);
```

Takes an expression hash reference, as returned by ["parse\_expr"](#parse_expr) or
lifted out of a statement, and returns the SQL text. Throws an exception
if the structure does not deserialize into a valid expression. There is
no `pretty` option; expressions are always rendered on one line.

# THE DATA STRUCTURE

The tree mirrors the Rust types of the `sqlparser` crate one to one, as
Expand All @@ -96,14 +129,14 @@ first.
- Most nodes carry a `span` hash recording where they appeared in the
source. ["unparse"](#unparse) ignores the contents but requires the field to be
present, so the easiest way to build a new node is to parse a small
snippet and lift the piece you need out of the result, rather than
constructing hashes by hand.
snippet (with ["parse\_expr"](#parse_expr) for an expression) and lift the piece you
need out of the result, rather than constructing hashes by hand.

The easiest way to learn the shape for a given construct is to parse an
example and dump it. The exact shape depends on the bundled crate
version, which is pinned in the distribution's `ffi/Cargo.toml`; a
release that bumps it may change the structure and will say so in the
change log.
example and dump it. The exact shape depends on the version of the
crate the bindings are built against, which is pinned in the
distribution's `ffi/Cargo.toml`; a release that bumps it may change the
structure and will say so in the change log.

# CAVEATS

Expand All @@ -118,14 +151,10 @@ install time.

# SEE ALSO

- [https://github.com/apache/datafusion-sqlparser-rs](https://github.com/apache/datafusion-sqlparser-rs)
- [https://crates.io/crates/sqlparser](https://crates.io/crates/sqlparser)

The parser this module wraps.

- [FFI::Platypus::Lang::Rust](https://metacpan.org/pod/FFI::Platypus::Lang::Rust)

How the Rust code is bundled and called.

# AUTHOR

Graham Ollis <plicease@cpan.org>
Expand Down
54 changes: 49 additions & 5 deletions ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
//! returned to the caller is a NUL terminated, heap allocated C string that
//! must be released with `sql_ast_simple_free`.

use sqlparser::ast::Statement;
use sqlparser::dialect::dialect_from_str;
use sqlparser::ast::{Expr, Statement};
use sqlparser::dialect::{dialect_from_str, Dialect};
use sqlparser::parser::Parser;
use sqlparser::tokenizer::Token;
use std::ffi::{c_char, CStr, CString};
use std::ptr;

Expand Down Expand Up @@ -45,6 +46,12 @@ fn finish(err_out: *mut *mut c_char, result: Result<String, String>) -> *mut c_c
}
}

/// Look up a dialect by name.
fn dialect(name: *const c_char) -> Result<Box<dyn Dialect>, String> {
let name = unsafe { borrow(name) }?;
dialect_from_str(name).ok_or_else(|| format!("unknown dialect: {name}"))
}

/// Parse `sql` using the named dialect and return the AST as a JSON array
/// of statements.
#[no_mangle]
Expand All @@ -54,16 +61,38 @@ pub extern "C" fn sql_ast_simple_parse(
err_out: *mut *mut c_char,
) -> *mut c_char {
let result = (|| {
let dialect_name = unsafe { borrow(dialect) }?;
let dialect = self::dialect(dialect)?;
let sql = unsafe { borrow(sql) }?;
let dialect =
dialect_from_str(dialect_name).ok_or_else(|| format!("unknown dialect: {dialect_name}"))?;
let ast = Parser::parse_sql(&*dialect, sql).map_err(|e| e.to_string())?;
serde_json::to_string(&ast).map_err(|e| e.to_string())
})();
finish(err_out, result)
}

/// Parse `sql` as a single expression, such as the body of a `WHERE`
/// clause, and return it as JSON. Anything left over after the expression
/// is an error.
#[no_mangle]
pub extern "C" fn sql_ast_simple_parse_expr(
dialect: *const c_char,
sql: *const c_char,
err_out: *mut *mut c_char,
) -> *mut c_char {
let result = (|| {
let dialect = self::dialect(dialect)?;
let sql = unsafe { borrow(sql) }?;
let mut parser = Parser::new(&*dialect)
.try_with_sql(sql)
.map_err(|e| e.to_string())?;
let expr = parser.parse_expr().map_err(|e| e.to_string())?;
parser
.expect_token(&Token::EOF)
.map_err(|e| e.to_string())?;
serde_json::to_string(&expr).map_err(|e| e.to_string())
})();
finish(err_out, result)
}

/// Turn a JSON array of statements (as produced by `sql_ast_simple_parse`,
/// possibly modified) back into SQL text. Statements are joined with
/// `"; "`, or `";\n"` when `pretty` is set.
Expand All @@ -85,6 +114,21 @@ pub extern "C" fn sql_ast_simple_unparse(
finish(err_out, result)
}

/// Turn a JSON expression (as produced by `sql_ast_simple_parse_expr`,
/// possibly modified) back into SQL text.
#[no_mangle]
pub extern "C" fn sql_ast_simple_unparse_expr(
json: *const c_char,
err_out: *mut *mut c_char,
) -> *mut c_char {
let result = (|| {
let json = unsafe { borrow(json) }?;
let expr: Expr = serde_json::from_str(json).map_err(|e| e.to_string())?;
Ok(expr.to_string())
})();
finish(err_out, result)
}

/// Release a string returned by any function in this library.
#[no_mangle]
pub extern "C" fn sql_ast_simple_free(p: *mut c_char) {
Expand Down
104 changes: 74 additions & 30 deletions lib/SQL/AST/Simple.pm
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ use 5.042;
use warnings;
use FFI::Platypus 2.00;
use JSON::MaybeXS ();
use Carp ();
use Carp qw( croak );
use Ref::Util qw( is_plain_arrayref is_plain_hashref );
use Exporter qw( import );

# ABSTRACT: Parse SQL into a plain Perl data structure and back again
# VERSION

=head1 SYNOPSIS

use SQL::AST::Simple qw( parse unparse );
use SQL::AST::Simple qw( parse unparse parse_expr unparse_expr );

my $ast = parse('SELECT a, b FROM t WHERE a > 1', dialect => 'postgresql');

Expand All @@ -22,13 +23,18 @@ use Exporter qw( import );

say unparse($ast); # SELECT a, b FROM u WHERE a > 1

# Expressions can be handled on their own, without a statement around them:
$ast->[0]{Query}{body}{Select}{selection} = parse_expr('a > 1 AND b = 2');
say unparse_expr($ast->[0]{Query}{body}{Select}{selection}); # a > 1 AND b = 2

=head1 DESCRIPTION

This module bundles the Rust
L<sqlparser|https://github.com/apache/datafusion-sqlparser-rs> crate and
exposes exactly two operations: turning SQL text into the parser's abstract
syntax tree as an ordinary Perl data structure, and turning such a data
structure back into SQL text. There is no object layer; the tree is what
This module provides Perl bindings for the Rust
L<sqlparser|https://crates.io/crates/sqlparser> crate. It
exposes two operations: turning SQL text into the parser's abstract syntax
tree as an ordinary Perl data structure, and turning such a data structure
back into SQL text. Each comes in a form for whole statements and a form
for a lone expression. There is no object layer; the tree is what
the crate's serde serialization produces, decoded from JSON. That keeps
the module small and makes every node the crate knows about available
without any wrapping, at the cost of a somewhat verbose structure.
Expand Down Expand Up @@ -62,6 +68,21 @@ C<teradata>.

=back

=head2 parse_expr

my $expr = parse_expr($sql);
my $expr = parse_expr($sql, dialect => $name);

Parses C<$sql> as a single expression, such as the condition of a C<WHERE>
clause, and returns it as a hash reference. The whole of C<$sql> must be
consumed by the expression; a leading C<WHERE> keyword or anything left
over after the expression is an error. Takes the same C<dialect> option
as L</parse>.

The result is exactly what appears inside a statement wherever the crate
expects an expression, so it can be spliced into a tree from L</parse>,
for instance as the C<selection> of a C<SELECT>.

=head2 unparse

my $sql = unparse($ast);
Expand All @@ -83,6 +104,15 @@ than on a single line, and are joined with C<";\n">.

=back

=head2 unparse_expr

my $sql = unparse_expr($expr);

Takes an expression hash reference, as returned by L</parse_expr> or
lifted out of a statement, and returns the SQL text. Throws an exception
if the structure does not deserialize into a valid expression. There is
no C<pretty> option; expressions are always rendered on one line.

=head1 THE DATA STRUCTURE

The tree mirrors the Rust types of the C<sqlparser> crate one to one, as
Expand Down Expand Up @@ -122,16 +152,16 @@ first.
Most nodes carry a C<span> hash recording where they appeared in the
source. L</unparse> ignores the contents but requires the field to be
present, so the easiest way to build a new node is to parse a small
snippet and lift the piece you need out of the result, rather than
constructing hashes by hand.
snippet (with L</parse_expr> for an expression) and lift the piece you
need out of the result, rather than constructing hashes by hand.

=back

The easiest way to learn the shape for a given construct is to parse an
example and dump it. The exact shape depends on the bundled crate
version, which is pinned in the distribution's C<ffi/Cargo.toml>; a
release that bumps it may change the structure and will say so in the
change log.
example and dump it. The exact shape depends on the version of the
crate the bindings are built against, which is pinned in the
distribution's C<ffi/Cargo.toml>; a release that bumps it may change the
structure and will say so in the change log.

=head1 CAVEATS

Expand All @@ -148,19 +178,15 @@ install time.

=over 4

=item L<https://github.com/apache/datafusion-sqlparser-rs>
=item L<https://crates.io/crates/sqlparser>

The parser this module wraps.

=item L<FFI::Platypus::Lang::Rust>

How the Rust code is bundled and called.

=back

=cut

our @EXPORT_OK = qw( parse unparse );
our @EXPORT_OK = qw( parse unparse parse_expr unparse_expr );

my $ffi = FFI::Platypus->new(
api => 2,
Expand All @@ -171,9 +197,11 @@ $ffi->bundle;

my $json = JSON::MaybeXS->new( utf8 => 1 );

$ffi->attach( _free => ['opaque'] => 'void' );
$ffi->attach( _parse => ['string', 'string', 'opaque*'] => 'opaque' );
$ffi->attach( _unparse => ['string', 'bool', 'opaque*'] => 'opaque' );
$ffi->attach( _free => ['opaque'] => 'void' );
$ffi->attach( _parse => ['string', 'string', 'opaque*'] => 'opaque' );
$ffi->attach( _parse_expr => ['string', 'string', 'opaque*'] => 'opaque' );
$ffi->attach( _unparse => ['string', 'bool', 'opaque*'] => 'opaque' );
$ffi->attach( _unparse_expr => ['string', 'opaque*'] => 'opaque' );

# Copy a Rust allocated C string into a Perl byte string and release it.
sub _take ($ptr) {
Expand All @@ -188,25 +216,41 @@ sub _call ($xsub, @args) {
unless(defined $ptr) {
my $msg = defined $err ? _take($err) : 'unknown error';
utf8::decode($msg);
Carp::croak($msg);
croak($msg);
}
return _take($ptr);
}

sub parse ($sql, %opt) {
sub _parse_with ($xsub, $sql, %opt) {
my $dialect = delete $opt{dialect} // 'generic';
Carp::croak("unknown options: @{[ sort keys %opt ]}") if %opt;
Carp::croak("sql must be defined") unless defined $sql;
croak("unknown options: @{[ sort keys %opt ]}") if %opt;
croak("sql must be defined") unless defined $sql;
utf8::encode($sql);
return $json->decode(_call(\&_parse, $dialect, $sql));
return $json->decode(_call($xsub, $dialect, $sql));
}

sub parse ($sql, %opt) {
return _parse_with(\&_parse, $sql, %opt);
}

sub parse_expr ($sql, %opt) {
return _parse_with(\&_parse_expr, $sql, %opt);
}

sub unparse ($ast, %opt) {
my $pretty = delete $opt{pretty} // 0;
Carp::croak("unknown options: @{[ sort keys %opt ]}") if %opt;
$ast = [$ast] if ref $ast eq 'HASH';
Carp::croak("ast must be an array or hash reference") unless ref $ast eq 'ARRAY';
croak("unknown options: @{[ sort keys %opt ]}") if %opt;
$ast = [$ast] if is_plain_hashref $ast;
croak("ast must be an array or hash reference") unless is_plain_arrayref $ast;
my $sql = _call(\&_unparse, $json->encode($ast), !!$pretty);
utf8::decode($sql);
return $sql;
}

sub unparse_expr ($expr, %opt) {
croak("unknown options: @{[ sort keys %opt ]}") if %opt;
croak("expr must be a hash reference") unless is_plain_hashref $expr;
my $sql = _call(\&_unparse_expr, $json->encode($expr));
utf8::decode($sql);
return $sql;
}
Loading
Loading