The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package SQL::TwoWay;
use strict;
use warnings FATAL => 'recursion';
use 5.010001; # Named capture
our $VERSION = "0.05";
use Carp ();
use Scalar::Util qw(reftype);

use parent qw(Exporter);

our @EXPORT = qw(two_way_sql);

our ($TOKEN_STR2ID, $TOKEN_ID2STR);
BEGIN {
    $TOKEN_STR2ID = +{
        VARIABLE => 1,
        SQL      => 2,
        IF       => 3,
        ELSE     => 4,
        END_     => 5,
    };
    $TOKEN_ID2STR = +{ reverse %$TOKEN_STR2ID };
}
use constant $TOKEN_STR2ID;

sub token2str {
    $TOKEN_ID2STR->{+shift}
}

sub two_way_sql {
    my ($sql, $params) = @_;

    my $tokens = tokenize_two_way_sql($sql);
    my $ast = parse_two_way_sql($tokens);
    my ($generated_sql, @binds) = process_two_way_sql($ast, $params);
    return ($generated_sql, @binds);
}

sub process_two_way_sql {
    my ($ast, $params) = @_;
    my ($sql, @binds);
    for my $node (@$ast) {
        if ($node->[0] eq IF) {
            my $name = $node->[1];
            unless (exists $params->{$name}) {
                Carp::croak("Unknown parameter for IF stmt: $name");
            }
            if ($params->{$name}) {
                my ($is, @ib) = process_two_way_sql($node->[2], $params);
                $sql .= $is;
                push @binds, @ib;
            } else {
                my ($is, @ib) = process_two_way_sql($node->[3], $params);
                $sql .= $is;
                push @binds, @ib;
            }
        } elsif ($node->[0] eq VARIABLE) {
            my $name = $node->[1];
            unless (exists $params->{$name}) {
                Carp::croak("Unknown parameter: $name");
            }

            if (reftype($params->{$name}) eq 'ARRAY') {
                $sql .= '('. join(',', ('?')x@{$params->{$name}}) .')';
                push @binds, @{$params->{$name}};
            } else {
                $sql .= '?';
                push @binds, $params->{$name};
            }
        } elsif ($node->[0] eq SQL) {
            $sql .= $node->[1];
        } else {
            Carp::croak("Unknown node: " . token2str($node->[0]));
        }
    }
    return ($sql, @binds);
}

sub parse_two_way_sql {
    my ($tokens) = @_;
    my @ast;
    while (@$tokens > 0) {
        push @ast, _parse_stmt($tokens);
    }
    return \@ast;
}

sub _parse_statements {
    my ($tokens) = @_;

    my @stmts;
    while (@$tokens && (
            $tokens->[0]->[0] == SQL
        ||  $tokens->[0]->[0] == VARIABLE
        ||  $tokens->[0]->[0] == IF
    )) {
        push @stmts, _parse_stmt($tokens);
    }
    return \@stmts;
}

sub _parse_stmt {
    my ($tokens) = @_;

    if ($tokens->[0]->[0] eq SQL || $tokens->[0]->[0] eq VARIABLE) {
        my $token = shift @$tokens;
        return [
            $token->[0],
            $token->[1]
        ];
    } elsif ($tokens->[0]->[0] eq IF) {
        return _parse_if_stmt($tokens);
    } else {
        Carp::croak("Unexpected token: " . token2str($tokens->[0]->[0]));
    }
}

sub _parse_if_stmt {
    my ($tokens) = @_;

    # IF
    my $if = shift @$tokens;

    # Parse statements
    my $if_block = _parse_statements($tokens);

    # ELSE block
    my $else_block = [];
    if ($tokens->[0]->[0] eq ELSE) {
        shift @$tokens; # remove ELSE
        $else_block = _parse_statements($tokens);
    }

    # And, there is END_
    unless ($tokens->[0]->[0] eq END_) {
        Carp::croak("Unexpected EOF in IF statement");
    }
    shift @$tokens; # remove END_

    return [
        IF, $if->[1], $if_block, $else_block
    ];
}

