Add multi-statement SQL script support when executing inline script

https://forum.virtualmin.com/t/edit-databases-sql-query-box-strange-behavior/136988

[no-build]
This commit is contained in:
Ilia Ross
2026-04-14 23:02:15 +02:00
parent 87d8969efb
commit 3bd85ab407
3 changed files with 285 additions and 23 deletions

View File

@@ -9,6 +9,197 @@ $access{'edonly'} && &error($text{'dbase_ecannot'});
&error_setup($text{'exec_err'});
$sql_charset = $in{'charset'};
# normalize_sql_for_history(command)
# Collapse textarea SQL into a single history line.
sub normalize_sql_for_history
{
my ($cmd) = @_;
$cmd =~ s/\r//g;
return join(" ", split(/\n+/, $cmd));
}
# count_sql_terminators(sql)
# Count semicolons in SQL text, skipping those inside single/double/backtick
# quotes, line comments (# and --), and block comments (/* ... */). Used to
# detect multi-statement input. Miscounts should not happen in practice but are
# safe either way: on undercount, multi-statement SQL goes through the
# single-statement path and fails visibly; on overcount, a single statement goes
# through the file executor which handles it fine.
sub count_sql_terminators
{
my ($sql) = @_;
my $count = 0;
my ($sq, $dq, $bq, $line_comment, $block_comment, $escaped);
for (my $i = 0; $i < length($sql); $i++) {
my $c = substr($sql, $i, 1);
my $n = $i+1 < length($sql) ? substr($sql, $i+1, 1) : '';
if ($line_comment) {
$line_comment = 0 if ($c eq "\n");
next;
}
if ($block_comment) {
if ($c eq '*' && $n eq '/') {
$block_comment = 0;
$i++;
}
next;
}
if ($sq) {
if ($c eq "\\" && !$escaped) {
$escaped = 1;
next;
}
$sq = 0 if ($c eq "'" && !$escaped);
$escaped = 0;
next;
}
if ($dq) {
if ($c eq "\\" && !$escaped) {
$escaped = 1;
next;
}
$dq = 0 if ($c eq '"' && !$escaped);
$escaped = 0;
next;
}
if ($bq) {
$bq = 0 if ($c eq '`');
next;
}
if ($c eq '#' ) {
$line_comment = 1;
next;
}
if ($c eq '-' && $n eq '-') {
my $p = $i ? substr($sql, $i-1, 1) : '';
my $a = $i+2 < length($sql) ? substr($sql, $i+2, 1) : '';
if (($i == 0 || $p =~ /\s/) && ($a eq '' || $a =~ /\s/)) {
$line_comment = 1;
$i++;
next;
}
}
if ($c eq '/' && $n eq '*') {
$block_comment = 1;
$i++;
next;
}
if ($c eq "'") {
$sq = 1;
$escaped = 0;
next;
}
if ($c eq '"') {
$dq = 1;
$escaped = 0;
next;
}
if ($c eq '`') {
$bq = 1;
next;
}
if ($c eq ';') {
$count++;
}
}
return $count;
}
# looks_like_sql_script(command)
# Detect pasted SQL that should use the file-style script executor.
sub looks_like_sql_script
{
my ($cmd) = @_;
return 1 if ($cmd =~ /^\s*(?:delimiter|source|\\\.)\b/im);
return 1 if ($cmd =~ /^\s*(?:--(?:\s|$)|#)/m);
return &count_sql_terminators($cmd) > 1;
}
# execute_textarea_script(database, command)
# Run pasted SQL through the same path used for uploaded script files.
sub execute_textarea_script
{
my ($db, $cmd) = @_;
my $file = &transname();
&open_tempfile(TEMP, ">$file");
&print_tempfile(TEMP, $cmd);
&print_tempfile(TEMP, "\n") if ($cmd !~ /\n\z/);
&close_tempfile(TEMP);
my @rv = &execute_sql_file($db, $file, undef, undef, $access{'buser'});
&unlink_file($file);
return @rv;
}
# summarize_sql_script(sql)
# Count common DDL and DML actions for friendly success messages.
sub summarize_sql_script
{
my ($sql) = @_;
my %rv = ( 'create_count' => 0,
'insert_count' => 0 );
foreach my $line (split(/\n/, $sql)) {
if ($line =~ /^\s*insert\s+into\s+`(\S+)`/i ||
$line =~ /^\s*insert\s+into\s+(\S+)/i) {
$rv{'insert_count'}++;
}
if ($line =~ /^\s*create\s+table\s+`(\S+)`/i ||
$line =~ /^\s*create\s+table\s+(\S+)/i) {
$rv{'create_count'}++;
}
}
return \%rv;
}
# extract_execute_error_text(error)
# Strip Webmin's wrapper text down to the database error itself.
sub extract_execute_error_text
{
my ($err) = @_;
$err =~ s/\s+at\s+\S+\s+line\s+\d+.*$//s;
if ($err =~ /failed\s*:\s*<tt>(.*?)<\/tt>\s*$/s) {
$err = $1;
}
elsif ($err =~ /failed\s*:\s*"([^"]+)"\s*$/s) {
$err = $1;
}
elsif ($err =~ /failed\s*:\s*(.*?)\s*$/s) {
$err = $1;
}
$err =~ s/<[^>]+>//g;
# Normalize to plain text: undo any HTML escaping from shared helpers
# so the caller can re-escape once at final display time.
$err = &html_unescape($err);
return $err;
}
# print_exec_status_lines(first, rest...)
# Print one or more status lines with predictable wrapper spans.
sub print_exec_status_lines
{
my ($first, @rest) = @_;
print &ui_tag('span', $first, { 'data-first-print' => undef });
if (@rest) {
print &ui_tag('br');
for (my $i = 0; $i < @rest; $i++) {
print &ui_tag('span', $rest[$i],
{ 'data-second-print' => undef });
print &ui_tag('br') if ($i+1 < @rest);
}
}
}
# print_exec_error_block(first, second, detail)
# Print a wrapped error block with a preformatted message body.
sub print_exec_error_block
{
my ($first, $second, $detail) = @_;
$detail = defined($detail) && $detail =~ /\S/ ? $detail : $text{'exec_noout'};
&print_exec_status_lines($first, $second);
print &ui_tag('br');
print &ui_tag('pre', &html_escape($detail),
{ 'style' => 'white-space: pre-wrap; margin-left: 10px;' });
}
if ($in{'clear'}) {
# Delete the history file
&unlink_file($commands_file.".".$in{'db'});
@@ -16,33 +207,87 @@ if ($in{'clear'}) {
}
else {
# Run some SQL
$in{'cmd'} = join(" ", split(/[\r\n]+/, $in{'cmd'}));
$cmd = $in{'cmd'} ? $in{'cmd'} : $in{'old'};
$d = &execute_sql_logged($in{'db'}, $cmd);
$rawcmd = defined($in{'cmd'}) ? $in{'cmd'} : undef;
defined($rawcmd) && $rawcmd =~ /\S/ || &error($text{'exec_ecmd'});
$rawcmd =~ s/\r//g;
$cmd = &normalize_sql_for_history($rawcmd);
# Multi-statement input uses the same executor as uploaded SQL files.
$script = &looks_like_sql_script($rawcmd);
&ui_print_header(undef, $text{'exec_title'}, "");
print &text('exec_out', "<tt>".&html_escape($cmd)."</tt>"),"<p>\n";
@data = @{$d->{'data'}};
if (@data) {
print &ui_columns_start($d->{'titles'});
foreach $r (@data) {
print &ui_columns_row([ map { &html_escape($_) } @$r ]);
if ($script) {
($ex, $out) = &execute_textarea_script($in{'db'}, $rawcmd);
$summary = &summarize_sql_script($rawcmd);
&ui_print_header(undef, $text{'exec_title'}, "");
if ($ex) {
&print_exec_error_block($text{'exec_scriptout'},
$text{'exec_scriptfailed'},
$out);
}
else {
@lines = ( );
if ($summary->{'create_count'}) {
push(@lines, &text('exec_scriptcreated',
$summary->{'create_count'}));
}
if ($summary->{'insert_count'}) {
push(@lines, &text('exec_scriptinserted',
$summary->{'insert_count'}));
}
if (!@lines) {
push(@lines, $text{'exec_scriptok'});
}
&print_exec_status_lines($text{'exec_scriptout'},
@lines);
print "<p>\n";
}
print &ui_columns_end();
}
else {
print "<b>$text{'exec_none'}</b> <p>\n";
eval {
local $main::error_must_die = 1;
# Capture DBI/mysql errors so we can render them consistently.
$d = &execute_sql_logged($in{'db'}, $cmd);
};
if ($@) {
$failed = 1;
$err = &extract_execute_error_text($@);
&ui_print_header(undef, $text{'exec_title'}, "");
&print_exec_error_block($text{'exec_cmdout'},
$text{'exec_cmdfailed'}, $err);
}
else {
@data = @{$d->{'data'}};
&ui_print_header(undef, $text{'exec_title'}, "");
if (@data) {
print &text('exec_out',
"<tt>".&html_escape($cmd)."</tt>"),"<p>\n";
print &ui_columns_start($d->{'titles'});
foreach $r (@data) {
print &ui_columns_row([
map { &html_escape($_) } @$r ]);
}
print &ui_columns_end();
}
else {
&print_exec_status_lines($text{'exec_cmdout'},
$text{'exec_cmdok'});
print "<p>\n";
}
}
}
&open_readfile(OLD, "$commands_file.$in{'db'}");
while(<OLD>) {
s/\r|\n//g;
$already++ if ($_ eq $in{'cmd'});
$already++ if ($_ eq $cmd);
}
close(OLD);
if (!$already && $in{'cmd'} =~ /\S/) {
# Only store successful single commands in history. Script runs are
# intentionally left out to avoid flattening multi-line SQL for re-use.
if (!$script && !$failed && !$already && $cmd =~ /\S/) {
&open_lock_tempfile(OLD, ">>$commands_file.$in{'db'}");
&print_tempfile(OLD, "$in{'cmd'}\n");
&print_tempfile(OLD, "$cmd\n");
&close_tempfile(OLD);
chmod(0700, "$commands_file.$in{'db'}");
}

View File

@@ -36,13 +36,21 @@ print &ui_form_start("exec.cgi", "form-data");
print &ui_hidden("db", $in{'db'});
print &ui_textarea("cmd", undef, 10, 70),"<br>\n";
if (@old) {
print $text{'exec_old'}," ",
&ui_select("old", undef,
[ map { [ $_, &html_escape(length($_) > 80 ?
substr($_, 0, 80).".." : $_) ] } @old ]),"\n",
&ui_button($text{'exec_edit'}, "movecmd", undef,
"onClick='cmd.value = old.options[old.selectedIndex].value'"),
" ",&ui_submit($text{'exec_clear'}, "clear"),"<br>\n";
$oldrow = $text{'exec_old_cmd'}."&nbsp;".
&ui_select("old", undef,
[ map { [ $_, &html_escape(length($_) > 80
? substr($_, 0, 80).".."
: $_) ] } @old ],
undef, undef, undef, undef,
"style='max-width: 40%;'")."\n".
&ui_button($text{'exec_edit'}, "movecmd", undef,
"onClick='cmd.value = ".
"old.options[old.selectedIndex].value'").
" ".&ui_submit($text{'exec_clear'}, "clear");
print &ui_tag('div', $oldrow,
{ 'style' => 'display: flex; align-items: center; '.
'flex-wrap: wrap; gap: 5px; '.
'margin-bottom: 5px;' });
}
print "$text{'exec_cs'}&nbsp;&nbsp;",$csel,"<br>\n";
print &ui_form_end([ [ undef, $text{'exec_exec'} ] ]);

View File

@@ -408,7 +408,7 @@ newdb_ecannot2=You are not allowed to create any more databases
exec_title=Execute SQL
exec_header=Enter an SQL command to execute on database $1 ..
exec_old=Or select a previous SQL command :
exec_old_cmd=Or select a previous SQL command
exec_exec=Execute
exec_clear=Clear History
exec_header2=Select an SQL commands file to execute on database $1.
@@ -416,7 +416,16 @@ exec_header2a=This can also be used to restore a MySQL backup, which is just a f
exec_file=From local file
exec_upload=From uploaded file
exec_err=Failed to execute SQL
exec_ecmd=No SQL command was entered
exec_cmdout=Output from submitted SQL command ..
exec_cmdok=.. SQL command executed successfully
exec_cmdfailed=.. SQL command failed :
exec_out=Output from SQL command $1 ..
exec_scriptout=Output from submitted SQL script ..
exec_scriptok=.. SQL script executed successfully
exec_scriptfailed=.. SQL script failed :
exec_scriptcreated=.. $1 tables were successfully created
exec_scriptinserted=.. $1 records were successfully inserted
exec_none=No data returned
exec_eupload=No file selected to upload
exec_efile=Local file does not exist