sub tokenize_two_way_sql {
    my $sql = shift;

    my @ret;
    my $NUMERIC_LITERAL = "-? [0-9.]+";
    my $STRING_LITERAL = q{ (?:
                                "
                                    (?:
                                        \\\\"
                                        | ""
                                        | [^"]
                                    )*
                                "
                                |
                                '
                                    (?:
                                        \\\\'
                                        | ''
                                        | [^']
                                    )*
                                '
                            ) };
    my $LITERAL = "(?: $STRING_LITERAL | $NUMERIC_LITERAL )";
    my $SINGLE_SLASH = '/ (?! \*)';
    $sql =~ s!
        # Variable /* $var */3
        (
            /\* \s+ \$ (?<variable> [A-Za-z0-9_-]+) \s+ \*/
            (?:
                # (3,2,4)
                $LITERAL | \(
                    (?: \s* $LITERAL \s* , \s* )*
                    $LITERAL
                \)
            )
        )
        |
        (?:
            /\* \s+ IF \s+ \$ (?<ifcond> [A-Za-z0-9_-]+) \s+ \*/
        )
        |
        (?<else>
            /\* \s+ ELSE \s+ \*/
        )
        |
        (?<end>
            /\* \s+ END \s+ \*/
        )
        |
        # Normal SQL strings
        (?<sql1> [^/]+ )
        |
        # Single slash character
        (?<sql2> $SINGLE_SLASH )
    !
        if (defined $+{variable}) {
            push @ret, [VARIABLE, $+{variable}]
        } elsif (defined $+{ifcond}) {
            push @ret, [IF, $+{ifcond}]
        } elsif (defined $+{else}) {
            push @ret, [ELSE]
        } elsif (defined $+{end}) {
            push @ret, [END_]
        } elsif (defined $+{sql1}) {
            push @ret, [SQL, $+{sql1}]
        } elsif (defined $+{sql2}) {
            push @ret, [SQL, $+{sql2}]
        } else {
            Carp::croak("Invalid sql: $sql");
        }
        ''
    !gex;

    return \@ret;
}

1;
__END__

=head1 NAME

SQL::TwoWay - Run same SQL in valid SQL and DBI placeholder.

=head1 SYNOPSIS

    use SQL::TwoWay;

    my $name = 'STARTING OVER';
    my ($sql, @binds) = two_way_sql(
        q{SELECT *
        FROM cd
        WHERE name=/* $name */"MASTERPIECE"}, {
        name => $name,
    });

    # $sql: SELECT * FROM cd WHERE name=?
    # $binds[0] = 'STARTING OVER'

=head1 DESCRIPTION

SQL::TwoWay is a way to support 2way SQL.

I guess building complex SQL using O/R Mapper or SQL builder, like SQL::Abstract is worth.
When you writing complex SQL, you should write SQL by your hand.

And then, you got a issue: "I can't run my query on MySQL console!". Yes.
A query like C<< SELECT * FROM cd WHERE name=? >> is not runnable on console because that contains placeholder.

So, the solution is SQL::TwoWay.

You can write a query like this.

    SELECT * FROM cd WHERE name=/* $name */"MASTERPIECE";

This query is 100% valid SQL.

And you can make C<<$sql>> and C<<@binds>> from this query. C<< SQL::TwoWay::two_way_sql() >> function convert this query.

Here is a example code:

    my ($sql, @binds) = two_way_sql(
        q{SELECT * FROM cd WHERE name=/* $name */"MASTERPIECE"},
        {
            name => 'STARTING OVER'
        }
    );

C<< $sql >> is:

    SELECT * FROM cd WHERE name=?;

And C<< @binds >> is:

    ('STARTING OVER')

So, you can use same SQL in MySQL console and Perl code. It means B<2way SQL>.

=head1 SYNTAX

=over 4

=item /* $var */4

=item /* $var */(1,2,3)

=item /* $var */"String"

Replace variables.

=item /* IF $cond */n=3/* ELSE */n=5/* END */

=item /* IF $cond */n=3/* END */

=back

=head1 PSEUDO BNF

    if : /* IF $var */
    else : /* ELSE */
    end : /* END */
    variable : /* $var */ literal
    literal: TBD
    sql : .

    root = ( stmt )+
    stmt = sql | variable | if_stmt
    if_stmt = "IF" statement+ "ELSE" statement+ "END"
            | "IF" statement+ "END"

=head1 LICENSE

Copyright (C) tokuhirom

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=head1 AUTHOR

tokuhirom E<lt>tokuhirom@gmail.comE<gt>

=head1 SEE ALSO

L<s2dao|http://s2dao.seasar.org/en/index.html> supports 2 way SQL in Java.