File Coverage

lib/DB/Handy.pm
Criterion Covered Total %
statement 2532 2825 89.6
branch 1267 1928 65.7
condition 407 710 57.3
subroutine 152 159 95.6
pod 17 41 41.4
total 4375 5663 77.2


line stmt bran cond sub pod time code
1             package DB::Handy;
2             ######################################################################
3             #
4             # DB::Handy - Pure-Perl flat-file relational database with DBI-like interface
5             #
6             # https://metacpan.org/dist/DB-Handy
7             #
8             # Copyright (c) 2026 INABA Hitoshi
9             ######################################################################
10             #
11             # Compatible : Perl 5.005_03 and later
12             # Platform : Windows and UNIX/Linux
13             #
14             # FILE LAYOUT:
15             # //
16             # .sch schema (text, key=value lines)
17             # .dat records (fixed-length binary)
18             # ..idx sorted index (binary)
19             #
20             # INDEX FILE FORMAT (each entry is fixed-size):
21             # Header : "SDBIDX1\n" (8 bytes)
22             # Entries (sorted ascending by key_bytes):
23             # [key_bytes : keysize bytes][rec_no : 4 bytes big-endian uint32]
24             #
25             # Key encoding (byte order == value order):
26             # INT : sign-bit-flipped big-endian uint32
27             # FLOAT : IEEE 754 order-preserving 8-byte encoding
28             # other : NUL-padded fixed-width string
29             #
30             # SCHEMA FILE format for indexes:
31             # IDX=::
32             ######################################################################
33              
34 15     15   328107 use strict;
  15         35  
  15         1222  
35 15 50   15   539 BEGIN { if ($] < 5.006) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
36 15     15   89 use warnings; local $^W = 1;
  15         43  
  15         1394  
37 15 100   15   516 BEGIN { pop @INC if $INC[-1] eq '.' }
38 15     15   106 use Fcntl qw(:DEFAULT :flock);
  15         26  
  15         7204  
39 15     15   136 use File::Path ();
  15         38  
  15         509  
40 15     15   103 use File::Spec;
  15         27  
  15         392  
41 15     15   8563 use POSIX ();
  15         120516  
  15         701  
42              
43 15     15   160 use vars qw($VERSION $errstr);
  15         51  
  15         1715  
44             $VERSION = '1.05';
45             $VERSION = $VERSION;
46             $errstr = '';
47              
48             ###############################################################################
49             # Constants
50             ###############################################################################
51 15     15   110 use constant RECORD_ACTIVE => "\x01";
  15         31  
  15         1909  
52 15     15   108 use constant RECORD_DELETED => "\x00";
  15         28  
  15         861  
53 15     15   80 use constant MAX_VARCHAR => 255;
  15         27  
  15         821  
54 15     15   160 use constant IDX_MAGIC => "SDBIDX1\n";
  15         24  
  15         628  
55 15     15   89 use constant IDX_MAGIC_LEN => 8;
  15         23  
  15         2008  
56 15     15   74 use constant REC_NO_SIZE => 4;
  15         24  
  15         873511  
57              
58             my %TYPE_SIZE = (
59             INT => 4,
60             FLOAT => 8,
61             CHAR => undef,
62             VARCHAR => undef,
63             DATE => 10,
64             );
65              
66             ###############################################################################
67             # Constructor
68             ###############################################################################
69             sub new {
70 38     38 1 2431587 my($class, %args) = @_;
71             my $self = {
72             base_dir => ($args{base_dir} || 'simpledbms_data'),
73 38   50     439 db_name => ($args{db_name} || ''),
      50        
74             _tables => {},
75             _locks => {},
76             };
77 38         99 bless $self, $class;
78 38 100       751 unless (-d $self->{base_dir}) {
79 14         39 eval {
80 14         8704 File::Path::mkpath($self->{base_dir});
81             };
82 14 50       123 if ($@) {
83 0         0 $errstr = "Cannot create base_dir: $@";
84 0         0 return undef;
85             }
86             }
87 38         156 return $self;
88             }
89              
90             ###############################################################################
91             # Database-level
92             ###############################################################################
93             sub create_database {
94 15     15 1 126 my($self, $db_name) = @_;
95 15         83 my $path = $self->_db_path($db_name);
96 15 100       514 if (-d $path) {
97 1         4 $errstr = "Database '$db_name' already exists";
98 1         4 return 0;
99             }
100 14         39 eval {
101 14         2436 File::Path::mkpath($path);
102             };
103 14 50       116 if ($@) {
104 0         0 $errstr = "Cannot create database '$db_name': $@";
105 0         0 return 0;
106             }
107 14         99 return 1;
108             }
109              
110             sub use_database {
111 40     40 1 202 my($self, $db_name) = @_;
112 40         177 my $path = $self->_db_path($db_name);
113 40 100       668 unless (-d $path) {
114 3         11 $errstr = "Database '$db_name' does not exist";
115 3         21 return 0;
116             }
117 37         153 $self->{db_name} = $db_name;
118 37         123 $self->{_tables} = {};
119 37         302 return 1;
120             }
121              
122             sub drop_database {
123 1     1 1 12 my($self, $db_name) = @_;
124 1         3 my $path = $self->_db_path($db_name);
125 1 50       10 unless (-d $path) {
126 0         0 $errstr = "Database '$db_name' does not exist";
127 0         0 return 0;
128             }
129 1         2 eval {
130 1         295 File::Path::rmtree($path);
131             };
132 1 50       8 if ($@) {
133 0         0 $errstr = "Cannot drop database '$db_name': $@";
134 0         0 return 0;
135             }
136 1 50       5 $self->{db_name} = '' if $self->{db_name} eq $db_name;
137 1         5 return 1;
138             }
139              
140             sub list_databases {
141 4     4 1 67 my($self) = @_;
142 4         12 my $base = $self->{base_dir};
143 4         12 local *DH;
144 4 50       154 opendir(DH, $base) or do { $errstr = "Cannot open base_dir: $!"; return (); };
  0         0  
  0         0  
145 4 100       207 my @dbs = grep { !/^\./ && -d File::Spec->catdir($base, $_) } readdir(DH);
  12         189  
146 4         44 closedir DH;
147 4         35 return sort @dbs;
148             }
149              
150             ###############################################################################
151             # Table-level
152             ###############################################################################
153             sub create_table {
154 102     102 1 457 my($self, $table, $columns) = @_;
155 102 100       413 return $self->_err("No database selected") unless $self->{db_name};
156 101         384 my $sch_file = $self->_file($table, 'sch');
157 101 50       19756 return $self->_err("Table '$table' already exists") if -f $sch_file;
158              
159 101         508 my @cols;
160 101         198 my $rec_size = 1;
161 101         341 for my $col (@$columns) {
162 235         572 my($name, $type, $size) = @$col;
163 235         418 $type = uc($type);
164 235 50       655 return $self->_err("Unknown type '$type'") unless exists $TYPE_SIZE{$type};
165 235         332 my $store;
166 235 100       719 if ($type eq 'CHAR') {
    100          
167 7 50 33     82 return $self->_err("CHAR requires a size") unless $size && ($size > 0);
168 7         15 $store = int($size);
169             }
170             elsif ($type eq 'VARCHAR') {
171 79         159 $store = MAX_VARCHAR;
172             }
173             else {
174 149         295 $store = $TYPE_SIZE{$type};
175             }
176 235         330 $rec_size += $store;
177 235         1184 push @cols, { name=>$name, type=>$type, size=>$store };
178             }
179              
180 101         336 local *FH;
181 101 50       18991 open(FH, "> $sch_file") or return $self->_err("Cannot write schema: $!");
182 101         1769 print FH "VERSION=1\n";
183 101         368 print FH "RECSIZE=$rec_size\n";
184 101         284 for my $c (@cols) {
185 235         942 print FH "COL=$c->{name}:$c->{type}:$c->{size}\n";
186             }
187 101         4941 close FH;
188              
189 101         455 local *FH;
190 101 50       437 open(FH, "> ".$self->_file($table, 'dat')) or return $self->_err("Cannot create dat: $!");
191 101         1275 close FH;
192 101         1016 return 1;
193             }
194              
195             sub drop_table {
196 3     3 1 13 my($self, $table) = @_;
197 3 50       14 return $self->_err("No database selected") unless $self->{db_name};
198 3         12 my $sch = $self->_load_schema($table);
199 3 50 33     36 if ($sch && $sch->{indexes}) {
200 3         9 for my $ix (values %{$sch->{indexes}}) {
  3         14  
201 1         3 my $f = $self->_idx_file($table, $ix->{name});
202 1 50       121 unlink $f if -f $f;
203             }
204             }
205 3         12 for my $ext (qw(sch dat lck)) {
206 9         39 my $f = $self->_file($table, $ext);
207 9 100       740 unlink $f if -f $f;
208             }
209 3         17 my $dir = $self->_db_path($self->{db_name});
210 3         33 local *DH;
211 3 50       126 if (opendir DH, $dir) {
212 3         98 for my $f (readdir DH) {
213 26 50       153 unlink File::Spec->catfile($dir, $f) if $f =~ /^\Q${table}\E\.[^.]+\.idx$/;
214             }
215 3         37 closedir DH;
216             }
217 3         12 delete $self->{_tables}{$table};
218 3         97 return 1;
219             }
220              
221             sub list_tables {
222 6     6 1 73 my($self) = @_;
223 6 50       24 return $self->_err("No database selected") unless $self->{db_name};
224 6         29 my $dir = $self->_db_path($self->{db_name});
225 6         21 local *DH;
226 6 50       284 opendir(DH, $dir) or return ();
227 6 100       245 my @tbls = map { /^(.+)\.sch$/ ? $1 : () } readdir DH;
  59         198  
228 6         111 closedir DH;
229 6         94 return sort @tbls;
230             }
231              
232             sub describe_table {
233 5     5 1 89 my($self, $table) = @_;
234 5 50       26 my $sch = $self->_load_schema($table) or return undef;
235 5         18 return $sch->{cols};
236             }
237              
238             ###############################################################################
239             # INDEX DDL
240             ###############################################################################
241             sub create_index {
242 34     34 1 120 my($self, $idxname, $table, $colname, $unique) = @_;
243 34 50       115 return $self->_err("No database selected") unless $self->{db_name};
244 34 50       124 my $sch = $self->_load_schema($table) or return undef;
245              
246 34         72 my($col_def) = grep { $_->{name} eq $colname } @{$sch->{cols}};
  86         318  
  34         102  
247 34 50       90 return $self->_err("Column '$colname' not found in '$table'") unless $col_def;
248 34 50       90 return $self->_err("Index '$idxname' already exists on '$table'") if $sch->{indexes}{$idxname};
249              
250 34 100       83 $unique = $unique ? 1 : 0;
251              
252 34         107 my $sch_file = $self->_file($table, 'sch');
253 34         92 local *FH;
254 34 50       1401 open(FH, ">> $sch_file") or return $self->_err("Cannot update schema: $!");
255 34         276 print FH "IDX=$idxname:$colname:$unique\n";
256 34         1436 close FH;
257              
258             $sch->{indexes}{$idxname} = {
259             name => $idxname,
260             col => $colname,
261             unique => $unique,
262             keysize => $col_def->{size},
263             coltype => $col_def->{type},
264 34         362 };
265              
266 34         195 return $self->_rebuild_index($table, $idxname);
267             }
268              
269             sub drop_index {
270 1     1 1 6 my($self, $idxname, $table) = @_;
271 1 50       6 return $self->_err("No database selected") unless $self->{db_name};
272 1 50       5 my $sch = $self->_load_schema($table) or return undef;
273 1 50       5 return $self->_err("Index '$idxname' does not exist on '$table'") unless $sch->{indexes}{$idxname};
274              
275 1         6 unlink $self->_idx_file($table, $idxname);
276 1         8 delete $sch->{indexes}{$idxname};
277 1         6 return $self->_rewrite_schema($table, $sch);
278             }
279              
280             sub list_indexes {
281 1     1 1 6 my($self, $table) = @_;
282 1 50       5 return $self->_err("No database selected") unless $self->{db_name};
283 1 50       4 my $sch = $self->_load_schema($table) or return undef;
284 1         2 return [ values %{$sch->{indexes}} ];
  1         6  
285             }
286              
287             ###############################################################################
288             # DML: INSERT
289             ###############################################################################
290             sub insert {
291 1626     1626 1 3795 my($self, $table, $row) = @_;
292 1626 50       5500 return $self->_err("No database selected") unless $self->{db_name};
293 1626 100       6236 my $sch = $self->_load_schema($table) or return undef;
294              
295             # UNIQUE check
296 1625         2913 for my $ix (values %{$sch->{indexes}}) {
  1625         5424  
297 1685 100       4888 next unless $ix->{unique};
298 34         72 my $val = $row->{$ix->{col}};
299 34 100       90 if ($self->_idx_lookup_exact($table, $ix, $val) >= 0) {
300 5         32 return $self->_err("UNIQUE constraint violated on '$ix->{name}' (col '$ix->{col}', value '$val')");
301             }
302             }
303              
304 1620         2316 for my $col (@{$sch->{cols}}) {
  1620         3649  
305 2863         5430 my $cn = $col->{name};
306 2863 100 100     13764 if ((!defined($row->{$cn}) || ($row->{$cn} eq '')) && defined $sch->{defaults}{$cn}) {
      100        
307 14         36 $row->{$cn} = $sch->{defaults}{$cn};
308             }
309             }
310 1620 50       2465 for my $cn (keys %{$sch->{notnull} || {}}) {
  1620         5187  
311 35 100 100     161 return $self->_err("NOT NULL constraint violated on column '$cn'") unless defined($row->{$cn}) && ($row->{$cn} ne '');
312             }
313 1613 50       2390 for my $cn (keys %{$sch->{checks} || {}}) {
  1613         4799  
314 11 100       60 return $self->_err("CHECK constraint failed on column '$cn'") unless eval_bool($sch->{checks}{$cn}, $row);
315             }
316 1610 50       5635 my $packed = $self->_pack_record($sch, $row) or return undef;
317 1610         5787 my $dat = $self->_file($table, 'dat');
318 1610         5209 local *FH;
319 1610 50       78916 open(FH, ">> $dat") or return $self->_err("Cannot open dat for append: $!");
320 1610         5624 binmode FH;
321 1610         5684 _lock_ex(\*FH);
322 1610         11975 my $file_size = (stat FH)[7];
323 1610         6582 my $rec_no = int($file_size / $sch->{recsize});
324 1610         18472 print FH $packed;
325 1610         4621 _unlock(\*FH);
326 1610         17939 close FH;
327              
328 1610         2733 for my $ix (values %{$sch->{indexes}}) {
  1610         6544  
329 1678         10780 $self->_idx_insert($table, $ix, $row->{$ix->{col}}, $rec_no);
330             }
331 1610         30760 return 1;
332             }
333              
334             sub delete_rows {
335 10     10 1 29 my($self, $table, $where_info) = @_;
336 10 50       110 return $self->_err("No database selected") unless $self->{db_name};
337 10 50       39 my $sch = $self->_load_schema($table) or return undef;
338 10         36 my $where_sub = _to_where_sub($where_info);
339 10         34 my $dat = $self->_file($table, 'dat');
340 10         29 my $recsize = $sch->{recsize};
341 10         24 my $count = 0;
342              
343 10         26 local *FH;
344 10 50       501 open(FH, "+< $dat") or return $self->_err("Cannot open dat for delete: $!");
345 10         40 binmode FH;
346 10         68 _lock_ex(\*FH);
347              
348 10         47 seek(FH, 0, 0);
349 10         25 my($pos, $rec_no) = (0, 0);
350 10         20 while (1) {
351 80         1111 seek(FH, $pos, 0);
352 80         147 my $raw = '';
353 80         941 my $n = read(FH, $raw, $recsize);
354 80 100 66     365 last unless defined($n) && ($n == $recsize);
355 70 100       217 if (substr($raw, 0, 1) ne RECORD_DELETED) {
356 65         238 my $row = $self->_unpack_record($sch, $raw);
357 65 100 66     224 if (!$where_sub || $where_sub->($row)) {
358 12         137 seek(FH, $pos, 0);
359 12         63 print FH RECORD_DELETED;
360 12         22 $count++;
361 12         23 for my $ix (values %{$sch->{indexes}}) {
  12         58  
362 9         56 $self->_idx_delete($table, $ix, $row->{$ix->{col}}, $rec_no);
363             }
364             }
365             }
366 70         129 $pos += $recsize;
367 70         111 $rec_no++;
368             }
369 10         76 _unlock(\*FH);
370 10         120 close FH;
371 10         80 return $count;
372             }
373              
374             ###############################################################################
375             # VACUUM
376             ###############################################################################
377             sub vacuum {
378 2     2 1 8 my($self, $table) = @_;
379 2 50       10 return $self->_err("No database selected") unless $self->{db_name};
380 2 50       9 my $sch = $self->_load_schema($table) or return undef;
381 2         8 my $dat = $self->_file($table, 'dat');
382 2         5 my $tmp = $dat . '.tmp';
383 2         5 my $recsize = $sch->{recsize};
384              
385 2         6 local *IN_FH;
386 2 50       97 open(IN_FH, "< $dat") or return $self->_err("Cannot open dat: $!");
387 2         10 local *OUT_FH;
388 2 50       374 open(OUT_FH, "> $tmp") or do { close IN_FH; return $self->_err("Cannot open tmp: $!"); };
  0         0  
  0         0  
389 2         10 binmode IN_FH;
390 2         3 binmode OUT_FH;
391 2         7 _lock_ex(\*IN_FH);
392              
393 2         3 my $kept = 0;
394 2         4 while (1) {
395 19         20 my $raw = '';
396 19         43 my $n = read(IN_FH, $raw, $recsize);
397 19 100 66     36 last unless defined($n) && ($n == $recsize);
398 17 100       23 if (substr($raw, 0, 1) ne RECORD_DELETED) {
399 13         26 print OUT_FH $raw;
400 13         15 $kept++;
401             }
402             }
403 2         6 _unlock(\*IN_FH);
404 2         16 close IN_FH;
405 2         61 close OUT_FH;
406 2 50       452 rename($tmp, $dat) or return $self->_err("Cannot replace dat: $!");
407              
408 2         7 for my $ix (values %{$sch->{indexes}}) {
  2         10  
409 3 50       16 $self->_rebuild_index($table, $ix->{name}) or return undef;
410             }
411 2         11 return $kept;
412             }
413              
414             ###############################################################################
415             # execute()
416             ###############################################################################
417             sub execute {
418 2354     2354 1 33266 my($self, $sql) = @_;
419 2354         31507 $sql =~ s/^\s+|\s+$//g;
420 2354         25749 $sql =~ s/\s+/ /g;
421              
422             # Detect subqueries: any SELECT that contains a nested (SELECT ...)
423             # Route through the subquery engine, but guard against infinite recursion
424             # by only routing non-trivial top-level statements (not pure SELECT).
425 2354 100       17303 if ($sql =~ /\(\s*SELECT\b/i) {
426              
427             # Only intercept DML/DDL statements and complex SELECTs here;
428             # pure inner SELECTs (called recursively) pass through normally.
429             # Top-level statements that may contain subqueries:
430 29 50       184 if ($sql =~ /^(?:SELECT|INSERT|UPDATE|DELETE)\b/i) {
431 29         113 return $self->execute_with_subquery($sql);
432             }
433             }
434              
435 2325 100       9428 if ($sql =~ /^CREATE\s+DATABASE\s+(\w+)$/i) {
436 4 50       21 return $self->create_database($1)
437             ? { type=>'ok', message=>"Database '$1' created" }
438             : { type=>'error', message=>$errstr };
439             }
440 2321 100       8086 if ($sql =~ /^USE\s+(\w+)$/i) {
441 29 100       106 return $self->use_database($1)
442             ? { type=>'ok', message=>"Using database '$1'" }
443             : { type=>'error', message=>$errstr };
444             }
445 2292 50       6959 if ($sql =~ /^DROP\s+DATABASE\s+(\w+)$/i) {
446 0 0       0 return $self->drop_database($1)
447             ? { type=>'ok', message=>"Database '$1' dropped" }
448             : { type=>'error', message=>$errstr };
449             }
450 2292 100       8245 if ($sql =~ /^SHOW\s+DATABASES$/i) {
451 1         5 return { type=>'list', data=>[ $self->list_databases() ] };
452             }
453 2291 100       6174 if ($sql =~ /^SHOW\s+TABLES$/i) {
454 1         7 return { type=>'list', data=>[ $self->list_tables() ] };
455             }
456 2290 100       6242 if ($sql =~ /^SHOW\s+(?:INDEXES|INDICES|INDEX)\s+(?:ON|FROM)\s+(\w+)$/i) {
457 1         6 my $ixs = $self->list_indexes($1);
458 1 50       10 return defined($ixs)
459             ? { type=>'indexes', table=>$1, data=>$ixs }
460             : { type=>'error', message=>$errstr };
461             }
462 2289 100       6749 if ($sql =~ /^DESCRIBE\s+(\w+)$/i) {
463 2         8 my $cols = $self->describe_table($1);
464 2 50       12 return $cols
465             ? { type=>'describe', data=>$cols }
466             : { type=>'error', message=>$errstr };
467             }
468 2287 100       7676 if ($sql =~ /^CREATE\s+TABLE\s+(\w+)\s*\((.+)\)$/si) {
469 102         519 my($tbl, $col_str) = ($1, $2);
470 102         384 my @col_defs = _split_col_defs($col_str);
471 102         225 my(@cols, %nn, %defs, %chks, $pk);
472 102         215 for my $cd (@col_defs) {
473 236         1483 $cd =~ s/^\s+|\s+$//g;
474 236 50       581 if ($cd =~ /^PRIMARY\s+KEY\s*\(\s*(\w+)\s*\)$/si) {
475 0         0 $pk = $1;
476 0         0 next;
477             }
478 236         381 my($cn, $ct, $cs, $rest);
479 236 100       1265 if ($cd =~ /^(\w+)\s+(CHAR|VARCHAR)\s*\(\s*(\d+)\s*\)(.*)/si) {
    50          
480 86         451 ($cn, $ct, $cs, $rest) = ($1, uc($2), $3, $4);
481             }
482             elsif ($cd =~ /^(\w+)\s+(\w+)(.*)/si) {
483 150         638 ($cn, $ct, $rest) = ($1, uc($2), $3);
484 150         281 $cs = undef;
485             }
486             else {
487 0         0 return { type=>'error', message=>"Cannot parse column def: $cd" };
488             }
489 236         634 push @cols, [ $cn, $ct, $cs ];
490 236 50       582 $rest = '' unless defined $rest;
491 236 100       565 $pk = $cn if $rest =~ /\bPRIMARY\s+KEY\b/si;
492 236 100       655 $nn{$cn} = 1 if $rest =~ /\b(?:NOT\s+NULL|PRIMARY\s+KEY)\b/si;
493 236 100       615 $defs{$cn} = (defined($1) ? $1 : $2) if $rest =~ /\bDEFAULT\s+(?:'([^']*)'|(-?\d+\.?\d*))/si;
    100          
494 236 100       601 $chks{$cn} = $1 if $rest =~ /\bCHECK\s*\((.+)\)/si;
495             }
496 102 100       270 $nn{$pk} = 1 if defined $pk;
497 102 100       644 $self->create_table($tbl, [ @cols ]) or return { type=>'error', message=>$errstr };
498 101 50 100     1035 if (%nn || %defs || %chks || defined $pk) {
      66        
      66        
499 11 50       44 my $sch = $self->_load_schema($tbl) or return { type=>'error', message=>$errstr };
500 11         45 $sch->{notnull} = { %nn };
501 11         38 $sch->{defaults} = { %defs };
502 11         33 $sch->{checks} = { %chks };
503 11 100       25 $sch->{pk} = $pk if defined $pk;
504 11         55 $self->_rewrite_schema($tbl, $sch);
505             }
506 101         1201 return { type=>'ok', message=>"Table '$tbl' created" };
507             }
508 2185 100       7312 if ($sql =~ /^DROP\s+TABLE\s+(\w+)$/i) {
509 3 50       15 return $self->drop_table($1)
510             ? { type=>'ok', message=>"Table '$1' dropped" }
511             : { type=>'error', message=>$errstr };
512             }
513 2182 100       6525 if ($sql =~ /^CREATE\s+(UNIQUE\s+)?INDEX\s+(\w+)\s+ON\s+(\w+)\s*\(\s*(\w+)\s*\)$/i) {
514 34         333 my($uniq, $idxname, $tbl, $col) = ($1, $2, $3, $4);
515 34 100       218 return $self->create_index($idxname, $tbl, $col, $uniq ? 1 : 0)
    50          
516             ? { type=>'ok', message=>"Index '$idxname' created on '$tbl'('$col')" }
517             : { type=>'error', message=>$errstr };
518             }
519 2148 100       5837 if ($sql =~ /^DROP\s+INDEX\s+(\w+)\s+ON\s+(\w+)$/i) {
520 1 50       8 return $self->drop_index($1, $2)
521             ? { type=>'ok', message=>"Index '$1' dropped" }
522             : { type=>'error', message=>$errstr };
523             }
524 2147 100       6225 if ($sql =~ /^VACUUM\s+(\w+)$/i) {
525 2         12 my $n = $self->vacuum($1);
526 2 50       27 return defined($n)
527             ? { type=>'ok', message=>"Vacuum done, $n records kept" }
528             : { type=>'error', message=>$errstr };
529             }
530             # INSERT INTO table VALUES (...) -- no column list: use schema order
531 2145 100       12238 if ($sql =~ /^INSERT\s+INTO\s+(\w+)\s+VALUES\s*\((.+)\)$/i) {
532 10         42 my($tbl, $val_str) = ($1, $2);
533 10 100       58 my $sch = $self->_load_schema($tbl)
534             or return { type=>'error', message=>"Table '$tbl' does not exist" };
535 9         17 my @cols = map { $_->{name} } @{$sch->{cols}};
  33         79  
  9         24  
536 9         30 my @v = _parse_values($val_str);
537 9 100       24 if (@v != @cols) {
538 2         19 return { type=>'error',
539             message=>"INSERT: " . scalar(@v) . " value(s) for "
540             . scalar(@cols) . " column(s) in table '$tbl'" };
541             }
542 7         14 my %row;
543 7         36 @row{@cols} = @v;
544 7 50       50 return $self->insert($tbl, { %row })
545             ? { type=>'ok', message=>"1 row inserted" }
546             : { type=>'error', message=>$errstr };
547             }
548 2135 100       11648 if ($sql =~ /^INSERT\s+INTO\s+(\w+)\s*\(([^)]+)\)\s*VALUES\s*\((.+)\)$/i) {
549 1609         9391 my($tbl, $col_str, $val_str) = ($1, $2, $3);
550 1609         6732 my @c = map { my $x = $_; $x =~ s/^\s+|\s+\$//g; $x } split /,/, $col_str;
  2808         4112  
  2808         8222  
  2808         8085  
551 1609         6151 my @v = _parse_values($val_str);
552 1609         2786 my %row;
553 1609         6055 @row{@c} = @v;
554 1609 100       9040 return $self->insert($tbl, { %row })
555             ? { type=>'ok', message=>"1 row inserted" }
556             : { type=>'error', message=>$errstr };
557             }
558 526 100       1662 if ($sql =~ /^INSERT\s+INTO\s+(\w+)\s*\(([^)]+)\)\s+(SELECT\b.+)$/si) {
559 4         20 my($tbl, $cs, $sel) = ($1, $2, $3);
560 4         17 my @dst_cols = map { my $x = $_; $x =~ s/^\s+|\s+$//g; $x } split /,/, $cs;
  8         13  
  8         17  
  8         22  
561              
562             # Extract SELECT column list in declaration order.
563             # The engine stores rows as hashes (alphabetical key order), so we
564             # must parse the SELECT list to know the intended positional mapping.
565 4         8 my @src_cols;
566 4 50       26 if ($sel =~ /^SELECT\s+(.*?)\s+FROM\s+/si) {
567 4         17 @src_cols = map { my $c = $_; $c =~ s/^\s+|\s+$//g; $c =~ s/\s+AS\s+\w+$//si; $c } split /,/, $1;
  8         11  
  8         16  
  8         11  
  8         13  
568             }
569 4         43 my $res = $self->execute($sel);
570 4 50       20 return { type=>'error', message=>$res->{message} } if $res->{type} eq 'error';
571 4         6 my $n = 0;
572 4         8 for my $r (@{$res->{data}}) {
  4         12  
573              
574             # Map SELECT columns to INSERT columns by position:
575             # dst_cols[i] <- r->{ src_cols[i] }
576             # When column names match (same-name case), this is identical
577             # to a name-based lookup. When they differ (e.g. INSERT INTO
578             # dst(a,b) SELECT x,y FROM src), the positional mapping is used.
579             # Fall back to alphabetical order when src_cols could not be
580             # parsed (e.g. SELECT *).
581 10 50       35 my @src_keys = @src_cols ? @src_cols : sort keys %$r;
582 10         14 my %row = ();
583 10         26 for my $i (0 .. $#dst_cols) {
584 20 50       59 $row{$dst_cols[$i]} = defined($src_keys[$i]) ? $r->{$src_keys[$i]} : undef;
585             }
586 10 50       89 $self->insert($tbl, { %row }) and $n++;
587             }
588 4         43 return { type=>'ok', message=>"$n row(s) inserted" };
589             }
590 522 100       2181 if ($sql =~ /^SELECT\b/i) {
591 487         2239 return $self->select($sql);
592             }
593 35 100       386 if ($sql =~ /^UPDATE\s+(\w+)\s+SET\s+(.+?)(\s+WHERE\s+.+)?$/si) {
594 25 100       198 my($tbl, $set_str, $wc) = ($1, $2, (defined($3) ? $3 : ''));
595 25         108 my %se = parse_set_exprs($set_str);
596 25         46 my $ws;
597 25 100       129 if ($wc =~ /\bWHERE\s+(.+)/si) {
598 24         101 (my $e = $1) =~ s/^\s+|\s+$//g;
599 24         67 $ws = where_sub($e);
600             }
601 25         244 my $n = $self->update($tbl, { %se }, $ws);
602 25 100       576 return defined($n)
603             ? { type=>'ok', message=>"$n row(s) updated" }
604             : { type=>'error', message=>$errstr };
605             }
606 10 50       90 if ($sql =~ /^DELETE\s+FROM\s+(\w+)(.*)?$/si) {
607 10 50       71 my($tbl, $rest) = ($1, (defined($2) ? $2 : ''));
608 10         21 my $ws;
609 10 50       51 if ($rest =~ /\bWHERE\s+(.+)/si) {
610 10         59 (my $e = $1) =~ s/^\s+|\s+$//g;
611 10         49 $ws = where_sub($e);
612             }
613 10         76 my $n = $self->delete_rows($tbl, $ws);
614 10 50       234 return defined($n)
615             ? { type=>'ok', message=>"$n row(s) deleted" }
616             : { type=>'error', message=>$errstr };
617             }
618 0         0 return { type=>'error', message=>"Unsupported SQL: $sql" };
619             }
620              
621             ###############################################################################
622             # SUBQUERY ENGINE
623             #
624             # Supported subquery positions:
625             #
626             # 1. WHERE col IN (SELECT single_col FROM ...)
627             # 2. WHERE col NOT IN (SELECT single_col FROM ...)
628             # 3. WHERE col OP (SELECT single_col FROM ...) OP = = != < > <= >=
629             # 4. WHERE EXISTS (SELECT ... FROM ...)
630             # 5. WHERE NOT EXISTS (SELECT ... FROM ...)
631             # 6. FROM (SELECT ...) AS alias -- derived table / inline view
632             # 7. SELECT (SELECT single_col ...) AS alias -- scalar subquery in SELECT list
633             #
634             # Nesting: subqueries may themselves contain subqueries (recursive expansion).
635             # Correlated subqueries: outer row values injected via _subq_context hashref.
636             ###############################################################################
637              
638             # ---------------------------------------------------------------------------
639             # Public wrapper: expand all subqueries in a SQL string, then execute.
640             # Called by execute() when a subquery token is detected.
641             # ---------------------------------------------------------------------------
642             sub execute_with_subquery {
643 33     33 0 85 my($self, $sql) = @_;
644              
645             # Handle derived table in FROM: FROM (SELECT ...) AS alias
646 33 100       271 if ($sql =~ /\bFROM\s*\(/i) {
647 4         18 return $self->_exec_derived_table($sql);
648             }
649              
650             # Handle scalar subqueries in SELECT list: SELECT (SELECT ...) AS alias
651 29 50       3076 if ($sql =~ /^SELECT\s*\(/i) {
652 0         0 return $self->_exec_scalar_select_subquery($sql);
653             }
654              
655             # Expand WHERE-clause subqueries iteratively (innermost first)
656 29         168 my $expanded = $self->_expand_where_subqueries($sql, {});
657 29 50       110 return $expanded if ref($expanded) eq 'HASH'; # error hash
658              
659             # If correlated subqueries remain (still contain (SELECT), use row-level evaluator
660 29 100       161 if ($expanded =~ /\(\s*SELECT\b/i) {
661 4         21 return $self->_exec_correlated_select($expanded);
662             }
663              
664 25         93 return $self->execute($expanded);
665             }
666              
667             # ---------------------------------------------------------------------------
668             # Execute a SELECT with correlated subqueries in the WHERE clause.
669             # Scans each row, evaluates the subquery with the row as outer context.
670             # ---------------------------------------------------------------------------
671             sub _exec_correlated_select {
672 4     4   18 my($self, $sql) = @_;
673              
674             # Must be a plain SELECT (no JOIN, no derived table)
675 4 50       42 unless ($sql =~ /^SELECT\s+(.+?)\s+FROM\s+(\w+)(.*)?$/i) {
676 0         0 return { type=>'error', message=>"Cannot execute correlated query: $sql" };
677             }
678 4 50       51 my($col_str, $tbl, $rest) = ($1, $2, (defined($3) ? $3 : ''));
679              
680 4 50       25 my $sch = $self->_load_schema($tbl) or return { type=>'error', message=>$errstr };
681              
682             # Parse col list
683 4         39 my @sel_cols;
684 4 50       39 unless ($col_str =~ /^\*$/) {
685 4         25 @sel_cols = map { my $x = $_; $x =~ s/^\s+|\s+\$//g; $x } split /,/, $col_str;
  4         18  
  4         23  
  4         20  
686             }
687              
688             # Strip ORDER BY / LIMIT / OFFSET
689 4         8 my %opts;
690 4 50       33 if ($rest =~ s/\bLIMIT\s+(\d+)//i) {
691 0         0 $opts{limit} = $1;
692             }
693 4 50       30 if ($rest =~ s/\bOFFSET\s+(\d+)//i) {
694 0         0 $opts{offset} = $1;
695             }
696 4 100       36 if ($rest =~ s/\bORDER\s+BY\s+(\w+)(?:\s+(ASC|DESC))?//i) {
697 1         6 $opts{order_by} = $1;
698 1 50       7 $opts{order_dir} = defined($2) ? $2 : 'ASC';
699             }
700              
701             # Extract WHERE expression
702 4         22 my $where_expr = '';
703 4 50       23 if ($rest =~ /\bWHERE\s+(.+)/i) {
704 4         271 $where_expr = $1;
705 4         72 $where_expr =~ s/^\s+|\s+$//g;
706             }
707              
708             # Parse conditions (may include subquery conditions)
709 4         23 my $conds = $self->_parse_conditions_with_subq($where_expr);
710 4         21 my $filter = $self->_compile_where_with_subq($conds);
711              
712             # Full scan with per-row subquery evaluation
713 4         28 my $dat = $self->_file($tbl, 'dat');
714 4         22 my $recsize = $sch->{recsize};
715 4         8 my @results;
716              
717 4         44 local *FH;
718 4 50       265 open(FH, "< $dat") or return { type=>'error', message=>"Cannot open dat: $!" };
719 4         19 binmode FH;
720 4         23 _lock_sh(\*FH);
721 4         11 my $rec_no = 0;
722 4         9 while (1) {
723 32         67 my $raw = '';
724 32         233 my $n = read(FH, $raw, $recsize);
725 32 100 66     154 last unless defined($n) && ($n == $recsize);
726 28 50       119 if (substr($raw, 0, 1) ne RECORD_DELETED) {
727 28         93 my $row = $self->_unpack_record($sch, $raw);
728              
729             # Make row available under both bare and table-qualified names
730 28         114 my %qrow = %$row;
731 28         45 for my $c (@{$sch->{cols}}) {
  28         84  
732 132         317 $qrow{"$tbl.$c->{name}"} = $row->{$c->{name}};
733             }
734 28 100       184 push @results, { %qrow } if $filter->({ %qrow });
735             }
736 28         161 $rec_no++;
737             }
738 4         21 _unlock(\*FH);
739 4         45 close FH;
740              
741             # ORDER BY
742 4 100       23 if (my $ob = $opts{order_by}) {
743 1 50       7 my $dir = lc(defined($opts{order_dir}) ? $opts{order_dir} : 'asc');
744             @results = sort {
745 1         6 my($va, $vb) = ($a->{$ob}, $b->{$ob});
  3         10  
746 3 50 33     31 my $cmp = (defined($va) && ($va =~ /^-?\d+\.?\d*$/) &&
    50          
    50          
747             defined($vb) && ($vb =~ /^-?\d+\.?\d*$/))
748             ? ($va <=> $vb)
749             : ((defined($va) ? $va : '') cmp (defined($vb) ? $vb : ''));
750 3 50       10 ($dir eq 'desc') ? -$cmp : $cmp;
751             } @results;
752             }
753              
754             # OFFSET / LIMIT
755 4 50       20 my $off = defined($opts{offset}) ? $opts{offset} : 0;
756 4 50       15 @results = splice(@results, $off) if $off;
757 4 50       16 if (defined $opts{limit}) {
758 0         0 my $last = $opts{limit} - 1;
759 0 0       0 $last = $#results if $last > $#results;
760 0         0 @results = @results[0..$last];
761             }
762              
763             # Column projection (remove table-qualified duplicates)
764 4         12 my @proj;
765 4         12 for my $r (@results) {
766 15         22 my %p;
767 15 50       30 if (@sel_cols) {
768 15         22 for my $c (@sel_cols) {
769 15         37 $p{$c} = $r->{$c};
770             }
771             }
772             else {
773              
774             # All bare columns
775 0         0 for my $c (@{$sch->{cols}}) {
  0         0  
776 0         0 $p{$c->{name}} = $r->{$c->{name}};
777             }
778             }
779 15         51 push @proj, { %p };
780             }
781              
782 4         233 return { type=>'rows', data=>[ @proj ] };
783             }
784              
785             # ---------------------------------------------------------------------------
786             # _expand_where_subqueries($sql, \%outer_row)
787             #
788             # Finds the innermost (SELECT ...) in a WHERE clause and replaces it with
789             # its evaluated result (a literal list or scalar). Repeats until no
790             # subqueries remain. Returns the rewritten SQL string, or error hashref.
791             # ---------------------------------------------------------------------------
792             sub _expand_where_subqueries {
793 29     29   89 my($self, $sql, $outer_row) = @_;
794 29   50     84 $outer_row ||= {};
795              
796 29         55 my $max_depth = 32;
797 29         62 my $iter = 0;
798              
799 29   66     204 while (($sql =~ /\(\s*SELECT\b/i) && ($iter++ < $max_depth)) {
800              
801             # Find the innermost (SELECT ...) -- i.e. the one with no nested (SELECT
802 27         128 my $pos = _find_innermost_subquery($sql);
803 27 50       103 last unless defined $pos;
804              
805 27         75 my($start, $end) = @$pos;
806 27         85 my $inner_sql = substr($sql, $start + 1, $end - $start - 1);
807 27         385 $inner_sql =~ s/^\s+|\s+$//g;
808              
809             # Determine context: what precedes the opening paren
810 27         82 my $prefix = substr($sql, 0, $start);
811              
812             # Detect correlated subquery: inner SQL contains tablename.colname
813             # references that are NOT from the inner query's own tables.
814             # Heuristic: if inner_sql has \w+\.\w+ patterns, check if those
815             # table-names appear in the inner FROM clause.
816 27 100       87 if (_subquery_is_correlated($inner_sql)) {
817              
818             # Cannot pre-evaluate; will be handled per-row in _compile_where_with_subq.
819             # Mark as a correlated subquery placeholder and stop expanding here.
820 4         33 last;
821             }
822              
823             # Inject outer row values for correlated references
824 23         93 my $resolved = $self->_resolve_correlated($inner_sql, $outer_row);
825              
826             # Execute the inner query
827 23         162 my $inner_res = $self->execute($resolved);
828 23 50 33     163 if (!$inner_res || ($inner_res->{type} eq 'error')) {
829 0 0       0 my $msg = $inner_res ? $inner_res->{message} : $errstr;
830 0         0 return { type=>'error', message=>"Subquery error: $msg" };
831             }
832              
833 23 50       43 my @inner_rows = @{ $inner_res->{data} || [] };
  23         102  
834              
835             # Determine what kind of subquery this is based on prefix context
836 23         42 my $replacement;
837 23 100 66     360 if (($prefix =~ /\bIN\s*$/i) || ($prefix =~ /\bNOT\s+IN\s*$/i)) {
    100          
838              
839             # IN / NOT IN: build a parenthesised list of literal values
840 12         20 my @vals;
841 12         32 for my $r (@inner_rows) {
842 17         47 my @rv = values %$r;
843 17 50       38 my $v = defined($rv[0]) ? $rv[0] : 'NULL';
844 17 100       72 if ($v =~ /^-?\d+\.?\d*$/) {
845 15         41 push @vals, $v;
846             }
847             else {
848 2         8 push @vals, "'$v'";
849             }
850             }
851 12 100       28 if (@vals) {
852 10         39 $replacement = '(' . join(',', @vals) . ')';
853             }
854             else {
855              
856             # Empty set: IN (NULL) never matches; NOT IN (NULL) always matches
857 2         5 $replacement = '(NULL)';
858             }
859             }
860             elsif ($prefix =~ /\b(?:EXISTS|NOT\s+EXISTS)\s*$/i) {
861              
862             # EXISTS / NOT EXISTS: replace the paren content with 1 or 0
863             # The EXISTS keyword stays; we replace just the (SELECT ...) with (1) or (0)
864 3 100       13 $replacement = @inner_rows ? '(1)' : '(0)';
865             }
866             else {
867              
868             # Scalar subquery (=, !=, <, >, <=, >=, or bare use)
869 8 50       22 if (@inner_rows > 1) {
870 0         0 return { type=>'error', message=>"Subquery returns more than one row" };
871             }
872 8 100       20 if (@inner_rows == 0) {
873 1         4 $replacement = 'NULL';
874             }
875             else {
876 7         89 my @rv = values %{ $inner_rows[0] };
  7         23  
877 7 50       22 my $v = defined($rv[0]) ? $rv[0] : 'NULL';
878 7 50       40 $replacement = ($v =~ /^-?\d+\.?\d*$/) ? $v : "'$v'";
879             }
880             }
881              
882             # Splice the replacement into the SQL
883 23         228 substr($sql, $start, $end - $start + 1) = $replacement;
884             }
885              
886 29         103 return $sql;
887             }
888              
889             # ---------------------------------------------------------------------------
890             # Detect whether a subquery SQL string contains correlated outer references.
891             # A subquery is correlated if it contains alias.colname where the alias
892             # is NOT one of the tables listed in its own FROM clause.
893             # ---------------------------------------------------------------------------
894             sub _subquery_is_correlated {
895 27     27   60 my($inner_sql) = @_;
896              
897             # Find tables in the inner FROM clause
898 27         53 my %inner_tables;
899              
900             # FROM t1 [AS a1] [JOIN t2 AS a2 ON ...]*
901 27 50       220 if ($inner_sql =~ /\bFROM\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/i) {
902 27 100       203 $inner_tables{ lc(defined($2) ? $2 : $1) } = 1;
903 27         92 $inner_tables{ lc($1) } = 1;
904             }
905 27         152 while ($inner_sql =~ /\bJOIN\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi) {
906 0 0       0 $inner_tables{ lc(defined($2) ? $2 : $1) } = 1;
907 0         0 $inner_tables{ lc($1) } = 1;
908             }
909              
910             # Look for alias.col references in WHERE clause
911 27         163 while ($inner_sql =~ /\b(\w+)\.(\w+)\b/g) {
912 5         25 my($tbl, $col) = (lc($1), $2);
913 5 100       40 return 1 unless $inner_tables{$tbl};
914             }
915 23         109 return 0;
916             }
917              
918             # ---------------------------------------------------------------------------
919             # Find the innermost (SELECT ...) -- the one whose content has no nested
920             # (SELECT. Returns [$start_pos, $end_pos] of the outer parens, or undef.
921             # ---------------------------------------------------------------------------
922             sub _find_innermost_subquery {
923 27     27   68 my($sql) = @_;
924 27         53 my $len = length($sql);
925 27         57 my $best_start;
926             my $best_end;
927              
928 27         38 my $i = 0;
929 27         112 while ($i < $len) {
930              
931             # Look for ( followed (possibly with spaces) by SELECT
932 2665 100       4423 if (substr($sql, $i, 1) eq '(' ) {
933              
934             # Check if this opens a SELECT
935 30         86 my $peek = substr($sql, $i+1);
936 30         96 $peek =~ s/^\s+//;
937 30 100       116 if ($peek =~ /^SELECT\b/i) {
938              
939             # Walk to matching close paren, check for no nested SELECT
940 29         74 my $depth = 1;
941 29         57 my $j = $i + 1;
942 29         165 my $has_nested = 0;
943 29         48 my $in_str = 0;
944 29   100     123 while (($j < $len) && ($depth > 0)) {
945 1486         1883 my $ch = substr($sql, $j, 1);
946 1486 100       2642 if ($ch eq "'") {
    100          
947              
948             # Toggle string mode
949 16         31 $in_str = !$in_str;
950             }
951             elsif (!$in_str) {
952 1430 100       2533 if ($ch eq '(') {
    100          
953 3         4 $depth++;
954              
955             # check for nested SELECT
956 3         9 my $p2 = substr($sql, $j+1);
957 3         11 $p2 =~ s/^\s+//;
958 3 100 66     17 $has_nested = 1 if ($depth > 1) && ($p2 =~ /^SELECT\b/i);
959             }
960             elsif ($ch eq ')') {
961 32         45 $depth--;
962             }
963             }
964 1486         3588 $j++;
965             }
966 29 100 66     118 if (($depth == 0) && !$has_nested) {
967              
968             # This is an innermost SELECT subquery
969 27         39 $best_start = $i;
970 27         65 $best_end = $j - 1;
971              
972             # Don't break -- we want the last (innermost) one found
973             }
974             }
975             }
976 2665         4184 $i++;
977             }
978              
979 27 50       165 return defined($best_start) ? [ $best_start, $best_end ] : undef;
980             }
981              
982             # ---------------------------------------------------------------------------
983             # _resolve_correlated($inner_sql, \%outer_row)
984             #
985             # Replace references to outer-row columns in a correlated subquery.
986             # Outer references appear as outer.colname or are matched when the column
987             # name exists in %outer_row but NOT in the inner query's table.
988             # Simple heuristic: replace outer.col tokens with the literal value.
989             # ---------------------------------------------------------------------------
990             sub _resolve_correlated {
991 51     51   153 my($self, $sql, $outer_row) = @_;
992 51 100       163 return $sql unless %$outer_row;
993              
994             # Build sorted list: longer (qualified) keys first so alias.col
995             # is replaced before bare col to avoid double-substitution.
996 28         363 my @keys = sort { length($b) <=> length($a) } keys %$outer_row;
  581         753  
997              
998 28         75 for my $qkey (@keys) {
999 264 50       736 my $val = defined($outer_row->{$qkey}) ? $outer_row->{$qkey} : 'NULL';
1000 264 100       1054 my $lit = ($val =~ /^-?\d+\.?\d*$/) ? $val : "'$val'";
1001              
1002 264 100       437 if (index($qkey, '.') >= 0) {
1003              
1004             # Qualified key: e.g. "employees.id"
1005             # Build regex that matches the full qualified token
1006 132         395 (my $pat = $qkey) =~ s/\./\\./g;
1007 132         12597 $sql =~ s/(?
1008             }
1009             else {
1010              
1011             # Bare key: only replace if NOT preceded by a dot
1012             # (avoids replacing "id" inside "employees.id" already handled above)
1013 132         9641 $sql =~ s/(?
1014             }
1015             }
1016 28         141 return $sql;
1017             }
1018              
1019             # ---------------------------------------------------------------------------
1020             # EXISTS / NOT EXISTS correlated subquery evaluation at runtime
1021             #
1022             # These must be evaluated per-outer-row, so they cannot be pre-expanded.
1023             # We detect them in _parse_conditions and defer evaluation.
1024             # ---------------------------------------------------------------------------
1025              
1026             # Enhanced _parse_conditions that understands subquery conditions.
1027             # Returns arrayref of condition hashrefs; subquery conditions have:
1028             # { type => 'subquery',
1029             # op => 'IN'|'NOT_IN'|'EXISTS'|'NOT_EXISTS'|'CMP',
1030             # col => colname, # for IN/NOT_IN/CMP
1031             # cmp_op => '='|..., # for CMP
1032             # subql => 'SELECT ...',
1033             # }
1034             sub _parse_conditions_with_subq {
1035 6     6   18 my($self, $expr) = @_;
1036 6         13 my @conds;
1037              
1038             # Split on AND (but not inside parens/strings)
1039 6         24 my @parts = _split_and_clauses($expr);
1040              
1041 6         17 for my $part (@parts) {
1042 6         113 $part =~ s/^\s+|\s+$//g;
1043              
1044             # EXISTS (SELECT ...)
1045 6 100       44 if ($part =~ /^(NOT\s+)?EXISTS\s*\((.+)\)\s*$/si) {
1046 3         17 my($neg, $subql) = ($1, $2);
1047 3         42 $subql =~ s/^\s+|\s+$//g;
1048 3 100       25 push @conds, {
1049             type => 'subquery',
1050             op => ($neg ? 'NOT_EXISTS' : 'EXISTS'),
1051             subql => $subql,
1052             };
1053 3         10 next;
1054             }
1055              
1056             # col [NOT] IN (SELECT ...)
1057 3 50       15 if ($part =~ /^([\w.]+)\s+(NOT\s+)?IN\s*\((\s*SELECT\b.+)\)\s*$/si) {
1058 0         0 my($col, $neg, $subql) = ($1, $2, $3);
1059 0         0 $subql =~ s/^\s+|\s+$//g;
1060 0 0       0 push @conds, {
1061             type => 'subquery',
1062             op => $neg ? 'NOT_IN' : 'IN',
1063             col => $col,
1064             subql => $subql,
1065             };
1066 0         0 next;
1067             }
1068              
1069             # col OP (SELECT ...)
1070 3 100       21 if ($part =~ /^([\w.]+)\s*(=|!=|<>|<=|>=|<|>)\s*\((\s*SELECT\b.+)\)\s*$/si) {
1071 1         12 my($col, $op, $subql) = ($1, uc($2), $3);
1072 1         21 $subql =~ s/^\s+|\s+$//g;
1073 1         13 push @conds, {
1074             type => 'subquery',
1075             op => 'CMP',
1076             cmp_op => $op,
1077             col => $col,
1078             subql => $subql,
1079             };
1080 1         37 next;
1081             }
1082              
1083             # Normal condition
1084 2 50       18 if ($part =~ /^(\w+)\s*(=|!=|<>|<=|>=|<|>|LIKE)\s*(?:'([^']*)'|(-?\d+\.?\d*))$/i) {
1085 2         16 my($col, $op, $sv, $nv) = ($1, $2, $3, $4);
1086 2 50       20 push @conds, { col=>$col, op=>uc($op), val=>defined($sv) ? $sv : $nv };
1087             }
1088             }
1089 6         22 return [ @conds ];
1090             }
1091              
1092             # Split WHERE expression on top-level AND (not inside parens or strings)
1093             sub _split_and_clauses {
1094 6     6   16 my($expr) = @_;
1095 6         10 my @parts;
1096 6         11 my $cur = '';
1097 6         11 my $depth = 0;
1098 6         7 my $in_str = 0;
1099 6         11 my $i = 0;
1100 6         12 my $len = length($expr);
1101              
1102 6         21 while ($i < $len) {
1103 334         448 my $ch = substr($expr, $i, 1);
1104 334 50 33     1451 if (($ch eq "'") && !$in_str) {
    50 33        
    50 66        
    100          
    100          
    50          
1105 0         0 $in_str = 1;
1106 0         0 $cur .= $ch;
1107             }
1108             elsif (($ch eq "'") && $in_str) {
1109 0         0 $in_str = 0;
1110 0         0 $cur .= $ch;
1111             }
1112             elsif ($in_str) {
1113 0         0 $cur .= $ch;
1114             }
1115             elsif ($ch eq '(') {
1116 4         7 $depth++;
1117 4         9 $cur .= $ch;
1118             }
1119             elsif ($ch eq ')') {
1120 4         10 $depth--;
1121 4         10 $cur .= $ch;
1122             }
1123             elsif (($depth == 0) && (substr($expr, $i, 5) =~ /^AND\s/i)) {
1124 0         0 push @parts, $cur;
1125 0         0 $cur = '';
1126 0         0 $i += 4; # skip "AND "
1127 0         0 next;
1128             }
1129             else {
1130 326         449 $cur .= $ch;
1131             }
1132 334         592 $i++;
1133             }
1134 6 50       51 push @parts, $cur if $cur =~ /\S/;
1135 6         28 return @parts;
1136             }
1137              
1138             # ---------------------------------------------------------------------------
1139             # Build a where-filter sub that handles subquery conditions (evaluated
1140             # at filter time with the candidate row as outer context).
1141             # ---------------------------------------------------------------------------
1142             sub _compile_where_with_subq {
1143 6     6   16 my($self, $conds) = @_;
1144 6 50 33 0   33 return sub { 1 } unless $conds && @$conds;
  0         0  
1145              
1146 6         12 my @plain;
1147             my @subq;
1148 6         18 for my $c (@$conds) {
1149 6 100 100     38 if (($c->{type} || '') eq 'subquery') {
1150 4         14 push @subq, $c;
1151             }
1152             else {
1153 2         5 push @plain, $c;
1154             }
1155             }
1156              
1157 6         92 my $plain_sub = _compile_where_from_conds([ @plain ]);
1158              
1159             return sub {
1160 36     36   78 my($row) = @_;
1161              
1162             # Plain conditions first (fast path)
1163 36 100 100     123 return 0 if $plain_sub && !$plain_sub->($row);
1164              
1165             # Subquery conditions (evaluated per row)
1166 34         64 for my $c (@subq) {
1167 28         63 my $op = $c->{op};
1168 28         137 my $subql = $self->_resolve_correlated($c->{subql}, $row);
1169 28         151 my $res = $self->execute($subql);
1170 28 50 33     180 my @rows = ($res && $res->{type} eq 'rows') ? @{$res->{data}} : ();
  28         94  
1171              
1172 28 100 33     133 if ($op eq 'EXISTS') {
    100          
    50          
    50          
1173 12 100       82 return 0 unless @rows;
1174             }
1175             elsif ($op eq 'NOT_EXISTS') {
1176 8 100       85 return 0 if @rows;
1177             }
1178             elsif (($op eq 'IN') || ($op eq 'NOT_IN')) {
1179 0 0       0 my $col_val = defined($row->{$c->{col}}) ? $row->{$c->{col}} : '';
1180 0         0 my $found = 0;
1181 0         0 for my $r (@rows) {
1182 0         0 my @rv = values %$r;
1183 0 0       0 my $rv = defined($rv[0]) ? $rv[0] : '';
1184 0   0     0 my $num = (($col_val =~ /^-?\d+\.?\d*$/) && ($rv =~ /^-?\d+\.?\d*$/));
1185 0 0       0 if ($num ? ($col_val == $rv) : ($col_val eq $rv)) {
    0          
1186 0         0 $found = 1;
1187 0         0 last;
1188             }
1189             }
1190 0 0 0     0 return 0 if $found && ($op eq 'NOT_IN');
1191 0 0 0     0 return 0 if !$found && ($op eq 'IN');
1192             }
1193             elsif ($op eq 'CMP') {
1194 8 50       20 return 0 if @rows > 1;
1195 8         15 my $rhs;
1196 8 100       21 if (@rows == 0) {
1197 4         9 $rhs = undef;
1198             }
1199             else {
1200 4         6 my @rv = values %{ $rows[0] };
  4         16  
1201 4         7 $rhs = $rv[0];
1202             }
1203 8 100       51 return 0 unless defined $rhs;
1204 4 50       21 my $lhs = defined($row->{$c->{col}}) ? $row->{$c->{col}} : '';
1205 4         11 my $cop = $c->{cmp_op};
1206 4   33     39 my $num = (($lhs =~ /^-?\d+\.?\d*$/) && ($rhs =~ /^-?\d+\.?\d*$/));
1207 4 50 33     34 if ($cop eq '=') {
    50          
    50          
    50          
    0          
    0          
1208 0 0       0 return 0 unless $num ? ($lhs == $rhs) : ($lhs eq $rhs);
    0          
1209             }
1210             elsif (($cop eq '!=') || ($cop eq '<>')) {
1211 0 0       0 return 0 unless $num ? ($lhs != $rhs) : ($lhs ne $rhs);
    0          
1212             }
1213             elsif ($cop eq '<') {
1214 0 0       0 return 0 unless $num ? ($lhs < $rhs) : ($lhs lt $rhs);
    0          
1215             }
1216             elsif ($cop eq '>') {
1217 4 50       23 return 0 unless $num ? ($lhs > $rhs) : ($lhs gt $rhs);
    50          
1218             }
1219             elsif ($cop eq '<=') {
1220 0 0       0 return 0 unless $num ? ($lhs <= $rhs) : ($lhs le $rhs);
    0          
1221             }
1222             elsif ($cop eq '>=') {
1223 0 0       0 return 0 unless $num ? ($lhs >= $rhs) : ($lhs ge $rhs);
    0          
1224             }
1225             }
1226             }
1227 21         284 return 1;
1228 6         72 };
1229             }
1230              
1231             # ---------------------------------------------------------------------------
1232             # Derived table: FROM (SELECT ...) AS alias [WHERE ...] [ORDER BY ...]
1233             #
1234             # Evaluates the inner SELECT, materialises the result as an in-memory
1235             # virtual table, then applies the outer WHERE/ORDER BY/LIMIT/OFFSET.
1236             # ---------------------------------------------------------------------------
1237             sub _exec_derived_table {
1238 4     4   13 my($self, $sql) = @_;
1239              
1240             # Parse: SELECT outer_cols FROM (inner_sql) AS alias [WHERE ...] [ORDER BY ...] [LIMIT] [OFFSET]
1241             # Step 1: find the outer SELECT list
1242 4 50       38 unless ($sql =~ /^SELECT\s+(.+?)\s+FROM\s*\(/si) {
1243 0         0 return { type=>'error', message=>"Cannot parse derived table query" };
1244             }
1245 4         16 my $outer_cols_str = $1;
1246              
1247             # Step 2: extract the (inner_sql) AS alias part using paren matching
1248 4         21 my $from_pos = index(lc($sql), 'from');
1249 4         12 my $paren_start = index($sql, '(', $from_pos);
1250 4 50       15 unless ($paren_start >= 0) {
1251 0         0 return { type=>'error', message=>"Cannot find subquery in FROM clause" };
1252             }
1253              
1254 4         36 my($inner_sql, $close_pos) = _extract_paren_content($sql, $paren_start);
1255 4 50       16 unless (defined $inner_sql) {
1256 0         0 return { type=>'error', message=>"Unmatched parentheses in FROM clause" };
1257             }
1258 4         62 $inner_sql =~ s/^\s+|\s+$//g;
1259              
1260             # Step 3: parse alias and trailing clauses after the closing paren
1261 4         11 my $after = substr($sql, $close_pos + 1);
1262 4         22 $after =~ s/^\s+//;
1263              
1264 4         10 my $alias;
1265 4 50       25 if ($after =~ s/^(?:AS\s+)?(\w+)\s*//i) {
1266 4         13 $alias = $1;
1267             }
1268             else {
1269 0         0 $alias = 'subq';
1270             }
1271              
1272             # Step 4: parse outer WHERE / ORDER BY / LIMIT / OFFSET
1273 4         9 my %outer_opts;
1274 4 100       21 if ($after =~ s/\bLIMIT\s+(\d+)//i) {
1275 1         5 $outer_opts{limit} = $1;
1276             }
1277 4 50       16 if ($after =~ s/\bOFFSET\s+(\d+)//i) {
1278 0         0 $outer_opts{offset} = $1;
1279             }
1280 4 100       21 if ($after =~ s/\bORDER\s+BY\s+([\w.]+)(?:\s+(ASC|DESC))?//i) {
1281 1         4 $outer_opts{order_by} = $1;
1282 1   50     8 $outer_opts{order_dir} = ($2 || 'ASC');
1283             }
1284              
1285 4         9 my $outer_where_str = '';
1286 4 100       20 if ($after =~ /\bWHERE\s+(.+)/i) {
1287 2         5 $outer_where_str = $1;
1288 2         13 $outer_where_str =~ s/^\s+|\s+$//g;
1289             }
1290              
1291             # Step 5: execute the inner query (recursing through execute_with_subquery)
1292 4         17 my $inner_res = $self->execute_with_subquery($inner_sql);
1293 4 50 33     34 if (!$inner_res || ($inner_res->{type} eq 'error')) {
1294 0 0       0 my $msg = $inner_res ? $inner_res->{message} : $errstr;
1295 0         0 return { type=>'error', message=>"Derived table subquery error: $msg" };
1296             }
1297              
1298 4 50       8 my @inner_rows = @{ $inner_res->{data} || [] };
  4         25  
1299              
1300             # Step 6: qualify column names with alias (for outer WHERE resolution)
1301 4         9 my @qualified_rows;
1302 4         16 for my $r (@inner_rows) {
1303 20         31 my %qr;
1304 20         49 for my $k (keys %$r) {
1305              
1306             # Strip existing alias prefix if any, re-prefix with outer alias
1307 40 50       111 my $bare = ($k =~ /\.(\w+)$/) ? $1 : $k;
1308 40         92 $qr{"$alias.$bare"} = $r->{$k};
1309 40         83 $qr{$bare} = $r->{$k}; # also keep bare for convenience
1310             }
1311 20         98 push @qualified_rows, { %qr };
1312             }
1313              
1314             # Step 7: apply outer WHERE
1315 4 100       21 if ($outer_where_str =~ /\S/) {
1316 2         13 my $conds = $self->_parse_conditions_with_subq($outer_where_str);
1317 2         8 my $filter = $self->_compile_where_with_subq($conds);
1318 2         5 @qualified_rows = grep { $filter->($_) } @qualified_rows;
  8         15  
1319             }
1320              
1321             # Step 8: ORDER BY
1322 4 100       23 if (my $ob = $outer_opts{order_by}) {
1323 1   50     6 my $dir = lc($outer_opts{order_dir} || 'asc');
1324             @qualified_rows = sort {
1325 1         8 my $va = defined($a->{$ob})
1326             ? $a->{$ob}
1327 17 50       37 : $a->{ ($ob =~ /\.(\w+)$/)[0] };
1328             my $vb = defined($b->{$ob})
1329             ? $b->{$ob}
1330 17 50       38 : $b->{ ($ob =~ /\.(\w+)$/)[0] };
1331 17 50 33     127 my $cmp = (defined($va) && ($va =~ /^-?\d+\.?\d*$/) &&
      0        
      0        
1332             defined($vb) && ($vb =~ /^-?\d+\.?\d*$/))
1333             ? ($va <=> $vb)
1334             : (($va || '') cmp ($vb || ''));
1335 17 50       38 ($dir eq 'desc') ? -$cmp : $cmp;
1336             } @qualified_rows;
1337             }
1338              
1339             # Step 9: OFFSET / LIMIT
1340 4   50     23 my $off = ($outer_opts{offset} || 0);
1341 4 50       14 @qualified_rows = splice(@qualified_rows, $off) if $off;
1342 4 100       18 if (defined $outer_opts{limit}) {
1343 1         4 my $last = $outer_opts{limit} - 1;
1344 1 50       5 $last = $#qualified_rows if $last > $#qualified_rows;
1345 1         10 @qualified_rows = @qualified_rows[0..$last];
1346             }
1347              
1348             # Step 10: outer column projection
1349 4         11 my @proj_rows;
1350 4 50       16 if ($outer_cols_str =~ /^\s*\*\s*$/) {
1351 0         0 @proj_rows = @qualified_rows;
1352             }
1353             else {
1354 4         22 my @want = map { my $x = $_; $x =~ s/^\s+|\s+\$//g; $x } split /,/, $outer_cols_str;
  4         9  
  4         25  
  4         19  
1355 4         12 for my $r (@qualified_rows) {
1356 13         22 my %p;
1357 13         21 for my $w (@want) {
1358 13 50 0     27 if (exists $r->{$w}) {
    0          
1359 13         33 $p{$w} = $r->{$w};
1360             }
1361             elsif ($w =~ /^$alias\.(\w+)$/ && exists $r->{$1}) {
1362 0         0 $p{$w} = $r->{$1};
1363             }
1364             else {
1365              
1366             # bare name search
1367 0         0 for my $k (keys %$r) {
1368 0 0 0     0 if (($k =~ /\.\Q$w\E$/) || ($k eq $w)) {
1369 0         0 $p{$w} = $r->{$k};
1370 0         0 last;
1371             }
1372             }
1373             }
1374             }
1375 13         62 push @proj_rows, { %p };
1376             }
1377             }
1378              
1379 4         113 return { type=>'rows', data=>[ @proj_rows ] };
1380             }
1381              
1382             # ---------------------------------------------------------------------------
1383             # Scalar subquery in SELECT list
1384             # SELECT (SELECT agg_col FROM t WHERE ...) AS label, other_col FROM main_tbl ...
1385             # ---------------------------------------------------------------------------
1386             sub _exec_scalar_select_subquery {
1387 0     0   0 my($self, $sql) = @_;
1388              
1389             # Strategy: collect all scalar subqueries in the SELECT list,
1390             # evaluate each, replace with the literal value, then execute the rest.
1391              
1392             # Find all top-level (SELECT ...) AS alias in the SELECT list
1393             # For simplicity: expand iteratively like WHERE subqueries
1394 0         0 my $expanded = $self->_expand_where_subqueries($sql, {});
1395 0 0       0 return $expanded if ref($expanded) eq 'HASH';
1396 0         0 return $self->execute($expanded);
1397             }
1398              
1399             # ---------------------------------------------------------------------------
1400             # Extract content between matching parens starting at $start_pos.
1401             # Returns ($content_without_outer_parens, $close_paren_pos).
1402             # ---------------------------------------------------------------------------
1403             sub _extract_paren_content {
1404 4     4   12 my($sql, $start_pos) = @_;
1405 4         9 my $len = length($sql);
1406 4         7 my $depth = 0;
1407 4         9 my $in_str = 0;
1408 4         18 for my $i ($start_pos .. $len-1) {
1409 204         367 my $ch = substr($sql, $i, 1);
1410 204 50 33     720 if (($ch eq "'") && !$in_str) {
    50 33        
    50          
1411 0         0 $in_str = 1;
1412             }
1413             elsif (($ch eq "'") && $in_str) {
1414 0         0 $in_str = 0;
1415             }
1416             elsif (!$in_str) {
1417 204 100       543 if ($ch eq '(') {
    100          
1418 4         9 $depth++;
1419             }
1420             elsif ($ch eq ')') {
1421 4         5 $depth--;
1422 4 50       15 if ($depth == 0) {
1423 4         30 return (substr($sql, $start_pos+1, $i-$start_pos-1), $i);
1424             }
1425             }
1426             }
1427             }
1428 4         0 return (undef, undef);
1429             }
1430              
1431             ###############################################################################
1432             # Index internals
1433             ###############################################################################
1434              
1435             sub _idx_file {
1436 3613     3613   6883 my($self, $table, $idxname) = @_;
1437 3613         64185 File::Spec->catfile($self->{base_dir}, $self->{db_name}, "$table.$idxname.idx");
1438             }
1439              
1440             sub _encode_key {
1441 2028     2028   4730 my($type, $keysize, $val) = @_;
1442 2028 50       6468 $val = '' unless defined $val;
1443 2028 100       6647 if ($type eq 'INT') {
    100          
1444 1293   100     3891 my $iv = int($val || 0);
1445 1293 50       2964 $iv = 2147483647 if $iv > 2147483647;
1446 1293 50       3673 $iv = -2147483648 if $iv < -2147483648;
1447 1293         7729 return pack('N', ($iv & 0xFFFFFFFF) ^ 0x80000000);
1448             }
1449             elsif ($type eq 'FLOAT') {
1450              
1451             # my $packed = pack('d>', $val+0);
1452 113         493 my $packed = pack('d', $val+0);
1453 113 50       403 $packed = reverse($packed) if unpack("C", pack("S", 1));
1454              
1455 113         317 my @b = unpack('C8', $packed);
1456 113 100       245 if ($b[0] & 0x80) {
1457 2         3 @b = map { $_ ^ 0xFF } @b;
  16         19  
1458             }
1459             else {
1460 111         201 $b[0] ^= 0x80;
1461             }
1462 113         505 return pack('C8', @b);
1463             }
1464             else {
1465 622         1772 my $sv = substr($val, 0, $keysize);
1466 622         3296 $sv .= "\x00" x ($keysize - length($sv));
1467 622         2366 return $sv;
1468             }
1469             }
1470              
1471              
1472             sub _idx_entry_size {
1473 1877     1877   4322 $_[0]->{keysize} + REC_NO_SIZE;
1474             }
1475              
1476             sub _idx_read_all {
1477 1877     1877   3883 my($self, $table, $ix) = @_;
1478 1877         5603 my $idx_file = $self->_idx_file($table, $ix->{name});
1479 1877         5119 my $entry_size = _idx_entry_size($ix);
1480 1877         2925 my @entries;
1481 1877 50       42544 return [ @entries ] unless -f $idx_file;
1482 1877         6930 local *FH;
1483 1877 50       58904 open(FH, "< $idx_file") or return [ @entries ];
1484 1877         5426 binmode FH;
1485 1877         3827 my $magic = '';
1486 1877         39849 read(FH, $magic, IDX_MAGIC_LEN);
1487 1877 50       6135 unless ($magic eq IDX_MAGIC) {
1488 0         0 close FH;
1489 0         0 return [ @entries ];
1490             }
1491 1877         2736 while (1) {
1492 298783         367813 my $entry = '';
1493 298783         460320 my $n = read(FH, $entry, $entry_size);
1494 298783 100 66     727245 last unless defined($n) && ($n == $entry_size);
1495 296906         776624 push @entries, [ substr($entry, 0, $ix->{keysize}), unpack('N', substr($entry, $ix->{keysize}, REC_NO_SIZE)) ];
1496             }
1497 1877         21324 close FH;
1498 1877         33592 return [ @entries ];
1499             }
1500              
1501             sub _idx_write_all {
1502 1730     1730   4431 my($self, $table, $ix, $entries) = @_;
1503 1730         7666 my $idx_file = $self->_idx_file($table, $ix->{name});
1504 1730         7738 local *FH;
1505 1730 50       242053 open(FH, "> $idx_file") or return $self->_err("Cannot write index: $!");
1506 1730         7441 binmode FH;
1507 1730         8029 _lock_ex(\*FH);
1508 1730         23111 print FH IDX_MAGIC;
1509 1730         4810 for my $e (@$entries) {
1510 294631         612763 print FH $e->[0] . pack('N', $e->[1]);
1511             }
1512 1730         6181 _unlock(\*FH);
1513 1730         345289 close FH;
1514 1730         84320 return 1;
1515             }
1516              
1517             sub _idx_bisect {
1518 1949     1949   4970 my($entries, $key_bytes) = @_;
1519 1949         4265 my($lo, $hi) = (0, scalar @$entries);
1520 1949         4927 while ($lo < $hi) {
1521 11417         19034 my $mid = int(($lo + $hi) / 2);
1522 11417 100       21685 if ($entries->[$mid][0] lt $key_bytes) {
1523 8006         14324 $lo = $mid + 1;
1524             }
1525             else {
1526 3411         5754 $hi = $mid;
1527             }
1528             }
1529 1949         4312 return $lo;
1530             }
1531              
1532             sub _idx_lookup_exact {
1533 38     38   74 my($self, $table, $ix, $val) = @_;
1534 38         100 my $key_bytes = _encode_key($ix->{coltype}, $ix->{keysize}, $val);
1535 38         122 my $entries = $self->_idx_read_all($table, $ix);
1536 38         100 my $pos = _idx_bisect($entries, $key_bytes);
1537 38   66     124 while (($pos < @$entries) && ($entries->[$pos][0] eq $key_bytes)) {
1538 9         70 return $pos;
1539             }
1540 29         122 return -1;
1541             }
1542              
1543             sub _idx_insert {
1544 1681     1681   5871 my($self, $table, $ix, $val, $rec_no) = @_;
1545 1681         5452 my $key_bytes = _encode_key($ix->{coltype}, $ix->{keysize}, $val);
1546 1681         5491 my $entries = $self->_idx_read_all($table, $ix);
1547 1681         5129 my $pos = _idx_bisect($entries, $key_bytes);
1548 1681         11140 splice(@$entries, $pos, 0, [$key_bytes, $rec_no]);
1549 1681         6418 return $self->_idx_write_all($table, $ix, $entries);
1550             }
1551              
1552             sub _idx_delete {
1553 12     12   35 my($self, $table, $ix, $val, $rec_no) = @_;
1554 12         40 my $key_bytes = _encode_key($ix->{coltype}, $ix->{keysize}, $val);
1555 12         43 my $entries = $self->_idx_read_all($table, $ix);
1556 12         35 my $pos = _idx_bisect($entries, $key_bytes);
1557 12         21 my $deleted = 0;
1558 12   33     57 while (($pos < @$entries) && ($entries->[$pos][0] eq $key_bytes)) {
1559 17 100       44 if ($entries->[$pos][1] == $rec_no) {
1560 12         28 splice(@$entries, $pos, 1);
1561 12         32 $deleted++;
1562 12         23 last;
1563             }
1564 5         13 $pos++;
1565             }
1566 12 50       63 return $self->_idx_write_all($table, $ix, $entries) if $deleted;
1567 0         0 return 1;
1568             }
1569              
1570             sub _idx_range {
1571 38     38   141 my($self, $table, $ix, $lo_val, $lo_inc, $hi_val, $hi_inc) = @_;
1572 38         137 my $entries = $self->_idx_read_all($table, $ix);
1573 38 50       150 return [] unless @$entries;
1574              
1575 38         91 my $lo_pos = 0;
1576 38 100       111 if (defined $lo_val) {
1577 31         127 my $lo_key = _encode_key($ix->{coltype}, $ix->{keysize}, $lo_val);
1578 31         139 $lo_pos = _idx_bisect($entries, $lo_key);
1579 31   66     248 $lo_pos++ while !$lo_inc && ($lo_pos < @$entries) && ($entries->[$lo_pos][0] eq $lo_key);
      100        
1580             }
1581 38         87 my $hi_pos = scalar @$entries;
1582 38 100       100 if (defined $hi_val) {
1583 23         84 my $hi_key = _encode_key($ix->{coltype}, $ix->{keysize}, $hi_val);
1584 23         66 my $p = _idx_bisect($entries, $hi_key);
1585 23   100     219 $p++ while $hi_inc && ($p < @$entries) && ($entries->[$p][0] eq $hi_key);
      100        
1586 23         44 $hi_pos = $p;
1587             }
1588 38         167 return [ map { $entries->[$_][1] } $lo_pos .. $hi_pos-1 ];
  174         704  
1589             }
1590              
1591             sub _rebuild_index {
1592 37     37   99 my($self, $table, $idxname) = @_;
1593 37 50       115 my $sch = $self->_load_schema($table) or return undef;
1594 37         82 my $ix = $sch->{indexes}{$idxname};
1595 37 50       93 return $self->_err("Index '$idxname' not found") unless $ix;
1596 37         126 my $dat = $self->_file($table, 'dat');
1597 37         120 my $recsize = $sch->{recsize};
1598 37         62 my @entries;
1599 37 50       673 if (-f $dat) {
1600 37         101 local *FH;
1601 37 50       960 open(FH, "< $dat") or return $self->_err("Cannot read dat: $!");
1602 37         134 binmode FH;
1603 37         59 my $rec_no = 0;
1604 37         58 while (1) {
1605 116         163 my $raw = '';
1606 116         964 my $n = read(FH, $raw, $recsize);
1607 116 100 66     510 last unless defined($n) && ($n == $recsize);
1608 79 50       164 if (substr($raw, 0, 1) ne RECORD_DELETED) {
1609 79         175 my $row = $self->_unpack_record($sch, $raw);
1610 79         230 push @entries, [ _encode_key($ix->{coltype}, $ix->{keysize}, $row->{$ix->{col}}), $rec_no ];
1611             }
1612 79         120 $rec_no++;
1613             }
1614 37         358 close FH;
1615             }
1616 37         125 @entries = sort { $a->[0] cmp $b->[0] } @entries;
  162         254  
1617 37         197 return $self->_idx_write_all($table, $ix, [ @entries ]);
1618             }
1619              
1620             sub _find_index_for_conds {
1621 161     161   541 my($self, $table, $sch, $conds) = @_;
1622 161 50 33     679 return undef unless $conds && @$conds;
1623 161 100       226 return undef unless %{$sch->{indexes}};
  161         845  
1624 32         90 my %col2ix;
1625 32         50 for my $ix (values %{$sch->{indexes}}) {
  32         112  
1626 69         195 $col2ix{$ix->{col}} = $ix;
1627             }
1628 32         68 for my $c (@$conds) {
1629 32 50       151 my $ix = $col2ix{$c->{col}} or next;
1630 32         59 my $op = $c->{op};
1631 32 100       137 if ($op eq '=') {
    100          
    100          
    100          
    50          
1632 17         63 my $key_bytes = _encode_key($ix->{coltype}, $ix->{keysize}, $c->{val});
1633 17         100 my $entries = $self->_idx_read_all($table, $ix);
1634 17         55 my $pos = _idx_bisect($entries, $key_bytes);
1635 17         36 my @rec_nos;
1636 17   100     93 while (($pos < @$entries) && ($entries->[$pos][0] eq $key_bytes)) {
1637 26         56 push @rec_nos, $entries->[$pos][1];
1638 26         117 $pos++;
1639             }
1640 17         117 return [ @rec_nos ];
1641             }
1642             elsif ($op eq '<') {
1643 2         23 return $self->_idx_range($table, $ix, undef, 0, $c->{val}, 0);
1644             }
1645             elsif ($op eq '<=') {
1646 3         17 return $self->_idx_range($table, $ix, undef, 0, $c->{val}, 1);
1647             }
1648             elsif ($op eq '>') {
1649 4         26 return $self->_idx_range($table, $ix, $c->{val}, 0, undef, 0);
1650             }
1651             elsif ($op eq '>=') {
1652 6         31 return $self->_idx_range($table, $ix, $c->{val}, 1, undef, 0);
1653             }
1654             }
1655 0         0 return undef;
1656             }
1657              
1658             # _try_index_and_range($table, $sch, $where_expr)
1659             #
1660             # Attempt to satisfy a two-sided range or BETWEEN predicate using an index.
1661             # Recognises these WHERE patterns (same column, values numeric or quoted):
1662             # col OP1 val1 AND col OP2 val2 (e.g. id > 5 AND id < 10)
1663             # col BETWEEN val1 AND val2
1664             # Returns an arrayref of matching record numbers, or undef if no index
1665             # can be applied (caller falls through to a full table scan).
1666             #
1667             sub _try_index_and_range {
1668 272     272   832 my($self, $table, $sch, $where_expr) = @_;
1669 272 100       392 return undef unless %{$sch->{indexes}};
  272         1065  
1670 91         148 my %col2ix;
1671 91         145 for my $ix (values %{$sch->{indexes}}) {
  91         294  
1672 176         505 $col2ix{$ix->{col}} = $ix;
1673             }
1674 91         523 my $VAL = qr/(?:'([^']*)'|(-?\d+\.?\d*))/;
1675 91         287 my $OP = qr/(<=|>=|<|>)/;
1676             # BETWEEN col BETWEEN val1 AND val2
1677 91 100       2252 if ($where_expr =~ /^(\w+)\s+BETWEEN\s+$VAL\s+AND\s+$VAL\s*$/i) {
1678 6         44 my($col, $lo_s, $lo_n, $hi_s, $hi_n) = ($1, $2, $3, $4, $5);
1679 6 50       44 my $lo = defined($lo_s) ? $lo_s : $lo_n;
1680 6 50       22 my $hi = defined($hi_s) ? $hi_s : $hi_n;
1681 6 50       21 my $ix = $col2ix{$col} or return undef;
1682 6         33 return $self->_idx_range($table, $ix, $lo, 1, $hi, 1);
1683             }
1684             # AND: col OP val AND col OP val (same column)
1685 85 100       1905 if ($where_expr =~ /^(\w+)\s+$OP\s+$VAL\s+AND\s+\1\s+$OP\s+$VAL\s*$/i) {
1686 10         96 my($col, $op1, $v1s, $v1n, $op2, $v2s, $v2n) = ($1, $2, $3, $4, $5, $6, $7);
1687 10 50       43 my $v1 = defined($v1s) ? $v1s : $v1n;
1688 10 50       37 my $v2 = defined($v2s) ? $v2s : $v2n;
1689 10 50       42 my $ix = $col2ix{$col} or return undef;
1690             # Determine lo (lower bound) and hi (upper bound)
1691 10         48 my($lo, $lo_inc, $hi, $hi_inc);
1692 10 100 100     74 if ($op1 eq '>' || $op1 eq '>=') {
1693 9         29 ($lo, $lo_inc) = ($v1, $op1 eq '>=');
1694 9         27 ($hi, $hi_inc) = ($v2, $op2 eq '<=');
1695             }
1696             else {
1697 1         3 ($lo, $lo_inc) = ($v2, $op2 eq '>=');
1698 1         3 ($hi, $hi_inc) = ($v1, $op1 eq '<=');
1699             }
1700 10         58 return $self->_idx_range($table, $ix, $lo, $lo_inc, $hi, $hi_inc);
1701             }
1702 75         373 return undef;
1703             }
1704              
1705             # _try_index_partial_and($table, $sch, $where_expr)
1706             #
1707             # For AND expressions involving multiple columns, pick the single indexed
1708             # column that yields the smallest candidate set and return its record
1709             # numbers. The caller applies the full WHERE predicate as a post-filter,
1710             # so correctness is guaranteed regardless of which index is chosen.
1711             #
1712             # Recognises AND-connected atoms of the form:
1713             # col = val col > val col >= val col < val col <= val
1714             # (quoted or numeric values; no subexpressions, BETWEEN, IN, OR, NOT)
1715             #
1716             # Returns an arrayref of candidate record numbers, or undef when no
1717             # usable index is found (caller falls through to a full table scan).
1718             #
1719             sub _try_index_partial_and {
1720 256     256   682 my($self, $table, $sch, $where_expr) = @_;
1721 256 100       416 return undef unless %{$sch->{indexes}};
  256         875  
1722             # Only handle pure AND expressions (no OR/NOT/BETWEEN/IN/subqueries)
1723 75 100       454 return undef if $where_expr =~ /\b(?:OR|NOT|BETWEEN|IN)\b/i;
1724 13 50       73 return undef if $where_expr =~ /\(\s*SELECT\b/i;
1725             # Split on AND and collect simple col OP val atoms
1726 13         23 my @atoms;
1727 13         51 my $VAL = qr/(?:'[^']*'|-?\d+\.?\d*)/;
1728 13         38 my $OP = qr/(?:<=|>=|!=|<>|<|>|=)/;
1729 13         96 for my $part (split /\bAND\b/i, $where_expr) {
1730 27         209 $part =~ s/^\s+|\s+$//g;
1731 27 50 33     873 if ($part =~ /^(\w+)\s*($OP)\s*($VAL)$/
1732             || $part =~ /^($VAL)\s*($OP)\s*(\w+)$/) {
1733             # Normalise so col is always on the left
1734 27         57 my($col, $op, $val);
1735 27 50       533 if ($part =~ /^(\w+)\s*($OP)\s*($VAL)$/) {
1736 27         135 ($col, $op, $val) = ($1, uc($2), $3);
1737             }
1738             else {
1739             # val OP col -- reverse the operator
1740 0         0 $part =~ /^($VAL)\s*($OP)\s*(\w+)$/;
1741 0         0 my %rev = ('>' => '<', '<' => '>', '>=' => '<=',
1742             '<=' => '>=', '=' => '=', '!=' => '!=',
1743             '<>' => '<>');
1744 0   0     0 ($col, $op, $val) = ($3, $rev{uc($2)} || uc($2), $1);
1745             }
1746 27         118 $val =~ s/^'|'$//g; # strip surrounding quotes
1747 27         195 push @atoms, { col => $col, op => $op, val => $val };
1748             }
1749             else {
1750 0         0 return undef; # complex atom -- cannot use index safely
1751             }
1752             }
1753 13 50       44 return undef unless @atoms >= 2; # single atom handled by Case 1/2
1754             # Build column -> index map
1755 13         28 my %col2ix;
1756 13         26 for my $ix (values %{$sch->{indexes}}) {
  13         45  
1757 26         74 $col2ix{$ix->{col}} = $ix;
1758             }
1759             # Try each atom in turn; return the first index hit
1760             # (equality index preferred over range for a smaller candidate set)
1761 13         30 my $best_eq = undef; # record list from an equality match
1762 13         21 my $best_rng = undef; # record list from a range match
1763 13         31 for my $a (@atoms) {
1764 17 100       62 my $ix = $col2ix{$a->{col}} or next;
1765 13         27 my $op = $a->{op};
1766 13 50 33     73 next if $op eq '!=' || $op eq '<>'; # inequality gives no benefit
1767 13         38 my $recs;
1768 13 100       70 if ($op eq '=') {
    50          
    50          
    50          
    0          
1769 10         57 my $key = _encode_key($ix->{coltype}, $ix->{keysize}, $a->{val});
1770 10         44 my $entries = $self->_idx_read_all($table, $ix);
1771 10         40 my $pos = _idx_bisect($entries, $key);
1772 10         17 my @r;
1773 10   100     65 while (($pos < @$entries) && ($entries->[$pos][0] eq $key)) {
1774 34         60 push @r, $entries->[$pos][1];
1775 34         96 $pos++;
1776             }
1777 10         26 $recs = [ @r ];
1778             # Equality index: take first found and stop
1779 10 50       66 $best_eq = $recs and last;
1780             }
1781             elsif ($op eq '<') {
1782 0         0 $recs = $self->_idx_range($table, $ix, undef, 0, $a->{val}, 0);
1783             }
1784             elsif ($op eq '<=') {
1785 0         0 $recs = $self->_idx_range($table, $ix, undef, 0, $a->{val}, 1);
1786             }
1787             elsif ($op eq '>') {
1788 3         107 $recs = $self->_idx_range($table, $ix, $a->{val}, 0, undef, 0);
1789             }
1790             elsif ($op eq '>=') {
1791 0         0 $recs = $self->_idx_range($table, $ix, $a->{val}, 1, undef, 0);
1792             }
1793 3 50 33     21 $best_rng = $recs if defined $recs && !defined $best_rng;
1794             }
1795 13 100       103 return $best_eq if defined $best_eq;
1796 3         22 return $best_rng;
1797             }
1798              
1799             # _try_index_in($table, $sch, $where_expr)
1800             #
1801             # Attempt to satisfy a col IN (v1, v2, ...) or col NOT IN (v1, v2, ...)
1802             # predicate using an index. For IN, performs one equality lookup per value
1803             # and returns the union of matching record numbers. NOT IN is not optimised
1804             # (returns undef so the caller falls through to a full table scan).
1805             #
1806             # The WHERE expression must consist of exactly one IN predicate with a
1807             # literal value list (no sub-selects, no OR/AND, no NOT IN).
1808             #
1809             # Returns an arrayref of candidate record numbers, or undef when no index
1810             # can be applied.
1811             #
1812             sub _try_index_in {
1813 247     247   656 my($self, $table, $sch, $where_expr) = @_;
1814 247 100       354 return undef unless %{$sch->{indexes}};
  247         846  
1815             # Match: col IN (literal-list) no NOT IN, no sub-select
1816 66 100       315 return undef unless $where_expr =~ /^\s*(\w+)\s+IN\s*\(([^)]*)\)\s*$/si;
1817 31         113 my($col, $list_str) = ($1, $2);
1818             # Find index for this column
1819 31         37 my $ix;
1820 31         49 for my $candidate (values %{$sch->{indexes}}) {
  31         78  
1821 38 100       99 if ($candidate->{col} eq $col) {
1822 30         45 $ix = $candidate;
1823 30         51 last;
1824             }
1825             }
1826 31 100       60 return undef unless defined $ix;
1827             # Parse the value list
1828 30         61 my @vals;
1829 30         56 my $ls = $list_str;
1830 30         199 while ($ls =~ s/^\s*(?:'((?:[^']|'')*)'|(-?\d+\.?\d*)|(NULL))\s*(?:,|$)//i) {
1831 87         257 my($sv, $nv, $nl) = ($1, $2, $3);
1832 87 100       169 if (defined $nl) {
    100          
1833             # NULL in IN list: no index lookup possible for NULL
1834 1         6 return undef;
1835             }
1836             elsif (defined $sv) {
1837 9         21 (my $x = $sv) =~ s/''/'/g;
1838 9         39 push @vals, $x;
1839             }
1840             else {
1841 77         286 push @vals, $nv;
1842             }
1843             }
1844 29 50       57 return undef unless @vals; # empty IN list: caller handles
1845             # Perform one equality index lookup per value, union the results
1846 29         42 my %seen;
1847             my @rec_nos;
1848 29         110 my $entries = $self->_idx_read_all($table, $ix);
1849 29         77 for my $val (@vals) {
1850 85         204 my $key = _encode_key($ix->{coltype}, $ix->{keysize}, $val);
1851 85         168 my $pos = _idx_bisect($entries, $key);
1852 85   100     312 while (($pos < @$entries) && ($entries->[$pos][0] eq $key)) {
1853 100         138 my $rn = $entries->[$pos][1];
1854 100 100       346 push @rec_nos, $rn unless $seen{$rn}++;
1855 100         377 $pos++;
1856             }
1857             }
1858 29         526 return [ @rec_nos ];
1859             }
1860              
1861             # _try_index_or($table, $sch, $where_expr)
1862             #
1863             # Attempt to satisfy a pure OR expression using indexes.
1864             #
1865             # Every atom in the OR chain must be a simple condition that can be served
1866             # by an index on the relevant column. If any atom has no usable index the
1867             # function returns undef and the caller falls through to a full table scan.
1868             #
1869             # Recognised atom forms (same column or different columns):
1870             # col = val col != val (not optimised -- returns undef)
1871             # col OP val (OP: <, <=, >, >=)
1872             # col BETWEEN lo AND hi
1873             # col IN (v1, v2, ...)
1874             #
1875             # Returns an arrayref of deduplicated record numbers, or undef.
1876             #
1877             sub _try_index_or {
1878 218     218   542 my($self, $table, $sch, $where_expr) = @_;
1879 218 100       319 return undef unless %{$sch->{indexes}};
  218         779  
1880             # Must be a pure OR expression -- no AND, no NOT, no subqueries
1881 37 100       188 return undef if $where_expr =~ /\b(?:AND|NOT)\b/i;
1882 30 50       93 return undef if $where_expr =~ /\(\s*SELECT\b/i;
1883             # Split on OR
1884 30         96 my @atoms = DB::Handy::bool_split($where_expr, 'OR');
1885 30 100       68 return undef unless @atoms >= 2;
1886             # Build column -> index map
1887 28         44 my %col2ix;
1888 28         37 for my $ix (values %{$sch->{indexes}}) {
  28         80  
1889 56         121 $col2ix{$ix->{col}} = $ix;
1890             }
1891 28         97 my $VAL = qr/(?:'(?:[^']|'')*'|-?\d+\.?\d*)/;
1892 28         58 my $OP = qr/(?:<=|>=|<|>|=)/;
1893             # Collect record numbers for each atom
1894 28         52 my %seen;
1895             my @all_recs;
1896 28         67 for my $atom (@atoms) {
1897 60         379 $atom =~ s/^\s+|\s+$//g;
1898 60         104 my $recs;
1899             # col BETWEEN lo AND hi
1900 60 50       1419 if ($atom =~ /^(\w+)\s+BETWEEN\s+($VAL)\s+AND\s+($VAL)\s*$/i) {
    100          
    50          
1901 0         0 my($col, $lo, $hi) = ($1, $2, $3);
1902 0 0       0 my $ix = $col2ix{$col} or return undef;
1903 0         0 $lo =~ s/^'(.*)'$/$1/s; $hi =~ s/^'(.*)'$/$1/s;
  0         0  
1904 0         0 $recs = $self->_idx_range($table, $ix, $lo, 1, $hi, 1);
1905             }
1906             # col IN (val, ...)
1907             elsif ($atom =~ /^(\w+)\s+IN\s*\(([^)]*)\)\s*$/i) {
1908 3         16 my($col, $list) = ($1, $2);
1909 3 50       11 my $ix = $col2ix{$col} or return undef;
1910 3         13 $recs = $self->_try_index_in($table, $sch, $atom);
1911 3 50       13 return undef unless defined $recs;
1912             }
1913             # col OP val (equality or range, not !=/<>)
1914             elsif ($atom =~ /^(\w+)\s*($OP)\s*($VAL)$/) {
1915 57         282 my($col, $op, $val) = ($1, uc($2), $3);
1916 57 50 33     244 return undef if $op eq '!=' || $op eq '<>';
1917 57 100       198 my $ix = $col2ix{$col} or return undef;
1918 56         179 $val =~ s/^'(.*)'$/$1/s;
1919 56 100       126 if ($op eq '=') {
    50          
    100          
    50          
    50          
1920 52         151 my $key = _encode_key($ix->{coltype}, $ix->{keysize}, $val);
1921 52         159 my $entries = $self->_idx_read_all($table, $ix);
1922 52         142 my $pos = _idx_bisect($entries, $key);
1923 52         69 my @r;
1924 52   100     196 while (($pos < @$entries) && ($entries->[$pos][0] eq $key)) {
1925 407         612 push @r, $entries->[$pos][1];
1926 407         1413 $pos++;
1927             }
1928 52         526 $recs = [ @r ];
1929             }
1930 0         0 elsif ($op eq '<') { $recs = $self->_idx_range($table, $ix, undef, 0, $val, 0) }
1931 2         8 elsif ($op eq '<=') { $recs = $self->_idx_range($table, $ix, undef, 0, $val, 1) }
1932 0         0 elsif ($op eq '>') { $recs = $self->_idx_range($table, $ix, $val, 0, undef, 0) }
1933 2         10 elsif ($op eq '>=') { $recs = $self->_idx_range($table, $ix, $val, 1, undef, 0) }
1934             }
1935             else {
1936 0         0 return undef; # complex atom: cannot use index
1937             }
1938 59 50       137 return undef unless defined $recs;
1939 59         115 for my $rn (@$recs) {
1940 424 100       1694 push @all_recs, $rn unless $seen{$rn}++;
1941             }
1942             }
1943 27         397 return [ @all_recs ];
1944             }
1945              
1946             ###############################################################################
1947             # JOIN -- Public entry point
1948             ###############################################################################
1949             # join_select(\@join_specs, \@col_specs, \@where_conds, \%opts)
1950             #
1951             # join_specs : arrayref of hashrefs, in left-to-right order
1952             # { table => 'employees', # physical table name
1953             # alias => 'e', # alias (or same as table)
1954             # type => 'INNER'|'LEFT'|'RIGHT'|'CROSS',
1955             # on_left => 'e.dept_id', # undef for first/CROSS
1956             # on_right => 'd.id', # undef for first/CROSS
1957             # }
1958             #
1959             # col_specs : arrayref of 'alias.col' or 'alias.*' or '*'
1960             # undef = all columns (alias-qualified)
1961             #
1962             # where_conds : arrayref of condition hashrefs (from _parse_join_conditions)
1963             # { lhs_alias, lhs_col, op, rhs_alias, rhs_col, val }
1964             #
1965             # opts : { order_by=>'alias.col'|'col', order_dir=>'ASC', limit=>N, offset=>M }
1966             #
1967             sub join_select {
1968 30     30 0 108 my($self, $join_specs, $col_specs, $where_conds, $opts) = @_;
1969 30 50       132 return $self->_err("No database selected") unless $self->{db_name};
1970 30   50     91 $opts ||= {};
1971 30   50     75 $where_conds ||= [];
1972              
1973             # ------------------------------------------------------------------
1974             # Step 1: load schemas; build alias -> { table, schema } map
1975             # ------------------------------------------------------------------
1976 30         73 my %alias_info; # alias => { table, sch, rows(lazy) }
1977 30         77 for my $js (@$join_specs) {
1978 62 50       201 my $sch = $self->_load_schema($js->{table}) or return undef;
1979             $alias_info{ $js->{alias} } = {
1980             table => $js->{table},
1981 62         324 sch => $sch,
1982             };
1983             }
1984              
1985             # ------------------------------------------------------------------
1986             # Step 2: load the leftmost (driving) table fully into memory
1987             # ------------------------------------------------------------------
1988 30         69 my $first = $join_specs->[0];
1989 30         50 my @cur_rows = @{ $self->_scan_table_all($first->{table}, $first->{alias}) };
  30         123  
1990 30 50 33     142 return undef unless defined($cur_rows[0]) || !$self->{_last_err};
1991              
1992             # ------------------------------------------------------------------
1993             # Step 3: for each subsequent table, apply the JOIN
1994             # ------------------------------------------------------------------
1995 30         126 for my $i (1 .. $#$join_specs) {
1996 32         78 my $js = $join_specs->[$i];
1997 32   50     134 my $join_type = uc($js->{type} || 'INNER');
1998              
1999             # Parse ON alias1.col1 = alias2.col2
2000 32         74 my($on_l_alias, $on_l_col, $on_r_alias, $on_r_col);
2001 32 50 33     190 if ($js->{on_left} && $js->{on_right}) {
2002 32         109 ($on_l_alias, $on_l_col) = _split_qualified($js->{on_left});
2003 32         94 ($on_r_alias, $on_r_col) = _split_qualified($js->{on_right});
2004             }
2005              
2006             # Load the right-side table
2007 32         57 my @right_rows = @{ $self->_scan_table_all($js->{table}, $js->{alias}) };
  32         121  
2008              
2009             # Build hash on right side if possible (index-nested-loop join)
2010 32         78 my %right_hash;
2011 32         58 my $use_hash = 0;
2012 32 50 33     158 if (defined($on_r_alias) && defined($on_r_col)) {
2013 32         77 for my $rr (@right_rows) {
2014             my $rkey = defined($rr->{"$on_r_alias.$on_r_col"})
2015 144 100       405 ? $rr->{"$on_r_alias.$on_r_col"}
2016             : '';
2017 144         194 push @{ $right_hash{$rkey} }, $rr;
  144         441  
2018             }
2019 32         60 $use_hash = 1;
2020             }
2021              
2022 32         57 my @next_rows;
2023              
2024 32 100 66     243 if (($join_type eq 'CROSS') || (!defined $on_l_alias)) {
    100          
    100          
    50          
2025              
2026             # Cartesian product
2027 1         2 for my $lr (@cur_rows) {
2028 2         5 for my $rr (@right_rows) {
2029 6         22 push @next_rows, { %$lr, %$rr };
2030             }
2031             }
2032             }
2033             elsif ($join_type eq 'INNER') {
2034 24         58 for my $lr (@cur_rows) {
2035             my $lkey = defined($lr->{"$on_l_alias.$on_l_col"})
2036 151 50       404 ? $lr->{"$on_l_alias.$on_l_col"}
2037             : '';
2038 151 50 100     423 my $matches = $use_hash ? ($right_hash{$lkey} || []) : [ @right_rows ];
2039 151         267 for my $rr (@$matches) {
2040 131 50 33     284 next if ($use_hash == 0) && !_join_row_matches($lr, $rr, $on_l_alias, $on_l_col, $on_r_alias, $on_r_col);
2041 131         908 push @next_rows, { %$lr, %$rr };
2042             }
2043             }
2044             }
2045             elsif ($join_type eq 'LEFT') {
2046 6         14 for my $lr (@cur_rows) {
2047             my $lkey = defined($lr->{"$on_l_alias.$on_l_col"})
2048 35 50       101 ? $lr->{"$on_l_alias.$on_l_col"}
2049             : '';
2050             my $matches = $use_hash ? ($right_hash{$lkey} || [])
2051 35 50 100     149 : [ grep { _join_row_matches($lr, $_, $on_l_alias, $on_l_col, $on_r_alias, $on_r_col) }
  0         0  
2052             @right_rows
2053             ];
2054 35 100       74 if (@$matches) {
2055 29         69 for my $rr (@$matches) {
2056 32         300 push @next_rows, { %$lr, %$rr };
2057             }
2058             }
2059             else {
2060              
2061             # NULL-fill right side
2062 6         42 my %null_right = _make_null_row($js->{alias}, $alias_info{$js->{alias}}{sch});
2063 6         54 push @next_rows, { %$lr, %null_right };
2064             }
2065             }
2066             }
2067             elsif ($join_type eq 'RIGHT') {
2068              
2069             # RIGHT JOIN: swap sides, do LEFT, then results are correct
2070 1         4 for my $rr (@right_rows) {
2071 5 50       17 my $rkey = defined($rr->{"$on_r_alias.$on_r_col"}) ? $rr->{"$on_r_alias.$on_r_col"} : '';
2072 5         9 my $l_alias_key = "$on_l_alias.$on_l_col";
2073 5         7 my @matched_lefts;
2074 5         7 for my $lr (@cur_rows) {
2075 35 50       72 my $lkey = defined($lr->{$l_alias_key}) ? $lr->{$l_alias_key} : '';
2076 35 100       98 push @matched_lefts, $lr if $lkey eq $rkey;
2077             }
2078 5 100       12 if (@matched_lefts) {
2079 3         6 for my $lr (@matched_lefts) {
2080 6         40 push @next_rows, { %$lr, %$rr };
2081             }
2082             }
2083             else {
2084              
2085             # NULL-fill all left-side aliases seen so far
2086 2         5 my %null_left;
2087 2         8 for my $prev_js (@{$join_specs}[0..$i-1]) {
  2         6  
2088 2         12 my %nr = _make_null_row($prev_js->{alias}, $alias_info{$prev_js->{alias}}{sch});
2089 2         12 %null_left = (%null_left, %nr);
2090             }
2091 2         16 push @next_rows, { %null_left, %$rr };
2092             }
2093             }
2094             }
2095              
2096 32         413 @cur_rows = @next_rows;
2097             }
2098              
2099             # ------------------------------------------------------------------
2100             # Step 4: apply WHERE (post-join filter)
2101             # ------------------------------------------------------------------
2102 30 100       101 if (@$where_conds) {
2103 17         90 my $wsub = _compile_join_where($where_conds);
2104 17         49 @cur_rows = grep { $wsub->($_) } @cur_rows;
  95         180  
2105             }
2106              
2107             # ------------------------------------------------------------------
2108             # Step 5: ORDER BY
2109             # ------------------------------------------------------------------
2110 30 100       117 if (my $ob = $opts->{order_by}) {
2111 6   50     28 my $dir = lc($opts->{order_dir} || 'asc');
2112              
2113             # ob may be 'alias.col' or bare 'col'; normalise
2114             @cur_rows = sort {
2115 6         41 my $va = $a->{$ob};
  57         106  
2116 57         99 my $vb = $b->{$ob};
2117 57 50 33     489 my $cmp = (defined($va) && ($va =~ /^-?\d+\.?\d*$/) &&
      0        
      0        
2118             defined($vb) && ($vb =~ /^-?\d+\.?\d*$/))
2119             ? ($va <=> $vb)
2120             : (($va || '') cmp ($vb || ''));
2121 57 100       145 ($dir eq 'desc') ? -$cmp : $cmp;
2122             } @cur_rows;
2123             }
2124              
2125             # ------------------------------------------------------------------
2126             # Step 6: OFFSET / LIMIT
2127             # ------------------------------------------------------------------
2128 30   100     124 my $offset = ($opts->{offset} || 0);
2129 30 100       85 @cur_rows = splice(@cur_rows, $offset) if $offset;
2130 30 100       107 if (defined $opts->{limit}) {
2131 2         8 my $last = $opts->{limit} - 1;
2132 2 50       9 $last = $#cur_rows if $last > $#cur_rows;
2133 2         19 @cur_rows = @cur_rows[0..$last];
2134             }
2135              
2136             # ------------------------------------------------------------------
2137             # Step 7: column projection
2138             # ------------------------------------------------------------------
2139 30 100 66     217 if ($col_specs && @$col_specs) {
2140              
2141             # Expand wildcards: 'alias.*' or '*'
2142 26         57 my @expanded;
2143 26         63 for my $cs (@$col_specs) {
2144 54 50       227 if ($cs eq '*') {
    100          
2145              
2146             # all columns from all aliases
2147 0         0 for my $js (@$join_specs) {
2148 0         0 my $a = $js->{alias};
2149 0         0 my $sch = $alias_info{$a}{sch};
2150 0         0 for my $c (@{$sch->{cols}}) {
  0         0  
2151 0         0 push @expanded, "$a.$c->{name}";
2152             }
2153             }
2154             }
2155             elsif ($cs =~ /^(\w+)\.\*$/) {
2156 1         6 my $a = $1;
2157 1 50       6 my $sch = $alias_info{$a} ? $alias_info{$a}{sch} : undef;
2158 1 50       4 if ($sch) {
2159 1         4 for my $c (@{$sch->{cols}}) {
  1         3  
2160 4         12 push @expanded, "$a.$c->{name}";
2161             }
2162             }
2163             }
2164             else {
2165 53         135 push @expanded, $cs;
2166             }
2167             }
2168 26         41 my @proj_rows;
2169 26         59 for my $r (@cur_rows) {
2170 83         137 my %p;
2171 83         170 for my $ck (@expanded) {
2172              
2173             # Try qualified name first, then bare name
2174 177 50       376 if (exists $r->{$ck}) {
2175 177         430 $p{$ck} = $r->{$ck};
2176             }
2177             else {
2178              
2179             # bare name: find first matching qualified key
2180 0         0 for my $k (keys %$r) {
2181 0 0 0     0 if (($k =~ /\.\Q$ck\E$/) || ($k eq $ck)) {
2182 0         0 $p{$ck} = $r->{$k};
2183 0         0 last;
2184             }
2185             }
2186             }
2187             }
2188 83         394 push @proj_rows, { %p };
2189             }
2190 26         297 return [ @proj_rows ];
2191             }
2192              
2193 4         25 return [ @cur_rows ];
2194             }
2195              
2196             # Load all active rows from a table, qualifying each column as "alias.col"
2197             sub _scan_table_all {
2198 62     62   153 my($self, $table, $alias) = @_;
2199 62 50       163 my $sch = $self->_load_schema($table) or return [];
2200 62         170 my $dat = $self->_file($table, 'dat');
2201 62         172 my $recsize = $sch->{recsize};
2202 62         105 my @rows;
2203              
2204 62         211 local *FH;
2205 62 50       2924 open(FH, "< $dat") or do { $errstr = "Cannot open dat '$dat': $!"; return []; };
  0         0  
  0         0  
2206 62         231 binmode FH;
2207 62         281 _lock_sh(\*FH);
2208 62         134 while (1) {
2209 388         698 my $raw = '';
2210 388         2619 my $n = read(FH, $raw, $recsize);
2211 388 100 66     1678 last unless defined($n) && ($n == $recsize);
2212 326 50       801 next if substr($raw, 0, 1) eq RECORD_DELETED;
2213 326         852 my $raw_row = $self->_unpack_record($sch, $raw);
2214              
2215             # Qualify column names with alias
2216 326         565 my %qrow;
2217 326         460 for my $col (@{$sch->{cols}}) {
  326         658  
2218 1169         3238 $qrow{"$alias.$col->{name}"} = $raw_row->{$col->{name}};
2219             }
2220 326         2167 push @rows, { %qrow };
2221             }
2222 62         260 _unlock(\*FH);
2223 62         742 close FH;
2224 62         533 return [ @rows ];
2225             }
2226              
2227             # Build a row of NULLs for the given alias (for outer joins)
2228             sub _make_null_row {
2229 8     8   29 my($alias, $sch) = @_;
2230 8         16 my %row;
2231 8         17 for my $col (@{$sch->{cols}}) {
  8         25  
2232 27         81 $row{"$alias.$col->{name}"} = undef;
2233             }
2234 8         46 return %row;
2235             }
2236              
2237             # Split "alias.col" into (alias, col); if no dot, return (undef, col)
2238             sub _split_qualified {
2239 83     83   178 my($qname) = @_;
2240 83 50       434 if ($qname =~ /^(\w+)\.(\w+)$/) {
2241 83         412 return ($1, $2);
2242             }
2243 0         0 return (undef, $qname);
2244             }
2245              
2246             # Check if a pair of rows satisfies the ON equality condition
2247             sub _join_row_matches {
2248 0     0   0 my($lr, $rr, $la, $lc, $ra, $rc) = @_;
2249 0 0       0 my $lv = defined($la) ? $lr->{"$la.$lc"} : $lr->{$lc};
2250 0 0       0 my $rv = defined($ra) ? $rr->{"$ra.$rc"} : $rr->{$rc};
2251 0 0 0     0 return 0 unless defined($lv) && defined($rv);
2252              
2253             # numeric compare if both look numeric
2254 0 0 0     0 if (($lv =~ /^-?\d+\.?\d*$/) && ($rv =~ /^-?\d+\.?\d*$/)) {
2255 0 0       0 return (($lv == $rv) ? 1 : 0);
2256             }
2257 0 0       0 return (($lv eq $rv) ? 1 : 0);
2258             }
2259              
2260             ###############################################################################
2261             # JOIN WHERE compiler
2262             # Conditions from the WHERE clause after a JOIN may reference qualified
2263             # columns (alias.col) or bare column names.
2264             # Condition hashref keys:
2265             # lhs_alias, lhs_col -- left-hand side
2266             # op -- = != <> < > <= >= LIKE
2267             # rhs_alias, rhs_col -- right-hand side (column comparison) OR
2268             # val -- literal value
2269             ###############################################################################
2270             sub _compile_join_where {
2271 17     17   47 my($conds) = @_;
2272 17 50 33 0   98 return sub { 1 } unless $conds && @$conds;
  0         0  
2273             return sub {
2274 95     95   180 my($row) = @_;
2275 95         154 for my $c (@$conds) {
2276              
2277             # Resolve left-hand value
2278 101         155 my $lv;
2279 101 50       199 if (defined $c->{lhs_alias}) {
2280 101         263 $lv = $row->{"$c->{lhs_alias}.$c->{lhs_col}"};
2281             }
2282             else {
2283              
2284             # bare name: search qualified keys
2285 0         0 for my $k (keys %$row) {
2286 0 0 0     0 if (($k =~ /\.\Q$c->{lhs_col}\E$/) || ($k eq $c->{lhs_col})) {
2287 0         0 $lv = $row->{$k};
2288 0         0 last;
2289             }
2290             }
2291             }
2292 101 100       213 $lv = '' unless defined $lv;
2293              
2294             # Resolve right-hand value (literal or column)
2295 101         127 my $rv;
2296 101 50       191 if (defined $c->{rhs_col}) {
2297 0 0       0 if (defined $c->{rhs_alias}) {
2298 0         0 $rv = $row->{"$c->{rhs_alias}.$c->{rhs_col}"};
2299             }
2300             else {
2301 0         0 for my $k (keys %$row) {
2302 0 0 0     0 if (($k =~ /\.\Q$c->{rhs_col}\E$/) || ($k eq $c->{rhs_col})) {
2303 0         0 $rv = $row->{$k};
2304 0         0 last;
2305             }
2306             }
2307             }
2308             }
2309             else {
2310 101         198 $rv = $c->{val};
2311             }
2312 101 100       195 $rv = '' unless defined $rv;
2313              
2314 101         160 my $op = $c->{op};
2315              
2316             # IN / NOT IN
2317 101 100 66     356 if (($op eq 'IN') || ($op eq 'NOT_IN')) {
2318 8         8 my $lhs_val = $lv;
2319 8         11 my $found = 0;
2320 8         10 for my $cv (@{$c->{vals}}) {
  8         14  
2321 8 50       14 next unless defined $cv;
2322 8   33     81 my $num2 = (($lhs_val =~ /^-?\d+\.?\d*$/) && ($cv =~ /^-?\d+\.?\d*$/));
2323 8 50       23 if ($num2 ? ($lhs_val == $cv) : ($lhs_val eq $cv)) {
    100          
2324 4         5 $found = 1;
2325 4         9 last;
2326             }
2327             }
2328 8 50 66     39 return 0 if $found && ($op eq 'NOT_IN');
2329 8 100 66     29 return 0 if !$found && ($op eq 'IN');
2330 4         8 next;
2331             }
2332              
2333 93   66     496 my $num = (($lv =~ /^-?\d+\.?\d*$/) && ($rv =~ /^-?\d+\.?\d*$/));
2334              
2335 93 100 33     258 if ($op eq '=') {
    50          
    50          
    100          
    50          
    50          
    0          
2336 81 100       722 return 0 unless $num ? ($lv == $rv) : ($lv eq $rv);
    100          
2337             }
2338             elsif (($op eq '!=') || ($op eq '<>')) {
2339 0 0       0 return 0 unless $num ? ($lv != $rv) : ($lv ne $rv);
    0          
2340             }
2341             elsif ($op eq '<') {
2342 0 0       0 return 0 unless $num ? ($lv < $rv) : ($lv lt $rv);
    0          
2343             }
2344             elsif ($op eq '>') {
2345 9 50       46 return 0 unless $num ? ($lv > $rv) : ($lv gt $rv);
    100          
2346             }
2347             elsif ($op eq '<=') {
2348 0 0       0 return 0 unless $num ? ($lv <= $rv) : ($lv le $rv);
    0          
2349             }
2350             elsif ($op eq '>=') {
2351 3 50       16 return 0 unless $num ? ($lv >= $rv) : ($lv ge $rv);
    100          
2352             }
2353             elsif ($op eq 'LIKE') {
2354 0         0 (my $p = $rv) =~ s/%/.*/g;
2355 0         0 $p =~ s/_/./g;
2356 0 0       0 return 0 unless $lv =~ /^$p$/i;
2357             }
2358             }
2359 26         151 return 1;
2360 17         162 };
2361             }
2362              
2363             ###############################################################################
2364             # JOIN SQL parser
2365             # Handles:
2366             # SELECT col_list
2367             # FROM t1 [AS a1]
2368             # [INNER|LEFT [OUTER]|RIGHT [OUTER]|CROSS] JOIN t2 [AS a2] ON a1.c = a2.c
2369             # [ JOIN t3 [AS a3] ON ... ]
2370             # [WHERE ...]
2371             # [ORDER BY alias.col [ASC|DESC]]
2372             # [LIMIT n] [OFFSET m]
2373             ###############################################################################
2374             sub _parse_join_sql {
2375 30     30   87 my($sql) = @_;
2376             # sql has been normalised: single spaces, trimmed
2377              
2378             # ---------------------------------------------------------------
2379             # 1. Extract SELECT column list and the FROM...rest portion
2380             # ---------------------------------------------------------------
2381 30 50       436 return undef unless $sql =~ /^SELECT\s+(.+?)\s+FROM\s+(.+)$/si;
2382 30         204 my($sel_str, $from_rest) = ($1, $2);
2383              
2384             # ---------------------------------------------------------------
2385             # 2. Strip trailing ORDER BY / LIMIT / OFFSET
2386             # (strip right-to-left to avoid greedy issues)
2387             # ---------------------------------------------------------------
2388 30         54 my %opts;
2389              
2390             # Strip suffixes right-to-left: OFFSET, LIMIT, ORDER BY
2391             # (ORDER BY may precede LIMIT/OFFSET, so strip LIMIT+OFFSET first)
2392 30 100       288 if ($from_rest =~ s/\s+OFFSET\s+(\d+)\s*$//i) {
2393 1         5 $opts{offset} = $1;
2394             }
2395 30 100       267 if ($from_rest =~ s/\s+LIMIT\s+(\d+)\s*$//i) {
2396 2         9 $opts{limit} = $1;
2397             }
2398 30 100       285 if ($from_rest =~ s/\s+ORDER\s+BY\s+([\w.]+)(?:\s+(ASC|DESC))?\s*$//i) {
2399 7         26 $opts{order_by} = $1;
2400 7   100     46 $opts{order_dir} = ($2 || 'ASC');
2401             }
2402              
2403             # ---------------------------------------------------------------
2404             # 3. Extract WHERE clause (everything after WHERE keyword,
2405             # which must come after all JOIN...ON clauses)
2406             # ---------------------------------------------------------------
2407 30         86 my $where_str = '';
2408              
2409             # WHERE must appear after the last ON clause; we find the last WHERE
2410 30 100       296 if ($from_rest =~ s/\s+WHERE\s+(.+)$//i) {
2411 17         47 $where_str = $1;
2412 17         120 $where_str =~ s/^\s+|\s+$//g;
2413             }
2414              
2415             # ---------------------------------------------------------------
2416             # 4. Parse the FROM clause using iterative regex matching
2417             # Grammar: table [AS alias] { join_type JOIN table [AS alias] ON col=col }*
2418             # ---------------------------------------------------------------
2419 30         58 my @join_specs;
2420              
2421             # Parse the driving (first) table
2422 30         65 my $fr = $from_rest;
2423 30         89 $fr =~ s/^\s+//;
2424 30 50       226 unless ($fr =~ s/^(\w+)(?:\s+(?:AS\s+)?(\w+))?//) {
2425 0         0 return undef;
2426             }
2427 30 50       958 my($first_tbl, $first_alias) = ($1, defined($2) ? $2 : $1);
2428 30         239 push @join_specs, { table => $first_tbl, alias => $first_alias, type => 'FIRST' };
2429              
2430             # Iteratively match JOIN clauses
2431 30         592 while ($fr =~ s/^\s+(?:(INNER|LEFT(?:\s+OUTER)?|RIGHT(?:\s+OUTER)?|CROSS)\s+)?JOIN\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?(?:\s+ON\s+([\w.]+)\s*=\s*([\w.]+))?//i) {
2432 32         256 my($type_kw, $tbl, $alias, $on_left, $on_right) = ($1, $2, $3, $4, $5);
2433 32         87 my $type = 'INNER';
2434 32 100 66     420 if (defined($type_kw) && ($type_kw =~ /LEFT/i)) {
    100 66        
    100 66        
2435 6         15 $type = 'LEFT';
2436             }
2437             elsif (defined($type_kw) && ($type_kw =~ /RIGHT/i)) {
2438 1         3 $type = 'RIGHT';
2439             }
2440             elsif (defined($type_kw) && ($type_kw =~ /CROSS/i)) {
2441 1         4 $type = 'CROSS';
2442             }
2443 32 50       88 $alias = $tbl unless defined $alias;
2444 32         298 push @join_specs, {
2445             table => $tbl,
2446             alias => $alias,
2447             type => $type,
2448             on_left => $on_left,
2449             on_right => $on_right,
2450             };
2451             }
2452              
2453             # Must have at least 2 tables to be a JOIN
2454 30 50       90 return undef if @join_specs < 2;
2455              
2456             # ---------------------------------------------------------------
2457             # 5. Parse SELECT column list
2458             # ---------------------------------------------------------------
2459 30         56 my @col_specs;
2460 30 100       115 if ($sel_str =~ /^\s*\*\s*$/) {
2461 3         8 @col_specs = (); # empty = all columns (expanded later)
2462             }
2463             else {
2464 27         189 for my $cs (split /\s*,\s*/, $sel_str) {
2465 57         275 $cs =~ s/^\s+|\s+$//g;
2466 57         140 push @col_specs, $cs;
2467             }
2468             }
2469              
2470             # ---------------------------------------------------------------
2471             # 6. Parse WHERE conditions
2472             # ---------------------------------------------------------------
2473 30         68 my @where_conds;
2474 30 100       135 @where_conds = _parse_join_conditions($where_str) if $where_str =~ /\S/;
2475              
2476 30         251 return [ [ @join_specs ], [ @col_specs ], [ @where_conds ], { %opts } ];
2477             }
2478              
2479             # Parse WHERE expression containing possibly qualified column names
2480             # Returns arrayref of condition hashrefs
2481             sub _parse_join_conditions {
2482 17     17   49 my($expr) = @_;
2483 17 50 33     109 return () unless defined($expr) && ($expr =~ /\S/);
2484 17         36 my @conds;
2485 17         72 for my $part (split /\s+AND\s+/i, $expr) {
2486 19         109 $part =~ s/^\s+|\s+$//g;
2487              
2488             # col-vs-col: alias1.col1 OP alias2.col2
2489 19 100 66     232 if (($part =~ /^((?:\w+\.)?\w+)\s*(=|!=|<>|<=|>=|<|>)\s*((?:\w+\.)?\w+)$/i) && ($part !~ /'/)) {
    100          
    50          
2490 12         68 my($lhs, $op, $rhs) = ($1, uc($2), $3);
2491              
2492             # Heuristic: if rhs looks like a number, treat as literal
2493 12 50       64 if ($rhs =~ /^-?\d+\.?\d*$/) {
2494 12         47 my($la, $lc) = _split_qualified($lhs);
2495 12         96 push @conds, { lhs_alias=>$la, lhs_col=>$lc, op=>$op, val=>$rhs };
2496             }
2497             else {
2498 0         0 my($la, $lc) = _split_qualified($lhs);
2499 0         0 my($ra, $rc) = _split_qualified($rhs);
2500 0         0 push @conds, { lhs_alias=>$la, lhs_col=>$lc, op=>$op, rhs_alias=>$ra, rhs_col=>$rc };
2501             }
2502             # col [NOT] IN (val, val, ...)
2503             }
2504             elsif ($part =~ /^((?:\w+\.)?\w+)\s+(NOT\s+)?IN\s*\(([^)]*)\)\s*$/i) {
2505 1         4 my($lhs, $neg, $list_str) = ($1, $2, $3);
2506 1         4 my($la, $lc) = _split_qualified($lhs);
2507 1         2 my @vals;
2508 1         1 my $ls = $list_str;
2509 1         6 while ($ls =~ s/^\s*(?:'([^']*)'|(-?\d+\.?\d*)|(NULL))\s*(?:,|$)//i) {
2510 1         2 my($sv, $nv, $nl) = ($1, $2, $3);
2511 1 50       5 push @vals, defined($nl) ? undef : (defined($sv) ? $sv : $nv);
    50          
2512             }
2513 1 50       9 push @conds, {
2514             lhs_alias => $la,
2515             lhs_col => $lc,
2516             op => ($neg ? 'NOT_IN' : 'IN'),
2517             vals => [ @vals ],
2518             };
2519             # col-vs-literal
2520             }
2521             elsif ($part =~ /^((?:\w+\.)?\w+)\s*(=|!=|<>|<=|>=|<|>|LIKE)\s*(?:'([^']*)'|(-?\d+\.?\d*))$/i) {
2522 6         42 my($lhs, $op, $sv, $nv) = ($1, uc($2), $3, $4);
2523 6         22 my($la, $lc) = _split_qualified($lhs);
2524 6 50       55 push @conds, { lhs_alias=>$la, lhs_col=>$lc, op=>$op, val=>defined($sv) ? $sv : $nv };
2525             }
2526             }
2527 17         57 return @conds;
2528             }
2529              
2530             ###############################################################################
2531             # General helpers
2532             ###############################################################################
2533             sub _err {
2534 24     24   53 my($self, $msg) = @_;
2535 24         44 $errstr = $msg;
2536 24         256 return undef;
2537             }
2538              
2539             sub _db_path {
2540 65     65   175 my($self, $db) = @_;
2541 65         2507 File::Spec->catdir($self->{base_dir}, $db);
2542             }
2543              
2544             sub _file {
2545 2548     2548   6931 my($self, $table, $ext) = @_;
2546 2548         64275 File::Spec->catfile($self->{base_dir}, $self->{db_name}, "$table.$ext");
2547             }
2548              
2549             sub _load_schema {
2550 2333     2333   6307 my($self, $table) = @_;
2551 2333 100       12928 return $self->{_tables}{$table} if $self->{_tables}{$table};
2552 122         363 my $sch_file = $self->_file($table, 'sch');
2553 122 100       2697 unless (-f $sch_file) {
2554 9         55 $errstr = "Table '$table' does not exist";
2555 9         102 return undef;
2556             }
2557 113         398 local *FH;
2558 113 50       3403 open(FH, "< $sch_file") or do { $errstr = "Cannot read schema: $!"; return undef; };
  0         0  
  0         0  
2559 113         308 my(%sch, @cols, %indexes);
2560 113         386 $sch{notnull} = {};
2561 113         280 $sch{defaults} = {};
2562 113         283 $sch{checks} = {};
2563 113         319 $sch{pk} = undef;
2564 113         3063 while () {
2565 504         830 chomp;
2566 504 100       3085 if (/^RECSIZE=(\d+)/) {
    100          
    100          
    50          
    100          
    50          
    100          
2567 113         621 $sch{recsize} = $1;
2568             }
2569             elsif (/^COL=(\w+):(\w+):(\d+)/) {
2570 272         2354 push @cols, { name=>$1, type=>$2, size=>$3 };
2571             }
2572             elsif (/^NOTNULL=(\w+)/) {
2573 1         6 $sch{notnull}{$1} = 1;
2574             }
2575             elsif (/^DEFAULT=(\w+):(.+)/) {
2576 0         0 $sch{defaults}{$1} = $2;
2577             }
2578             elsif (/^CHECK=(\w+):(.+)/) {
2579 1         19 $sch{checks}{$1} = $2;
2580             }
2581             elsif (/^PK=(\w+)/) {
2582 0         0 $sch{pk} = $1;
2583 0         0 $sch{notnull}{$1} = 1;
2584             }
2585             elsif (/^IDX=(\w+):(\w+):([01])/) {
2586 4         24 my($iname, $icol, $iuniq) = ($1, $2, $3);
2587 4         18 my($cdef) = grep { $_->{name} eq $icol } @cols;
  16         30  
2588             $indexes{$iname} = {
2589             name => $iname,
2590             col => $icol,
2591             unique => $iuniq+0,
2592             keysize => ($cdef ? $cdef->{size} : 0),
2593 4 50       103 coltype => ($cdef ? $cdef->{type} : 'VARCHAR'),
    50          
2594             };
2595             }
2596             }
2597 113         1097 close FH;
2598 113         453 $sch{cols} = [ @cols ];
2599 113         443 $sch{indexes} = { %indexes };
2600 113         402 $self->{_tables}{$table} = \%sch; # don't write { %sch }
2601 113         695 return \%sch; # don't write { %sch }
2602             }
2603              
2604             sub _rewrite_schema {
2605 12     12   74 my($self, $table, $sch) = @_;
2606 12         42 my $sch_file = $self->_file($table, 'sch');
2607 12         31 local *FH;
2608 12 50       1128 open(FH, "> $sch_file") or return $self->_err("Cannot rewrite schema: $!");
2609 12         107 print FH "VERSION=1\n";
2610 12         56 print FH "RECSIZE=$sch->{recsize}\n";
2611 12         20 for my $c (@{$sch->{cols}}) {
  12         40  
2612 37         102 print FH "COL=$c->{name}:$c->{type}:$c->{size}\n";
2613             }
2614 12         17 for my $ix (values %{$sch->{indexes}}) {
  12         43  
2615 2         8 print FH "IDX=$ix->{name}:$ix->{col}:$ix->{unique}\n";
2616             }
2617 12 50       21 for my $c (sort keys %{$sch->{notnull} || {}}) {
  12         59  
2618 15         33 print FH "NOTNULL=$c\n";
2619             }
2620 12 50       22 for my $c (sort keys %{$sch->{defaults} || {}}) {
  12         56  
2621 7         20 print FH "DEFAULT=$c:$sch->{defaults}{$c}\n";
2622             }
2623 12 50       32 for my $c (sort keys %{$sch->{checks} || {}}) {
  12         65  
2624 6         18 print FH "CHECK=$c:$sch->{checks}{$c}\n";
2625             }
2626 12 100       54 print FH "PK=$sch->{pk}\n" if $sch->{pk};
2627 12         1741 close FH;
2628 12         112 return 1;
2629             }
2630              
2631             sub _pack_record {
2632 1637     1637   3522 my($self, $sch, $row) = @_;
2633 1637         3442 my $data = RECORD_ACTIVE;
2634 1637         2254 for my $col (@{$sch->{cols}}) {
  1637         3449  
2635 2915 100       8930 my $v = defined($row->{$col->{name}}) ? $row->{$col->{name}} : '';
2636 2915         4941 my $t = $col->{type};
2637 2915         4852 my $s = $col->{size};
2638 2915 100       6568 if ($t eq 'INT') {
    100          
2639 1857   100     5537 my $iv = int($v || 0);
2640 1857 50       4099 $iv = 2147483647 if $iv > 2147483647;
2641 1857 50       4354 $iv = -2147483648 if $iv < -2147483648;
2642 1857         7956 $data .= pack('N', $iv&0xFFFFFFFF);
2643             }
2644             elsif ($t eq 'FLOAT') {
2645 100         511 $data .= pack('d', $v+0);
2646             }
2647             else {
2648 958         1955 my $sv = substr($v, 0, $s);
2649 958         2865 $sv .= "\x00" x ($s-length($sv));
2650 958         2801 $data .= $sv;
2651             }
2652             }
2653 1637         6203 return $data;
2654             }
2655              
2656             sub _unpack_record {
2657 3002     3002   6445 my($self, $sch, $raw) = @_;
2658 3002         4197 my %row;
2659 3002         4102 my $offset = 1;
2660 3002         4079 for my $col (@{$sch->{cols}}) {
  3002         7233  
2661 9435         15204 my $t = $col->{type};
2662 9435         14059 my $s = $col->{size};
2663 9435         19827 my $chunk = substr($raw, $offset, $s);
2664 9435 100       20255 if ($t eq 'INT') {
    100          
2665 5530         11171 my $uv = unpack('N', $chunk);
2666 5530 100       10585 $uv -= 4294967296 if $uv > 2147483647;
2667 5530         12200 $row{$col->{name}} = $uv;
2668             }
2669             elsif ($t eq 'FLOAT') {
2670 293         752 $row{$col->{name}} = unpack('d', $chunk);
2671             }
2672             else {
2673 3612         16505 (my $sv = $chunk) =~ s/\x00+$//;
2674 3612         9303 $row{$col->{name}} = $sv;
2675             }
2676 9435         15916 $offset += $s;
2677             }
2678 3002         27885 return { %row };
2679             }
2680              
2681 3377     3377   30124 sub _lock_ex { flock($_[0], LOCK_EX) }
2682 485     485   4103 sub _lock_sh { flock($_[0], LOCK_SH) }
2683 3862     3862   118778 sub _unlock { flock($_[0], LOCK_UN) }
2684              
2685             sub _to_where_sub {
2686 10     10   23 my($wi) = @_;
2687 10 50       30 return undef unless defined $wi;
2688 10 50       93 return $wi if ref($wi) eq 'CODE';
2689 0 0       0 return _compile_where_from_conds($wi) if ref($wi) eq 'ARRAY';
2690 0         0 return undef;
2691             }
2692              
2693             sub _split_col_defs {
2694 102     102   240 my($str) = @_;
2695 102         158 my @parts;
2696 102         199 my $cur = '';
2697 102         195 my $depth = 0;
2698 102         983 for my $ch (split //, $str) {
2699 3001 100 66     7501 if ($ch eq '(') {
    100          
    100          
2700 92         141 $depth++;
2701 92         165 $cur .= $ch;
2702             }
2703             elsif ($ch eq ')') {
2704 92         194 $depth--;
2705 92         186 $cur .= $ch;
2706             }
2707             elsif (($ch eq ',') && ($depth == 0)) {
2708 134         308 push @parts, $cur;
2709 134         243 $cur = '';
2710             }
2711             else {
2712 2683         3699 $cur .= $ch;
2713             }
2714             }
2715 102 50       772 push @parts, $cur if $cur =~ /\S/;
2716 102         419 return @parts;
2717             }
2718              
2719             sub _parse_values {
2720 1618     1618   3291 my($str) = @_;
2721 1618         2570 my @vals;
2722 1618         4384 while (length $str) {
2723 2840         6063 $str =~ s/^\s+//;
2724 2840 50       5608 last unless length $str;
2725 2840 100       19671 if ($str =~ s/^'((?:[^']|'')*)'(?:\s*,\s*|\s*$)//) {
    100          
    50          
2726 933         1787 my $s = $1;
2727 933         1614 $s =~ s/''/'/g;
2728 933         2646 push @vals, $s;
2729             }
2730             elsif ($str =~ s/^(NULL)(?:\s*,\s*|\s*$)//i) {
2731 1         4 push @vals, undef;
2732             }
2733             elsif ($str =~ s/^(-?\d+\.?\d*)(?:\s*,\s*|\s*$)//) {
2734 1906         6753 push @vals, $1;
2735             }
2736             else {
2737 0         0 last;
2738             }
2739             }
2740 1618         5029 return @vals;
2741             }
2742              
2743             sub _parse_conditions {
2744 0     0   0 my($expr) = @_;
2745 0         0 my @conds;
2746              
2747             # Use paren-aware AND splitter
2748 0         0 my @parts = _split_and_clauses($expr);
2749 0         0 for my $part (@parts) {
2750 0         0 $part =~ s/^\s+|\s+$//g;
2751              
2752             # col [NOT] IN (val, val, ...) -- expanded from subquery or literal list
2753 0 0       0 if ($part =~ /^(\w+)\s+(NOT\s+)?IN\s*\(([^)]*)\)\s*$/i) {
2754 0         0 my($col, $neg, $list_str) = ($1, $2, $3);
2755 0         0 my @vals;
2756              
2757             # parse list: numbers or quoted strings or NULL
2758 0         0 my $ls = $list_str;
2759 0         0 while ($ls =~ s/^\s*(?:'([^']*)'|(-?\d+\.?\d*)|(NULL))\s*(?:,|$)//i) {
2760 0         0 my($sv, $nv, $nl) = ($1, $2, $3);
2761 0 0       0 push @vals, defined($nl) ? undef : (defined($sv) ? $sv : $nv);
    0          
2762             }
2763 0 0       0 push @conds, {
2764             col => $col,
2765             op => $neg ? 'NOT_IN' : 'IN',
2766             vals => [ @vals ],
2767             };
2768 0         0 next;
2769             }
2770              
2771             # EXISTS (1) or EXISTS (0) -- already evaluated by subquery expander
2772 0 0       0 if ($part =~ /^(NOT\s+)?EXISTS\s*\((\d+)\)$/i) {
2773 0         0 my($neg, $val) = ($1, $2);
2774 0 0       0 my $truth = $val ? 1 : 0;
2775 0 0       0 $truth = 1 - $truth if $neg;
2776 0         0 push @conds, { op => 'CONST', val => $truth };
2777 0         0 next;
2778             }
2779              
2780             # EXISTS (1) or NOT EXISTS (0) without outer parens (legacy)
2781 0 0       0 if ($part =~ /^(NOT\s+)?EXISTS\s+(\d+)$/i) {
2782 0         0 my($neg, $val) = ($1, $2);
2783 0 0       0 my $truth = $val ? 1 : 0;
2784 0 0       0 $truth = 1 - $truth if $neg;
2785 0         0 push @conds, { op => 'CONST', val => $truth };
2786 0         0 next;
2787             }
2788              
2789             # col OP NULL -- SQL NULL semantics: comparison with NULL is always false
2790 0 0       0 if ($part =~ /^(\w+)\s*(=|!=|<>|<=|>=|<|>)\s*NULL$/i) {
2791 0         0 push @conds, { op => 'CONST', val => 0 };
2792 0         0 next;
2793             }
2794              
2795             # IS [NOT] NULL
2796 0 0       0 if ($part =~ /^(\w+)\s+IS\s+(NOT\s+)?NULL$/i) {
2797 0         0 my($col, $neg) = ($1, $2);
2798 0 0       0 push @conds, { col=>$col, op=>$neg ? 'IS_NOT_NULL' : 'IS_NULL' };
2799 0         0 next;
2800             }
2801              
2802             # Normal col OP literal
2803 0 0       0 if ($part =~ /^(\w+)\s*(=|!=|<>|<=|>=|<|>|LIKE)\s*(?:'([^']*)'|(-?\d+\.?\d*))$/i) {
2804 0         0 my($col, $op, $sv, $nv) = ($1, $2, $3, $4);
2805 0 0       0 push @conds, { col=>$col, op=>uc($op), val=>(defined($sv) ? $sv : $nv) };
2806             }
2807             }
2808 0         0 return [ @conds ];
2809             }
2810              
2811             sub _compile_where_from_conds {
2812 6     6   20 my($conds) = @_;
2813 6 100 66     54 return undef unless $conds && @$conds;
2814             return sub {
2815 8     8   12 my($row) = @_;
2816 8         11 for my $c (@$conds) {
2817 8         14 my $op = $c->{op};
2818              
2819             # Constant (pre-evaluated EXISTS/NOT EXISTS)
2820 8 50 33     35 if ($op eq 'CONST') {
    50          
    50          
    50          
2821 0 0       0 return 0 unless $c->{val};
2822             # IN / NOT IN with value list
2823             }
2824             elsif (($op eq 'IN') || ($op eq 'NOT_IN')) {
2825 0 0       0 my $rv = defined($row->{$c->{col}}) ? $row->{$c->{col}} : '';
2826 0         0 my $found = 0;
2827 0         0 for my $cv (@{$c->{vals}}) {
  0         0  
2828 0 0       0 next unless defined $cv;
2829 0   0     0 my $num = (($rv =~ /^-?\d+\.?\d*$/) && ($cv =~ /^-?\d+\.?\d*$/));
2830 0 0       0 if ($num ? ($rv == $cv) : ($rv eq $cv)) {
    0          
2831 0         0 $found = 1;
2832 0         0 last;
2833             }
2834             }
2835 0 0 0     0 return 0 if $found && ($op eq 'NOT_IN');
2836 0 0 0     0 return 0 if !$found && ($op eq 'IN');
2837             # IS NULL / IS NOT NULL
2838             }
2839             elsif ($op eq 'IS_NULL') {
2840 0 0 0     0 return 0 unless !defined($row->{$c->{col}}) || ($row->{$c->{col}} eq '');
2841             }
2842             elsif ($op eq 'IS_NOT_NULL') {
2843 0 0 0     0 return 0 unless defined($row->{$c->{col}}) && ($row->{$c->{col}} ne '');
2844             # Standard comparison
2845             }
2846             else {
2847 8 50       24 my $rv = defined($row->{$c->{col}}) ? $row->{$c->{col}} : '';
2848 8         13 my $cv = $c->{val};
2849 8   33     53 my $num = (($rv =~ /^-?\d+\.?\d*$/) && defined($cv) && ($cv =~ /^-?\d+\.?\d*$/));
2850 8 100 33     38 if ($op eq '=') {
    50          
    50          
    50          
    0          
    0          
    0          
2851 4 50       11 return 0 unless $num ? ($rv == $cv) : ($rv eq $cv);
    50          
2852             }
2853             elsif (($op eq '!=') || ($op eq '<>')) {
2854 0 0       0 return 0 unless $num ? ($rv != $cv) : ($rv ne $cv);
    0          
2855             }
2856             elsif ($op eq '<') {
2857 0 0       0 return 0 unless $num ? ($rv < $cv) : ($rv lt $cv);
    0          
2858             }
2859             elsif ($op eq '>') {
2860 4 50       65 return 0 unless $num ? ($rv > $cv) : ($rv gt $cv);
    100          
2861             }
2862             elsif ($op eq '<=') {
2863 0 0       0 return 0 unless $num ? ($rv <= $cv) : ($rv le $cv);
    0          
2864             }
2865             elsif ($op eq '>=') {
2866 0 0       0 return 0 unless $num ? ($rv >= $cv) : ($rv ge $cv);
    0          
2867             }
2868             elsif ($op eq 'LIKE') {
2869 0         0 (my $p = $cv) =~ s/%/.*/g;
2870 0         0 $p =~ s/_/./g;
2871 0 0       0 return 0 unless $rv =~ /^$p$/i;
2872             }
2873             }
2874             }
2875 6         14 return 1;
2876 2         33 };
2877             }
2878              
2879             ###############################################################################
2880             # SQL-92 Engine
2881             ###############################################################################
2882              
2883             # =============================================================================
2884             # Expression evaluator eval_expr($expr, \%row) -> scalar
2885             # =============================================================================
2886             sub eval_expr {
2887 4500     4500 0 9920 my($expr, $row) = @_;
2888 4500 50       9449 return undef unless defined $expr;
2889 4500         17366 $expr =~ s/^\s+|\s+$//g;
2890 4500 50       8620 return undef unless length($expr);
2891 4500 50       9759 return undef if $expr =~ /^NULL$/i;
2892 4500 100       15724 return $expr + 0 if $expr =~ /^-?\d+\.?\d*$/;
2893 4411 100       9375 if ($expr =~ /^'((?:[^']|'')*)'$/) {
2894 18         59 (my $s = $1) =~ s/''/'/g;
2895 18         138 return $s;
2896             }
2897 4393 50 33     9731 if (($expr =~ /^\((.+)\)$/s) && ($1 !~ /^\s*SELECT\b/i)) {
2898 0         0 return eval_expr($1, $row);
2899             }
2900 4393 100       8734 if ($expr =~ /^CASE\b(.*)\bEND$/si) {
2901 9         26 return eval_case($1, $row);
2902             }
2903 4384 100       8502 if ($expr =~ /^COALESCE\s*\((.+)\)$/si) {
2904 4         16 for my $a (args($1)) {
2905 6         18 my $v = eval_expr($a, $row);
2906 6 100 66     55 return $v if defined($v) && ($v ne '');
2907             }
2908 0         0 return undef;
2909             }
2910 4380 100       8271 if ($expr =~ /^NULLIF\s*\((.+)\)$/si) {
2911 2         7 my @a = args($1);
2912 2 50       6 return undef unless @a == 2;
2913 2         6 my($va, $vb) = (eval_expr($a[0], $row), eval_expr($a[1], $row));
2914 2 50 33     12 if (defined($va) && defined($vb)) {
2915 2 50 33     21 return undef if ((($va =~ /^-?\d+\.?\d*$/) && ($vb =~ /^-?\d+\.?\d*$/)) ? ($va == $vb) : ($va eq $vb));
    100          
2916             }
2917 1         8 return $va;
2918             }
2919 4378 100       8538 if ($expr =~ /^CAST\s*\(\s*(.+?)\s+AS\s+(\w+(?:\s*\(\s*\d+\s*\))?)\s*\)$/si) {
2920 2         9 my($ie, $t) = ($1, uc($2));
2921 2         23 my $v = eval_expr($ie, $row);
2922 2 50       6 return undef unless defined $v;
2923 2 100       8 return int($v) if $t =~ /^INT/i;
2924 1 50       5 return $v + 0 if $t =~ /^(FLOAT|REAL|DOUBLE|NUMERIC|DECIMAL)/i;
2925 1         4 return "$v";
2926             }
2927 4376 100       8647 if ($expr =~ /^(UPPER|LOWER|LENGTH|ABS|SIGN|TRIM|LTRIM|RTRIM)\s*\((.+)\)$/si) {
2928 9         43 my($fn, $arg) = (uc($1), $2);
2929 9         22 my $v = eval_expr($arg, $row);
2930 9 50       32 return undef unless defined $v;
2931 9 100       38 return uc($v) if $fn eq 'UPPER';
2932 7 100       33 return lc($v) if $fn eq 'LOWER';
2933 4 100       22 return length($v) if $fn eq 'LENGTH';
2934 2 50       3 return abs($v + 0) if $fn eq 'ABS';
2935 2 0       6 return (($v > 0) ? 1 : ($v < 0) ? -1 : 0) if $fn eq 'SIGN';
    0          
    50          
2936 2 50       6 if ($fn eq 'TRIM') {
2937 2         8 (my $s = $v) =~ s/^\s+|\s+$//g;
2938 2         9 return $s;
2939             }
2940 0 0       0 if ($fn eq 'LTRIM') {
2941 0         0 (my $s = $v) =~ s/^\s+//;
2942 0         0 return $s;
2943             }
2944 0 0       0 if ($fn eq 'RTRIM') {
2945 0         0 (my $s = $v) =~ s/\s+$//;
2946 0         0 return $s;
2947             }
2948             }
2949 4367 50       8329 if ($expr =~ /^ROUND\s*\((.+)\)$/si) {
2950 0         0 my @a = args($1);
2951 0         0 my $v = eval_expr($a[0], $row);
2952 0 0       0 return undef unless defined $v;
2953 0 0 0     0 my $d = (@a > 1) ? int(eval_expr($a[1], $row) || 0) : 0;
2954 0         0 return sprintf("%.${d}f", $v+0) + 0;
2955             }
2956 4367 50       8065 if ($expr =~ /^(FLOOR|CEIL(?:ING)?)\s*\((.+)\)$/si) {
2957 0         0 my($fn, $arg) = (uc($1), $2);
2958 0         0 my $v = eval_expr($arg, $row);
2959 0 0       0 return undef unless defined $v;
2960 0 0       0 return $fn eq 'FLOOR' ? POSIX::floor($v+0) : POSIX::ceil($v+0);
2961             }
2962 4367 50       8292 if ($expr =~ /^MOD\s*\((.+)\)$/si) {
2963 0         0 my @a = args($1);
2964 0 0       0 return undef unless @a == 2;
2965 0         0 my($a, $b) = (eval_expr($a[0], $row)+0, eval_expr($a[1], $row)+0);
2966 0 0       0 return undef if $b == 0;
2967 0         0 return $a % $b;
2968             }
2969 4367 100       8390 if ($expr =~ /^(?:SUBSTR|SUBSTRING)\s*\((.+)\)$/si) {
2970 1         5 my $inner = $1;
2971 1         4 my($se, $ste, $le);
2972 1 50       11 if ($inner =~ /^(.+?)\s+FROM\s+(\S+)(?:\s+FOR\s+(.+))?$/si) {
2973 0         0 ($se, $ste, $le) = ($1, $2, $3);
2974             }
2975             else {
2976 1         5 ($se, $ste, $le) = args($inner);
2977             }
2978 1         6 my $s = eval_expr($se, $row);
2979 1 50       5 return undef unless defined $s;
2980 1   50     4 my $st = int(eval_expr($ste, $row) || 1);
2981 1 50       5 $st = 1 if $st < 1;
2982 1 50 50     112 return defined($le)
2983             ? substr($s, $st-1, int(eval_expr($le, $row) || 0))
2984             : substr($s, $st-1);
2985             }
2986 4366 50       8067 if ($expr =~ /^CONCAT\s*\((.+)\)$/si) {
2987 0         0 my @args = args($1);
2988 0         0 my $r = '';
2989 0         0 for (@args) {
2990 0         0 my $v = eval_expr($_, $row);
2991 0 0       0 $r .= defined($v) ? $v : '';
2992             }
2993 0         0 return $r;
2994             }
2995              
2996             # Binary operator: find rightmost at depth 0 (precedence low->high: || then +/- then */%)
2997 4366         7847 for my $op ('\\|\\|', '[+\\-]', '[*/%]') {
2998 13076         22658 my $p = find_binop($expr, $op);
2999 13076 100       29152 if (defined $p) {
3000 28         78 my $opsym = substr($expr, $p->{s}, $p->{l});
3001 28         167 my $lv = eval_expr(substr($expr, 0, $p->{s}), $row);
3002 28         123 my $rv = eval_expr(substr($expr, $p->{s}+$p->{l}), $row);
3003 28 100       81 if ($opsym eq '||') {
3004 6 50       41 return (defined($lv) ? $lv : '').(defined($rv) ? $rv : '');
    50          
3005             }
3006 22 50 33     116 return undef unless defined($lv) && defined($rv);
3007 22         58 my($l, $r) = ($lv + 0, $rv + 0);
3008 22 100       111 return $l + $r if $opsym eq '+';
3009 12 50       28 return $l - $r if $opsym eq '-';
3010 12 100       75 return $l * $r if $opsym eq '*';
3011 6 50 33     49 return undef if (($opsym eq '/') || ($opsym eq '%')) && ($r == 0);
      33        
3012 6 50       12 return $l / $r if $opsym eq '/';
3013 6 50       29 return $l % $r if $opsym eq '%';
3014             }
3015             }
3016 4338 50       10045 if ($expr =~ /^-([\w('.].*)$/s) {
3017 0         0 my $v = eval_expr($1, $row);
3018 0 0       0 return undef unless defined $v;
3019 0         0 return - ($v + 0);
3020             }
3021 4338 100       9321 if ($expr =~ /^(\w+)\.(\w+)$/) {
3022 39         176 my($a, $c) = ($1, $2);
3023 39 100       262 return exists($row->{"$a.$c"}) ? $row->{"$a.$c"} : $row->{$c};
3024             }
3025 4299 50       25265 return $row->{$expr} if $expr =~ /^\w+$/;
3026 0         0 return undef;
3027             }
3028              
3029             sub eval_case {
3030 9     9 0 53 my($body, $row) = @_;
3031 9         145 $body =~ s/^\s+|\s+$//g;
3032 9         20 my $base;
3033 9 50       42 unless ($body =~ /^\s*WHEN\b/i) {
3034 0 0       0 $body =~ s/^(.+?)\s+(?=WHEN\b)//si and $base = $1;
3035             }
3036 9         16 my $else;
3037 9 50       106 $body =~ s/\s*\bELSE\b\s+(.+?)\s*$//si and $else = $1;
3038 9         92 while ($body =~ s/^\s*WHEN\s+(.+?)\s+THEN\s+(.+?)(?=\s+WHEN\b|\s*$)//si) {
3039 15         44 my($we, $te) = ($1, $2);
3040 15         19 my $m;
3041 15 50       27 if (defined $base) {
3042 0         0 my($bv, $wv) = (eval_expr($base, $row), eval_expr($we, $row));
3043 0   0     0 $m = defined($bv) && defined($wv) && ((($bv =~ /^-?\d+\.?\d*$/) && ($wv =~ /^-?\d+\.?\d*$/)) ? ($bv == $wv) : ($bv eq $wv));
3044             }
3045             else {
3046 15         33 $m = eval_bool($we, $row);
3047             }
3048 15 100       97 return eval_expr($te, $row) if $m;
3049             }
3050 3 50       24 return defined($else) ? eval_expr($else, $row) : undef;
3051             }
3052              
3053             sub eval_bool {
3054 35     35 0 73 my($expr, $row) = @_;
3055 35         157 $expr =~ s/^\s+|\s+$//g;
3056 35 50       197 if ($expr =~ /^(.+?)\s*(=|!=|<>|<=|>=|<|>)\s*(.+)$/s) {
3057 35         149 my($l, $op, $r) = ($1, uc($2), $3);
3058 35         78 my($lv, $rv) = (eval_expr($l, $row), eval_expr($r, $row));
3059 35 50 33     135 return 0 unless defined($lv) && defined($rv);
3060 35   33     199 my $n = (($lv =~ /^-?\d+\.?\d*$/) && ($rv =~ /^-?\d+\.?\d*$/));
3061 35 50       112 return $n ? ($lv == $rv) : ($lv eq $rv) if $op eq '=';
    100          
3062 30 0       107 return $n ? ($lv != $rv) : ($lv ne $rv) if $op =~ /^(!|<>)/;
    50          
3063 30 50       97 return $n ? ($lv < $rv) : ($lv lt $rv) if $op eq '<';
    100          
3064 20 0       53 return $n ? ($lv > $rv) : ($lv gt $rv) if $op eq '>';
    50          
3065 20 50       53 return $n ? ($lv <= $rv) : ($lv le $rv) if $op eq '<=';
    100          
3066 15 50       107 return $n ? ($lv >= $rv) : ($lv ge $rv) if $op eq '>=';
    50          
3067             }
3068 0 0       0 if ($expr =~ /^(.+)\s+IS\s+(NOT\s+)?NULL$/si) {
3069 0         0 my $v = eval_expr($1, $row);
3070 0 0 0     0 return $2 ? (defined($v) && ($v ne '')) : (!defined($v) || ($v eq ''));
      0        
3071             }
3072 0         0 return 0;
3073             }
3074              
3075             # Argument splitter (handles parentheses and string literals)
3076             sub args {
3077 404     404 0 877 my($str) = @_;
3078 404         608 my @parts;
3079 404         690 my $cur = '';
3080 404         664 my $d = 0;
3081 404         714 my $in_q = 0;
3082 404         1975 for my $ch (split //, $str) {
3083 2810 100 100     13525 if (($ch eq "'") && !$in_q) {
    100 66        
    100 100        
    100          
    100          
    100          
3084 17         24 $in_q = 1;
3085 17         28 $cur .= $ch;
3086             }
3087             elsif (($ch eq "'") && $in_q) {
3088 17         28 $in_q = 0;
3089 17         27 $cur .= $ch;
3090             }
3091             elsif ($in_q) {
3092 71         92 $cur .= $ch;
3093             }
3094             elsif ($ch eq '(') {
3095 45         99 $d++;
3096 45         96 $cur .= $ch;
3097             }
3098             elsif ($ch eq ')') {
3099 45         67 $d--;
3100 45         89 $cur .= $ch;
3101             }
3102             elsif (($ch eq ',') && ($d == 0)) {
3103 107         304 push @parts, $cur;
3104 107         237 $cur = '';
3105             }
3106             else {
3107 2508         4118 $cur .= $ch;
3108             }
3109             }
3110 404 50       2096 push @parts, $cur if $cur =~ /\S/;
3111 404         1278 return @parts;
3112             }
3113              
3114             # Find rightmost binary operator at depth 0
3115             sub find_binop {
3116 13076     13076 0 24620 my($expr, $op_pat) = @_;
3117 13076         17814 my $d = 0;
3118 13076         17301 my $in_q = 0;
3119 13076         18038 my $best = undef;
3120 13076         27209 for my $i (0 .. length($expr)-1) {
3121 40187         67137 my $ch = substr($expr, $i, 1);
3122 40187 100 100     331532 if (($ch eq "'") && !$in_q) {
    100 66        
    50 66        
    50 66        
    100 66        
      100        
3123 2         3 $in_q = 1;
3124             }
3125             elsif (($ch eq "'") && $in_q) {
3126 2         3 $in_q = 0;
3127             }
3128             elsif (!$in_q && ($ch eq '(')) {
3129 0         0 $d++;
3130             }
3131             elsif (!$in_q && ($ch eq ')')) {
3132 0         0 $d--;
3133             }
3134             elsif (!$in_q && ($d == 0) && ($i > 0)) {
3135 27105 100       318719 if (substr($expr, $i) =~ /^($op_pat)/) {
3136 29         139 $best = { s=>$i, l=>length($1) };
3137             }
3138             }
3139             }
3140 13076         28665 return $best;
3141             }
3142              
3143             # =============================================================================
3144             # WHERE engine where_sub($expr) -> coderef
3145             # =============================================================================
3146             sub where_sub {
3147 349     349 0 765 my($expr) = @_;
3148 349 50 33 0   2076 return sub{1} unless defined($expr) && ($expr =~ /\S/);
  0         0  
3149 349         989 return compile_tree(parse_bool($expr));
3150             }
3151              
3152             sub parse_bool {
3153 519     519 0 1001 my($expr) = @_;
3154 519         2932 $expr =~ s/^\s+|\s+$//g;
3155 519         1411 my @or = bool_split($expr, 'OR');
3156 519 100       1257 return { op=>'OR', kids=>[map{parse_bool($_)}@or] } if @or > 1;
  75         168  
3157 484         1155 my @and = bool_split($expr, 'AND');
3158 484 100       1194 return { op=>'AND', kids=>[map{parse_bool($_)}@and] } if @and > 1;
  91         263  
3159 439 100       1478 return { op=>'NOT', kids=>[parse_bool($1)] } if $expr =~ /^NOT\s+(.+)$/si;
3160 437 100 66     1451 if (($expr =~ /^\((.+)\)$/s) && ($1 !~ /^\s*SELECT\b/i)) {
3161 2         12 return parse_bool($1);
3162             }
3163 435         1123 return { op=>'LEAF', cond=>parse_leaf($expr) };
3164             }
3165              
3166             sub bool_split {
3167 1033     1033 0 2180 my($expr, $kw) = @_;
3168 1033         1465 my $kl = length($kw);
3169 1033         1352 my @parts;
3170 1033         1501 my $cur = '';
3171 1033         1442 my $d = 0;
3172 1033         1328 my $in_q = 0;
3173 1033         1296 my $i = 0;
3174 1033         1434 my $len = length($expr);
3175 1033         2129 while ($i < $len) {
3176 12323         16996 my $ch = substr($expr, $i, 1);
3177 12323 100 100     72822 if (($ch eq "'") && !$in_q) {
    100 66        
    100 66        
    100 100        
    100 100        
    100 100        
      66        
      100        
3178 256         348 $in_q = 1;
3179 256         471 $cur .= $ch;
3180             }
3181             elsif (($ch eq "'") && $in_q) {
3182 256         356 $in_q = 0;
3183 256         362 $cur .= $ch;
3184             }
3185             elsif ($in_q) {
3186 762         981 $cur .= $ch;
3187             }
3188             elsif ($ch eq '(') {
3189 120         167 $d++;
3190 120         168 $cur .= $ch;
3191             }
3192             elsif ($ch eq ')') {
3193 120         173 $d--;
3194 120         144 $cur .= $ch;
3195             }
3196             elsif (($d == 0)
3197             && !$in_q
3198             && (uc(substr($expr, $i, $kl)) eq $kw)
3199             && (($i == 0) || (substr($expr, $i-1, 1) =~ /\s/))
3200             && (($i+$kl) < $len)
3201             && (substr($expr, $i+$kl, 1) =~ /\s/)
3202             ) {
3203              
3204             # For AND: do not split the AND inside BETWEEN x AND y
3205 129 100       372 if ($kw eq 'AND') {
3206 56         120 my $before = $cur;
3207 56         472 $before =~ s/^\s+|\s+$//g;
3208 56 100       315 if ($before =~ /\bBETWEEN\s+\S+\s*$/i) {
3209 10         22 $cur .= $ch;
3210 10         20 $i++;
3211 10         35 next;
3212             }
3213             }
3214 119         247 push @parts, $cur;
3215 119         209 $cur = '';
3216 119         191 $i += $kl;
3217 119         303 next;
3218             }
3219             else {
3220 10680         28126 $cur .= $ch;
3221             }
3222 12194         20529 $i++;
3223             }
3224 1033         2173 push @parts, $cur;
3225 1033         1941 @parts = grep {/\S/} @parts;
  1152         4853  
3226 1033 100       3257 return @parts > 1 ? @parts : ($expr);
3227             }
3228              
3229             sub parse_leaf {
3230 435     435 0 819 my($part) = @_;
3231 435         2101 $part =~ s/^\s+|\s+$//g;
3232 435 100       1274 if ($part =~ /^(NOT\s+)?EXISTS\s*\((\d+)\)$/i) {
3233 3         14 my($neg, $v) = ($1, $2);
3234 3 100       9 my $t = $v ? 1 : 0;
3235 3 50       9 $t = 1 - $t if $neg;
3236 3         34 return { op=>'CONST', val=>$t };
3237             }
3238 432 100       1256 if ($part =~ /^([\w.]+)\s+(NOT\s+)?IN\s*\(([^)]*)\)$/si) {
3239 47         224 my($col, $neg, $ls) = ($1, $2, $3);
3240 47         83 my @vals;
3241 47         279 while ($ls =~ s/^\s*(?:'((?:[^']|'')*)'|(-?\d+\.?\d*)|(NULL))\s*(?:,|$)//i) {
3242 127         264 my($sv, $nv, $nl) = ($1, $2, $3);
3243 127 100       237 if (defined $nl) {
    100          
3244 3         12 push @vals, undef;
3245             }
3246             elsif (defined $sv) {
3247 9         41 (my $x = $sv) =~ s/''/'/g;
3248 9         40 push @vals, $x;
3249             }
3250             else {
3251 115         416 push @vals, $nv;
3252             }
3253             }
3254 47 100       591 return { op=>($neg ? 'NOT_IN' : 'IN'), col=>$col, vals=>[ @vals ] };
3255             }
3256 385 100       2053 return { op=>'CONST', val=>0 } if $part =~ /^[\w.]+\s*(?:=|!=|<>|<=|>=|<|>)\s*NULL$/si;
3257 384 100       1455 if ($part =~ /^([\w.]+)\s+IS\s+(NOT\s+)?NULL$/si) {
3258 3 100       33 return { op=>($2 ? 'IS_NOT_NULL' : 'IS_NULL'), col=>$1 };
3259             }
3260 381 100       1065 if ($part =~ /^([\w.]+)\s+(NOT\s+)?BETWEEN\s+(.+?)\s+AND\s+(.+)$/si) {
3261 10         69 my($col, $neg, $lo, $hi) = ($1, $2, $3, $4);
3262 10         38 $lo =~ s/^'(.*)'$/$1/s;
3263 10         26 $hi =~ s/^'(.*)'$/$1/s;
3264 10 100       126 return { op=>($neg ? 'NOT_BETWEEN' : 'BETWEEN'), col=>$col, lo=>$lo, hi=>$hi };
3265             }
3266 371 100       1578 if ($part =~ /^(.+?)\s+(NOT\s+)?LIKE\s+('(?:[^']|'')*'|\S+)$/si) {
3267 5         23 my($lhs, $neg, $pat) = ($1, $2, $3);
3268 5         24 $pat =~ s/^'(.*)'$/$1/s;
3269 5         18 (my $re = $pat) =~ s/%/.*/g;
3270 5         11 $re =~ s/_/./g;
3271 5 100       55 return { op=>($neg ? 'NOT_LIKE' : 'LIKE'), lhs=>$lhs, re=>$re };
3272             }
3273 366 50       3529 if ($part =~ /^(.+?)\s*(=|!=|<>|<=|>=|<|>)\s*(.+)$/s) {
3274 366         1833 my($lhs, $op, $rhs) = ($1, uc($2), $3);
3275 366         1224 $lhs =~ s/^\s+|\s+$//g;
3276 366         1006 $rhs =~ s/^\s+|\s+$//g;
3277 366         549 my $rv;
3278 366 100       1246 if ($rhs =~ /^'((?:[^']|'')*)'$/) {
3279 70         219 ($rv = $1) =~ s/''/'/g;
3280             }
3281             else {
3282 296         500 $rv = $rhs;
3283             }
3284 366         4513 return{ op=>$op, lhs=>$lhs, rhs_expr=>$rhs, rhs_val=>$rv };
3285             }
3286 0         0 return{ op=>'CONST', val=>0 };
3287             }
3288              
3289             sub compile_tree {
3290 517     517 0 1059 my($tree) = @_;
3291 517         1112 my $op = $tree->{op};
3292 517 100       1313 if ($op eq 'AND') {
3293 45         90 my @s = map {compile_tree($_)} @{$tree->{kids}};
  91         288  
  45         141  
3294 45 100   442   306 return sub { for my $s(@s) { return 0 unless $s->($_[0]) } 1 };
  442         748  
  627         1226  
  150         682  
3295             }
3296 472 100       1091 if ($op eq 'OR') {
3297 35         62 my @s = map { compile_tree($_) } @{$tree->{kids}};
  75         160  
  35         88  
3298 35 100   480   184 return sub { for my $s(@s) { return 1 if $s->($_[0]) } 0 };
  480         935  
  906         2328  
  35         178  
3299             }
3300 437 100       973 if ($op eq 'NOT') {
3301 2         13 my $s = compile_tree($tree->{kids}[0]);
3302 2 100   12   14 return sub{ $s->($_[0]) ? 0 : 1 };
  12         26  
3303             }
3304 435         1148 return compile_leaf($tree->{cond});
3305             }
3306              
3307             sub compile_leaf {
3308 435     435 0 809 my($c) = @_;
3309 435 50       1171 my $op = defined($c->{op}) ? $c->{op} : '';
3310 435 100   32   950 return sub { $c->{val} ? 1 : 0 } if $op eq 'CONST';
  32 100       143  
3311 431 100       1010 if ($op eq 'IS_NULL') {
3312 1         2 my $col = $c->{col};
3313 1 50   3   5 return sub { my $v = $_[0]{$col}; !defined($v) || ($v eq '') };
  3         4  
  3         14  
3314             }
3315 430 100       870 if ($op eq 'IS_NOT_NULL') {
3316 2         6 my $col = $c->{col};
3317 2 50   6   15 return sub { my $v = $_[0]{$col}; defined($v) && ($v ne '') };
  6         14  
  6         53  
3318             }
3319 428 100 100     1824 if (($op eq 'BETWEEN') || ($op eq 'NOT_BETWEEN')) {
3320 10         60 my($col, $lo, $hi, $neg) = ($c->{col}, $c->{lo}, $c->{hi}, $op eq 'NOT_BETWEEN');
3321             return sub {
3322 50     50   94 my $v = $_[0]{$col};
3323 50 50       106 return 0 unless defined $v;
3324 50   33     483 my $n = (($v =~ /^-?\d+\.?\d*$/) && ($lo =~ /^-?\d+\.?\d*$/) && ($hi =~ /^-?\d+\.?\d*$/));
3325 50 50 100     234 my $in_q = $n ? (($v>=$lo) && ($v<=$hi)) : (($v ge $lo) && ($v le $hi));
      0        
3326 50 100       235 $neg ? !$in_q : $in_q;
3327 10         92 };
3328             }
3329 418 100 100     1591 if (($op eq 'IN') || ($op eq 'NOT_IN')) {
3330 47         166 my($col, $vals, $neg) = ($c->{col}, $c->{vals}, $op eq 'NOT_IN');
3331             return sub {
3332 585 50   585   1691 my $rv = defined($_[0]{$col}) ? $_[0]{$col} : '';
3333 585         917 my $f = 0;
3334 585         1174 for my $cv (@$vals) {
3335 1583 100       2854 next unless defined $cv;
3336 1560   66     7741 my $n = (($rv =~ /^-?\d+\.?\d*$/) && ($cv =~ /^-?\d+\.?\d*$/));
3337 1560 100       4098 if ($n ? ($rv == $cv) : ($rv eq $cv)) {
    100          
3338 153         206 $f = 1;
3339 153         238 last;
3340             }
3341             }
3342 585 100       2113 $neg ? !$f : $f;
3343 47         431 };
3344             }
3345 371 100 100     1415 if (($op eq 'LIKE') || ($op eq 'NOT_LIKE')) {
3346 5         43 my($lhs, $re, $neg) = ($c->{lhs}, $c->{re}, $op eq 'NOT_LIKE');
3347             return sub {
3348 24     24   60 my $v = eval_expr($lhs, $_[0]);
3349 24 50       51 $v = '' unless defined $v;
3350 24 100       192 my $m = ($v =~ /^$re$/si) ? 1 : 0;
3351 24 100       117 $neg ? !$m : $m;
3352 5         56 };
3353             }
3354 366         614 my($lhs, $op2, $rv_lit, $rhs_expr) = @{$c}{qw(lhs op rhs_val rhs_expr)};
  366         1484  
3355             return sub {
3356 1991     1991   3009 my $row = $_[0];
3357 1991         4494 my $lv = eval_expr($lhs, $row);
3358 1991 50       4356 return 0 unless defined $lv;
3359 1991 50 66     10675 my $rv = (($rhs_expr =~ /^[\w.]+$/) && ($rhs_expr !~ /^-?\d+\.?\d*$/)) ? eval_expr($rhs_expr, $row) : $rv_lit;
3360 1991 50       4002 $rv = '' unless defined $rv;
3361 1991   66     9147 my $n = (($lv =~ /^-?\d+\.?\d*$/) && ($rv =~ /^-?\d+\.?\d*$/));
3362 1991 100       10999 return $n ? ($lv == $rv) : ($lv eq $rv) if $op2 eq '=';
    100          
3363 729 100       1920 return $n ? ($lv != $rv) : ($lv ne $rv) if $op2 =~ /^(!|<>)/;
    100          
3364 719 50       1855 return $n ? ($lv < $rv) : ($lv lt $rv) if $op2 eq '<';
    100          
3365 646 50       2739 return $n ? ($lv > $rv) : ($lv gt $rv) if $op2 eq '>';
    100          
3366 254 50       1022 return $n ? ($lv <= $rv) : ($lv le $rv) if $op2 eq '<=';
    100          
3367 131 50       924 return $n ? ($lv >= $rv) : ($lv ge $rv) if $op2 eq '>=';
    50          
3368 0         0 return 0;
3369 366         3290 };
3370             }
3371              
3372             # =============================================================================
3373             # SELECT dispatcher
3374             # =============================================================================
3375             sub select {
3376 487     487 0 1196 my($self, $sql) = @_;
3377 487         1605 my @up = split_union($sql);
3378 487 100       1553 return $self->exec_union([ @up ]) if @up > 1;
3379 456 100       2974 if ($sql =~ /\bJOIN\b/i) {
3380              
3381             # Parse GROUP BY / HAVING from the SQL before handing off to _parse_join_sql
3382 30         53 my $join_sql = $sql;
3383 30         66 my(@gb_join, $having_join);
3384 30         67 $having_join = '';
3385 30 50       296 if ($join_sql =~ s/\bHAVING\s+(.+?)(?=\s*(?:ORDER\s+BY|LIMIT|OFFSET|$))//si) {
3386 0         0 $having_join = $1;
3387 0         0 $having_join =~ s/^\s+|\s+$//g;
3388             }
3389 30 100       313 if ($join_sql =~ s/\bGROUP\s+BY\s+(.+?)(?=\s*(?:HAVING|ORDER\s+BY|LIMIT|OFFSET|$))//si) {
3390 1         5 my $gbs = $1;
3391 1         9 $gbs =~ s/^\s+|\s+$//g;
3392 1         8 @gb_join = map { my $x = $_; $x =~ s/^\s+|\s+$//g; $x } split /\s*,\s*/, $gbs;
  1         3  
  1         8  
  1         7  
3393             }
3394 30         125 my $has_agg = ($sql =~ /\b(?:COUNT|SUM|AVG|MIN|MAX)\s*\(/si);
3395 30   66     235 my $needs_groupby = (@gb_join || ($having_join ne '') || $has_agg);
3396              
3397 30         119 my $parsed = _parse_join_sql($join_sql);
3398 30 50       113 if ($parsed) {
3399 30         89 my($js, $cs, $wc, $opts) = @$parsed;
3400              
3401             # If GROUP BY / HAVING / aggregate: fetch raw rows with SELECT *
3402 30         49 my $rows;
3403 30 100       100 if ($needs_groupby) {
3404              
3405             # Fetch all columns as raw data for aggregation
3406 1         4 my $raw_opts = {%$opts};
3407 1         5 delete $raw_opts->{order_by};
3408 1         4 delete $raw_opts->{order_dir};
3409 1         4 delete $raw_opts->{limit};
3410 1         4 delete $raw_opts->{offset};
3411 1         6 $rows = $self->join_select($js, [], $wc, $raw_opts);
3412             }
3413             else {
3414 29         166 $rows = $self->join_select($js, $cs, $wc, $opts);
3415             }
3416 30 50       94 return{ type=>'error', message=>$errstr } unless $rows;
3417              
3418 30 100       88 if ($needs_groupby) {
3419              
3420             # Parse col_specs from the original SQL for aggregate evaluation
3421 1         2 my @col_specs_raw;
3422 1 50       20 if ($sql =~ /^SELECT\s+(.+?)\s+FROM\b/si) {
3423 1         3 my $cs_str = $1;
3424 1         10 for my $c (split /\s*,\s*/, $cs_str) {
3425 3         19 $c =~ s/^\s+|\s+$//g;
3426 3 100       20 if ($c =~ /^(.+?)\s+AS\s+(\w+)\s*$/si) {
3427 2         8 push @col_specs_raw, [ $1, $2 ];
3428             }
3429             else {
3430 1 50       7 my $alias = ($c =~ /^(\w+)\.(\w+)$/) ? $2 : $c;
3431 1         5 push @col_specs_raw, [ $c, $alias ];
3432             }
3433             }
3434             }
3435              
3436             # Group rows
3437 1         4 my(%gr, @go);
3438 1 50       3 if (@gb_join) {
3439 1         2 for my $row (@$rows) {
3440              
3441             # resolve GROUP BY key: try qualified then unqualified
3442             my $k = join("\x00", map {
3443 6         13 my $col = $_;
  6         8  
3444             my $v = defined($row->{$col})
3445             ? $row->{$col}
3446             : (($col =~ /^(\w+)\.(\w+)$/) && defined $row->{$2})
3447 6 0 0     15 ? $row->{$2}
    50          
3448             : '';
3449 6 50       17 defined($v) ? $v : '';
3450             } @gb_join);
3451 6 100       17 push @go, $k unless exists $gr{$k};
3452 6         8 push @{$gr{$k}}, $row;
  6         30  
3453             }
3454             }
3455             else {
3456 0         0 @go = ('__all__');
3457 0         0 $gr{__all__} = $rows;
3458             }
3459              
3460 1         2 my @results;
3461 1         3 for my $gk (@go) {
3462 3         7 my $grp = $gr{$gk};
3463 3         6 my $rep = $grp->[0];
3464 3         4 my %out;
3465 3         6 for my $spec (@col_specs_raw) {
3466 9         22 my($expr, $alias) = @$spec;
3467 9         21 $out{$alias} = eval_agg($expr, $grp, $rep);
3468             }
3469 3 50       11 if ($having_join ne '') {
3470 0         0 my $h = $having_join;
3471 0         0 my $cnt = scalar @$grp;
3472 0         0 $h =~ s/COUNT\s*\(\s*\*\s*\)/$cnt/gsi;
3473 0         0 $h =~ s/\b(SUM|AVG|MIN|MAX|COUNT)\s*\(([^)]+)\)/eval_agg("$1($2)", $grp, $rep)/geis;
  0         0  
3474 0 0       0 next unless where_sub($h)->({ %out });
3475             }
3476 3         23 push @results, { %out };
3477             }
3478              
3479             # ORDER BY from opts
3480 1 50       7 if (defined $opts->{order_by}) {
3481 1         4 my $ob = $opts->{order_by};
3482 1   50     5 my $dir = lc($opts->{order_dir} || 'asc');
3483             @results = sort {
3484 1 50       7 my $va = defined($a->{$ob}) ? $a->{$ob} : '';
  3         10  
3485 3 50       7 my $vb = defined($b->{$ob}) ? $b->{$ob} : '';
3486 3 50 33     11 my $c = (($va =~ /^-?\d+\.?\d*$/) && ($vb =~ /^-?\d+\.?\d*$/))
3487             ? ($va <=> $vb)
3488             : ($va cmp $vb);
3489 3 50       9 ($dir eq 'desc') ? -$c : $c;
3490             } @results;
3491             }
3492 1 50 33     7 if (defined($opts->{offset}) && ($opts->{offset} > 0)) {
3493 0         0 @results = splice(@results, $opts->{offset});
3494             }
3495 1 50       4 if (defined $opts->{limit}) {
3496 0         0 my $l = $opts->{limit} - 1;
3497 0 0       0 $l = $#results if $l > $#results;
3498 0         0 @results = @results[0 .. $l];
3499             }
3500 1         37 return { type=>'rows', data=>[ @results ] };
3501             }
3502 29         555 return { type=>'rows', data=>$rows };
3503             }
3504             }
3505 426 50       2032 my $p = $self->parse_select($sql) or return { type=>'error', message=>"Cannot parse SELECT: $sql" };
3506 426         1511 my($distinct, $col_specs, $tbl, $where_expr, $gb, $having, $ob, $limit, $offset) = @$p;
3507 426   100     2530 my $needs_agg = (@$gb || ($having ne '') || grep { $_->[0] =~ /\b(?:COUNT|SUM|AVG|MIN|MAX)\s*\(/si } @$col_specs);
3508 426 100       1121 return $self->exec_groupby($tbl, $col_specs, $where_expr, $gb, $having, $ob, $limit, $offset) if $needs_agg;
3509 401 100       1551 my $sch = $self->_load_schema($tbl) or return { type=>'error', message=>$errstr };
3510 394         1306 my $dat = $self->_file($tbl, 'dat');
3511 394         783 my $ws;
3512 394 100       1157 if ($where_expr ne '') {
3513             # Case 1: single condition col OP val (no AND/OR/NOT/BETWEEN/IN)
3514 304 100 66     3162 if (($where_expr =~ /^(\w+)\s*(=|!=|<>|<=|>=|<|>)\s*(?:'([^']*)'|(-?\d+\.?\d*))$/)
3515             && ($where_expr !~ /\b(?:OR|AND|NOT|BETWEEN|IN)\b/i)
3516             ) {
3517 161         1106 my($col, $op, $sv, $nv) = ($1, $2, $3, $4);
3518 161 100       1326 my $cond = [{ col=>$col, op=>uc($op), val=>defined($sv) ? $sv : $nv }];
3519 161         851 my $idx = $self->_find_index_for_conds($tbl, $sch, $cond);
3520 161 100       681 if (defined $idx) {
3521 32         110 my $wsub = where_sub($where_expr);
3522 32         164 my @rows;
3523 32         94 local *FH;
3524 32 50       1556 open(FH, "< $dat") or return $self->_err("Cannot open dat: $!");
3525 32         106 binmode FH;
3526 32         138 _lock_sh(\*FH);
3527 32         111 my $rs = $sch->{recsize};
3528 32         167 for my $rn (sort { $a <=> $b } @$idx) {
  96         184  
3529 85         958 seek(FH, $rn*$rs, 0);
3530 85         200 my $raw = '';
3531 85         1085 my $n = read(FH, $raw, $rs);
3532 85 50 33     423 next unless defined($n) && ($n == $rs);
3533 85 50       247 next if substr($raw, 0, 1) eq RECORD_DELETED;
3534 85         287 my $row = $self->_unpack_record($sch, $raw);
3535 85 50 33     415 push @rows, $row if !$wsub || $wsub->($row);
3536             }
3537 32         146 _unlock(\*FH);
3538 32         2267 close FH;
3539 32         207 return{ type=>'rows', data=>[$self->project([ @rows ], $col_specs, $distinct, $ob, $limit, $offset)] };
3540             }
3541             }
3542             # Case 2: AND of two range conditions on the same indexed column
3543             # col OP1 val1 AND col OP2 val2 (e.g. id > 5 AND id < 10)
3544             # also: col BETWEEN val1 AND val2
3545 272         1316 my $idx_range = $self->_try_index_and_range($tbl, $sch, $where_expr);
3546 272 100       774 if (defined $idx_range) {
3547 16         63 my $wsub = where_sub($where_expr);
3548 16         84 my @rows;
3549 16         48 local *FH;
3550 16 50       778 open(FH, "< $dat") or return $self->_err("Cannot open dat: $!");
3551 16         59 binmode FH;
3552 16         78 _lock_sh(\*FH);
3553 16         57 my $rs = $sch->{recsize};
3554 16         96 for my $rn (sort { $a <=> $b } @$idx_range) {
  140         238  
3555 95         1005 seek(FH, $rn*$rs, 0);
3556 95         177 my $raw = '';
3557 95         1046 my $n = read(FH, $raw, $rs);
3558 95 50 33     404 next unless defined($n) && ($n == $rs);
3559 95 50       278 next if substr($raw, 0, 1) eq RECORD_DELETED;
3560 95         307 my $row = $self->_unpack_record($sch, $raw);
3561 95 50 33     336 push @rows, $row if !$wsub || $wsub->($row);
3562             }
3563 16         91 _unlock(\*FH);
3564 16         256 close FH;
3565 16         153 return{ type=>'rows', data=>[$self->project([ @rows ], $col_specs, $distinct, $ob, $limit, $offset)] };
3566             }
3567             # Case 3: AND across different indexed columns.
3568             # Use the best available single-column index to narrow the candidate
3569             # record set, then apply the full WHERE predicate as a post-filter.
3570             # Example: WHERE dept = 'Eng' AND salary > 70000
3571 256         894 my $idx_partial = $self->_try_index_partial_and($tbl, $sch, $where_expr);
3572 256 100       624 if (defined $idx_partial) {
3573 12         39 my $wsub = where_sub($where_expr);
3574 12         65 my @rows;
3575 12         35 local *FH;
3576 12 50       626 open(FH, "< $dat") or return $self->_err("Cannot open dat: $!");
3577 12         42 binmode FH;
3578 12         63 _lock_sh(\*FH);
3579 12         42 my $rs = $sch->{recsize};
3580 12         79 for my $rn (sort { $a <=> $b } @$idx_partial) {
  37         102  
3581 40         480 seek(FH, $rn*$rs, 0);
3582 40         123 my $raw = '';
3583 40         625 my $n = read(FH, $raw, $rs);
3584 40 50 33     174 next unless defined($n) && ($n == $rs);
3585 40 50       119 next if substr($raw, 0, 1) eq RECORD_DELETED;
3586 40         194 my $row = $self->_unpack_record($sch, $raw);
3587 40 100 66     241 push @rows, $row if !$wsub || $wsub->($row);
3588             }
3589 12         76 _unlock(\*FH);
3590 12         163 close FH;
3591 12         90 return{ type=>'rows', data=>[$self->project([ @rows ], $col_specs, $distinct, $ob, $limit, $offset)] };
3592             }
3593             # Case 4: col IN (v1, v2, ...) -- equality index per value, union.
3594 244         882 my $idx_in = $self->_try_index_in($tbl, $sch, $where_expr);
3595 244 100       592 if (defined $idx_in) {
3596 26         91 my $wsub = where_sub($where_expr);
3597 26         81 my @rows;
3598 26         75 local *FH;
3599 26 50       1005 open(FH, "< $dat") or return $self->_err("Cannot open dat: $!");
3600 26         81 binmode FH;
3601 26         95 _lock_sh(\*FH);
3602 26         73 my $rs = $sch->{recsize};
3603 26         147 for my $rn (sort { $a <=> $b } @$idx_in) {
  113         212  
3604 90         689 seek(FH, $rn*$rs, 0);
3605 90         129 my $raw = '';
3606 90         835 my $n = read(FH, $raw, $rs);
3607 90 50 33     332 next unless defined($n) && ($n == $rs);
3608 90 50       250 next if substr($raw, 0, 1) eq RECORD_DELETED;
3609 90         226 my $row = $self->_unpack_record($sch, $raw);
3610 90 50 33     250 push @rows, $row if !$wsub || $wsub->($row);
3611             }
3612 26         80 _unlock(\*FH);
3613 26         270 close FH;
3614 26         122 return{ type=>'rows', data=>[$self->project([ @rows ], $col_specs, $distinct, $ob, $limit, $offset)] };
3615             }
3616             # Case 5: pure OR of simple indexed conditions.
3617             # Every atom must have an index; returns union of all matching records.
3618 218         922 my $idx_or = $self->_try_index_or($tbl, $sch, $where_expr);
3619 218 100       569 if (defined $idx_or) {
3620 27         74 my $wsub = where_sub($where_expr);
3621 27         143 my @rows;
3622 27         66 local *FH;
3623 27 50       1106 open(FH, "< $dat") or return $self->_err("Cannot open dat: $!");
3624 27         91 binmode FH;
3625 27         135 _lock_sh(\*FH);
3626 27         88 my $rs = $sch->{recsize};
3627 27         145 for my $rn (sort { $a <=> $b } @$idx_or) {
  547         848  
3628 420         5357 seek(FH, $rn*$rs, 0);
3629 420         828 my $raw = '';
3630 420         4930 my $n = read(FH, $raw, $rs);
3631 420 50 33     1963 next unless defined($n) && ($n == $rs);
3632 420 50       1187 next if substr($raw, 0, 1) eq RECORD_DELETED;
3633 420         1576 my $row = $self->_unpack_record($sch, $raw);
3634 420 50 33     1704 push @rows, $row if !$wsub || $wsub->($row);
3635             }
3636 27         118 _unlock(\*FH);
3637 27         325 close FH;
3638 27         240 return{ type=>'rows', data=>[$self->project([ @rows ], $col_specs, $distinct, $ob, $limit, $offset)] };
3639             }
3640 191         605 $ws = where_sub($where_expr);
3641             }
3642 281         870 my @raw;
3643 281         952 local *FH;
3644 281 50       14572 open(FH, "< $dat") or return $self->_err("Cannot open dat: $!");
3645 281         1108 binmode FH;
3646 281         1234 _lock_sh(\*FH);
3647 281         946 my $rs = $sch->{recsize};
3648 281         443 while (1) {
3649 1858         2871 my $raw = '';
3650 1858         14034 my $n = read(FH, $raw, $rs);
3651 1858 100 66     6378 last unless defined($n) && ($n == $rs);
3652 1577 100       3578 next if substr($raw, 0, 1) eq RECORD_DELETED;
3653 1566         4033 my $row = $self->_unpack_record($sch, $raw);
3654 1566 100 100     4330 push @raw, $row if !$ws || $ws->($row);
3655             }
3656 281         1214 _unlock(\*FH);
3657 281         3721 close FH;
3658 281         1868 return{ type=>'rows', data=>[ $self->project([ @raw ], $col_specs, $distinct, $ob, $limit, $offset) ] };
3659             }
3660              
3661             sub parse_select {
3662 426     426 0 1121 my($self, $sql) = @_;
3663 426         5068 $sql =~ s/^\s+|\s+$//g;
3664 426 50       2386 $sql =~ s/^SELECT\s+//si or return undef;
3665 426         767 my $distinct = 0;
3666 426 100       1330 $distinct = 1 if $sql =~ s/^DISTINCT\s+//si;
3667 426         1313 my($col_str, $rest) = split_at_from($sql);
3668 426 50 33     3072 return undef unless defined($col_str) && defined($rest);
3669 426         2433 $rest =~ s/^\s*FROM\s+//si;
3670 426         758 my $tbl;
3671 426 50       2714 ($rest =~ s/^(\w+)//) and ($tbl = $1);
3672              
3673             # Optional alias (consumed only when token is not a SQL keyword)
3674 426 50 66     3410 if (($rest =~ /^\s+(\w+)/) && ($1 !~ /^(?:WHERE|GROUP|ORDER|HAVING|LIMIT|OFFSET|INNER|LEFT|RIGHT|JOIN|ON|UNION)$/i)) {
3675 0         0 $rest =~ s/^\s+(?:AS\s+)?\w+//si;
3676             }
3677 426         1262 $rest =~ s/^\s+//;
3678 426 50       1026 return undef unless $tbl;
3679 426         1050 my($limit, $offset) = (undef, undef);
3680 426 100       1870 $rest =~ s/\s+OFFSET\s+(\d+)\s*$//si and $offset = $1;
3681 426 100       1686 $rest =~ s/\s+LIMIT\s+(\d+)\s*$//si and $limit = $1;
3682 426         739 my @ob;
3683 426 100       3095 if ($rest =~ s/(?:^|\s+)ORDER\s+BY\s+(.+?)(?=\s*(?:LIMIT|OFFSET|$))//si) {
3684 57         161 my $s = $1;
3685 57         253 $s =~ s/^\s+|\s+$//g;
3686 57         241 for my $item (split /\s*,\s*/, $s) {
3687 59         206 $item =~ s/^\s+|\s+$//g;
3688 59         119 my $dir = 'ASC';
3689 59 100       204 $item =~ s/\s+(ASC|DESC)\s*$//si and $dir = uc($1);
3690 59         277 push @ob, [ $item, $dir ];
3691             }
3692             }
3693 426         830 my $having = '';
3694 426 100       2594 $rest =~ s/(?:^|\s+)HAVING\s+(.+?)(?=\s*(?:ORDER|LIMIT|OFFSET|$))//si and $having = $1;
3695 426         675 $having =~ s/^\s+|\s+$//g;
3696 426         604 my @gb;
3697 426 100       2503 if ($rest =~ s/(?:^|\s+)GROUP\s+BY\s+(.+?)(?=\s*(?:HAVING|ORDER|LIMIT|OFFSET|$))//si) {
3698 11         161 @gb = map { my $x = $_; $x =~ s/^\s+|\s+\$//g; $x } split /\s*,\s*/, $1;
  11         32  
  11         51  
  11         67  
3699             }
3700 426         744 my $where = '';
3701 426 100       2986 $rest =~ /(?:^|\s*)WHERE\s+(.+)/si and ($where = $1) =~ s/^\s+|\s+$//g;
3702 426         1200 my @cs = parse_col_list($col_str);
3703 426         3316 return [ $distinct, [ @cs ], $tbl, $where, [ @gb ], $having, [ @ob ], $limit, $offset ];
3704             }
3705              
3706             sub split_at_from {
3707 426     426 0 998 my($str) = @_;
3708 426         671 my $d = 0;
3709 426         618 my $in_q = 0;
3710 426         838 my $len = length($str);
3711 426         1550 for my $i (0 .. $len-1) {
3712 3413         5179 my $ch = substr($str, $i, 1);
3713 3413 100 100     34897 if (($ch eq "'") && !$in_q) {
    100 66        
    100 100        
    100 100        
    100 100        
      100        
      33        
      66        
      33        
      33        
3714 8         19 $in_q = 1;
3715             }
3716             elsif (($ch eq "'") && $in_q) {
3717 8         17 $in_q = 0;
3718             }
3719             elsif (!$in_q && ($ch eq '(')) {
3720 45         89 $d++;
3721             }
3722             elsif (!$in_q && ($ch eq ')')) {
3723 45         83 $d--;
3724             }
3725             elsif (!$in_q
3726             && ($d == 0)
3727             && (uc(substr($str, $i, 4)) eq 'FROM')
3728             && (($i == 0) || (substr($str, $i-1, 1) =~ /\s/))
3729             && (($i+4 >= $len) || (substr($str, $i+4, 1) =~ /\s/))
3730             ) {
3731 426         2370 return (substr($str, 0, $i), substr($str, $i));
3732             }
3733             }
3734 0         0 return (undef, undef);
3735             }
3736              
3737             sub parse_col_list {
3738 426     426 0 925 my($cs) = @_;
3739 426         1998 $cs =~ s/^\s+|\s+$//g;
3740 426 100       1231 return([ '*', '*' ]) if $cs eq '*';
3741 372         618 my @specs;
3742 372         1295 for my $c (args($cs)) {
3743 470         1933 $c =~ s/^\s+|\s+$//g;
3744 470         744 my($expr, $alias);
3745 470 100       1597 if ($c =~ /^(.+?)\s+AS\s+(\w+)\s*$/si) {
3746 57         333 ($expr, $alias) = ($1, $2);
3747 57         272 $expr =~ s/^\s+|\s+$//g;
3748             }
3749             else {
3750 413         711 $expr = $c;
3751 413 50       1152 $alias = ($expr =~ /^(\w+)\.(\w+)$/?$2:$expr);
3752             }
3753 470         1595 push @specs, [$expr, $alias];
3754             }
3755 372         987 return @specs;
3756             }
3757              
3758             sub project {
3759 394     394 0 1171 my($self, $rows, $col_specs, $distinct, $ob, $limit, $offset) = @_;
3760 394   100     1925 my $star = ((@$col_specs == 1) && ($col_specs->[0][0] eq '*'));
3761              
3762             # ORDER BY must be evaluated against the original (unprojected) rows so that
3763             # columns not listed in SELECT (e.g. "SELECT name ... ORDER BY score") are
3764             # still accessible for sorting.
3765 394         1143 my @sorted = @$rows;
3766 394 100       1024 if (@$ob) {
3767             @sorted = sort {
3768 48         299 my($ra, $rb) = ($a, $b);
  281         672  
3769 281         558 for my $o (@$ob) {
3770 294         681 my($e, $dir) = @$o;
3771 294 0       422 my $va = do { my $vv = eval_expr($e, $ra); defined($vv) ? $vv : (defined($ra->{$e}) ? $ra->{$e} : '') };
  294 50       644  
  294         767  
3772 294 0       461 my $vb = do { my $vv = eval_expr($e, $rb); defined($vv) ? $vv : (defined($rb->{$e}) ? $rb->{$e} : '') };
  294 50       608  
  294         824  
3773 294 100 66     2095 my $c = (($va =~ /^-?\d+\.?\d*$/) && ($vb =~ /^-?\d+\.?\d*$/)) ? ($va <=> $vb) : ($va cmp $vb);
3774 294 100       815 $c = -$c if lc($dir) eq 'desc';
3775 294 100       1245 return $c if $c;
3776             }
3777             0
3778             } @sorted;
3779             }
3780              
3781             # Apply OFFSET / LIMIT on sorted raw rows before projection
3782 394 100       1008 $offset = 0 unless defined $offset;
3783 394 100       934 @sorted = splice(@sorted, $offset) if $offset;
3784 394 100       950 if (defined $limit) {
3785 12         34 my $l = $limit-1;
3786 12 50       45 $l = $#sorted if $l>$#sorted;
3787 12         62 @sorted = @sorted[0 .. $l];
3788             }
3789              
3790             # Project to requested columns
3791 394         735 my @out;
3792 394         794 for my $row (@sorted) {
3793 1394 100       2697 if ($star) {
3794 114         629 push @out, { %$row };
3795             }
3796             else {
3797 1280         1892 my %p;
3798 1280         3627 $p{$_->[1]} = eval_expr($_->[0], $row) for @$col_specs;
3799 1280         6347 push @out, { %p };
3800             }
3801             }
3802              
3803             # DISTINCT (applied after projection so aliases are visible)
3804 394 100       1017 if ($distinct) {
3805 4         11 my %s;
3806             my @d;
3807 4         11 for my $r (@out) {
3808 22 50       129 my $k = join("\x00", map{ defined($r->{$_}) ? $r->{$_} : "\x01" } sort keys %$r);
  22         149  
3809 22 100       86 push @d, $r unless $s{$k}++;
3810             }
3811 4         28 @out = @d;
3812             }
3813 394         11476 return @out;
3814             }
3815              
3816             # =============================================================================
3817             # GROUP BY / HAVING / aggregate functions
3818             # =============================================================================
3819             sub exec_groupby {
3820 25     25 0 111 my($self, $tbl, $col_specs, $where_expr, $gb, $having, $ob, $limit, $offset) = @_;
3821 25 50       114 my $sch = $self->_load_schema($tbl) or return{ type=>'error', message=>$errstr };
3822 25         126 my $dat = $self->_file($tbl, 'dat');
3823 25 100       103 my $ws = ($where_expr ne '') ? where_sub($where_expr) : undef;
3824 25         52 my @raw;
3825 25         117 local *FH;
3826 25 50       1402 open(FH, "< $dat") or return $self->_err("Cannot open dat: $!");
3827 25         104 binmode FH;
3828 25         135 _lock_sh(\*FH);
3829 25         99 my $rs = $sch->{recsize};
3830 25         68 while (1) {
3831 166         271 my $raw = '';
3832 166         1584 my $n = read(FH, $raw, $rs);
3833 166 100 66     715 last unless defined($n) && ($n == $rs);
3834 141 100       381 next if substr($raw, 0, 1) eq RECORD_DELETED;
3835 132         358 my $row = $self->_unpack_record($sch, $raw);
3836 132 100 100     465 push @raw, $row if !$ws || $ws->($row);
3837             }
3838 25         118 _unlock(\*FH);
3839 25         313 close FH;
3840 25         77 my %gr;
3841             my @go;
3842 25 100       89 if (@$gb) {
3843 11         28 for my $row (@raw) {
3844 71 50       160 my $k = join("\x00", map { my $v = eval_expr($_, $row); defined($v) ? $v : '' } @$gb);
  71         175  
  71         352  
3845 71 100       238 push @go, $k unless exists $gr{$k};
3846 71         116 push @{$gr{$k}}, $row;
  71         244  
3847             }
3848             }
3849             else {
3850 14         53 @go = ('__all__');
3851 14         58 $gr{__all__} = [ @raw ];
3852             }
3853 25         54 my @results;
3854 25         57 for my $gk (@go) {
3855 43         148 my $grp = $gr{$gk};
3856 43 100       133 my $rep = defined($grp->[0]) ? $grp->[0] : {};
3857 43         81 my %out;
3858 43         182 $out{$_->[1]} = eval_agg($_->[0], $grp, $rep) for @$col_specs;
3859 43 100       158 if ($having ne '') {
3860 7         15 my $h = $having;
3861 7         18 my $cnt = scalar @$grp;
3862 7         26 $h =~ s/COUNT\s*\(\s*\*\s*\)/$cnt/gsi;
3863 7         93 $h =~ s/\b(SUM|AVG|MIN|MAX|COUNT)\s*\(([^)]+)\)/eval_agg("$1($2)", $grp, $rep)/geis;
  5         37  
3864 7 100       53 next unless where_sub($h)->({ %out });
3865             }
3866 41         328 push @results, { %out };
3867             }
3868 25 100       84 if (@$ob) {
3869             @results = sort {
3870 9         63 my($ra, $rb) = ($a, $b);
  22         59  
3871 22         52 for my $o (@$ob) {
3872 22         58 my($e, $dir) = @$o;
3873 22 0       60 my $va = do { my $vv = eval_expr($e, $ra); defined($vv) ? $vv : (defined($ra->{$e}) ? $ra->{$e} : '') };
  22 50       62  
  22         78  
3874 22 0       44 my $vb = do { my $vv = eval_expr($e, $rb); defined($vv) ? $vv : (defined($rb->{$e}) ? $rb->{$e} : '') };
  22 50       70  
  22         78  
3875 22 100 66     174 my $c = (($va =~ /^-?\d+\.?\d*$/) && ($vb =~ /^-?\d+\.?\d*$/)) ? ($va <=> $vb) : ($va cmp $vb);
3876 22 50       75 $c = -$c if lc($dir) eq 'desc';
3877 22 50       130 return $c if $c;
3878             }
3879             0
3880             } @results
3881             }
3882 25 50       89 $offset = 0 unless defined $offset;
3883 25 50       71 @results = splice(@results, $offset) if $offset;
3884 25 50       70 if (defined $limit) {
3885 0         0 my $l = $limit - 1;
3886 0 0       0 $l = $#results if $l>$#results;
3887 0         0 @results = @results[0..$l];
3888             }
3889 25         758 return{ type=>'rows', data=>[ @results ] };
3890             }
3891              
3892             sub eval_agg {
3893 95     95 0 233 my($expr, $grp, $rep) = @_;
3894 95 100       502 return scalar @$grp if $expr =~ /^COUNT\s*\(\s*\*\s*\)$/si;
3895 63 100       161 if ($expr =~ /^COUNT\s*\(\s*DISTINCT\s+(.+)\s*\)$/si) {
3896 1         4 my $e = $1;
3897 1         2 my %s;
3898 1 50       4 $s{ do { my $vv = eval_expr($e, $_); defined($vv) ? $vv : '' } }++ for @$grp;
  5         16  
  5         26  
3899 1         8 return scalar keys %s;
3900             }
3901 62 100       263 if ($expr =~ /^(COUNT|SUM|AVG|MIN|MAX)\s*\((.+)\)$/si) {
3902 30         164 my($fn, $inner) = (uc($1), $2);
3903 30         153 $inner =~ s/^\s+|\s+$//g;
3904 30         76 my @vals = grep { defined $_ } map { eval_expr($inner, $_) } @$grp;
  83         216  
  83         208  
3905 30 50       104 return 0 unless @vals;
3906 30 50       102 return scalar @vals if $fn eq 'COUNT';
3907 30 100       96 if ($fn eq 'SUM') {
3908 13         43 my $s = 0;
3909 13         45 $s += $_ for @vals;
3910 13         86 return $s;
3911             }
3912 17 100       47 if ($fn eq 'AVG') {
3913 9         18 my $s = 0;
3914 9         29 $s += $_ for @vals;
3915 9         85 return $s / @vals;
3916             }
3917 8 100       24 if ($fn eq 'MIN') {
3918 2 50 33     13 return (sort { (($a =~ /^-?\d+\.?\d*$/) && ($b =~ /^-?\d+\.?\d*$/)) ? ($a<=>$b) : ($a cmp $b) } @vals)[0];
  4         53  
3919             }
3920 6 50       22 if ($fn eq 'MAX') {
3921 6 50 33     38 return (sort { (($a =~ /^-?\d+\.?\d*$/) && ($b =~ /^-?\d+\.?\d*$/)) ? ($b<=>$a) : ($b cmp $a) } @vals)[0];
  13         207  
3922             }
3923             }
3924 32         167 return eval_expr($expr, $rep);
3925             }
3926              
3927             # =============================================================================
3928             # UNION / UNION ALL
3929             # =============================================================================
3930             sub split_union {
3931 487     487 0 1065 my($sql) = @_;
3932 487         786 my @parts;
3933 487         930 my $cur = '';
3934 487         865 my $d = 0;
3935 487         700 my $in_q = 0;
3936 487         727 my $i = 0;
3937 487         913 my $len = length($sql);
3938 487         1379 while ($i < $len) {
3939 22385         33525 my $ch = substr($sql, $i, 1);
3940 22385 100 100     176201 if (($ch eq "'") && !$in_q) {
    100 66        
    100 66        
    100 100        
    100 66        
    100          
3941 101         297 $in_q = 1;
3942 101         182 $cur .= $ch;
3943             }
3944             elsif (($ch eq "'") && $in_q) {
3945 101         176 $in_q = 0;
3946 101         196 $cur .= $ch;
3947             }
3948             elsif ($in_q) {
3949 336         446 $cur .= $ch;
3950             }
3951             elsif ($ch eq '(') {
3952 105         171 $d++;
3953 105         181 $cur .= $ch;
3954             }
3955             elsif ($ch eq ')') {
3956 105         155 $d--;
3957 105         189 $cur .= $ch;
3958             }
3959             elsif ($d == 0 && !$in_q
3960             && (($i == 0) || (substr($sql, $i-1, 1) =~ /\s/))) {
3961             # Detect UNION / INTERSECT / EXCEPT set operators
3962 4379         6524 my $kw = '';
3963 4379         5810 my $klen = 0;
3964 4379 100 66     23980 if ((uc(substr($sql, $i, 5)) eq 'UNION')
    100 66        
    100 66        
      66        
      66        
      66        
3965             && ($i+5 < $len) && (substr($sql, $i+5, 1) =~ /[\s(]/)) {
3966 4         11 $kw = 'UNION'; $klen = 5;
  4         10  
3967             }
3968             elsif ((uc(substr($sql, $i, 9)) eq 'INTERSECT')
3969             && ($i+9 < $len) && (substr($sql, $i+9, 1) =~ /[\s(]/)) {
3970 16         36 $kw = 'INTERSECT'; $klen = 9;
  16         30  
3971             }
3972             elsif ((uc(substr($sql, $i, 6)) eq 'EXCEPT')
3973             && ($i+6 < $len) && (substr($sql, $i+6, 1) =~ /[\s(]/)) {
3974 12         27 $kw = 'EXCEPT'; $klen = 6;
  12         22  
3975             }
3976 4379 100       7166 if ($klen) {
3977 32         106 push @parts, $cur;
3978 32         64 $cur = '';
3979 32         71 $i += $klen;
3980 32   66     210 while (($i < $len) && (substr($sql, $i, 1) =~ /\s/)) { $i++ }
  32         164  
3981             # UNION ALL / INTERSECT ALL / EXCEPT ALL
3982 32 100 66     622 if (($kw eq 'UNION')
    100 100        
    100 33        
      66        
      66        
      100        
      33        
      66        
      66        
      100        
      33        
      66        
3983             && ($i+3 <= $len) && (uc(substr($sql, $i, 3)) eq 'ALL')
3984             && (($i+3 >= $len) || (substr($sql, $i+3, 1) =~ /\s/))) {
3985 2         6 push @parts, 'UNION_ALL';
3986 2         6 $i += 3;
3987 2   66     19 while (($i < $len) && (substr($sql, $i, 1) =~ /\s/)) { $i++ }
  2         11  
3988             }
3989             elsif (($kw eq 'INTERSECT')
3990             && ($i+3 <= $len) && (uc(substr($sql, $i, 3)) eq 'ALL')
3991             && (($i+3 >= $len) || (substr($sql, $i+3, 1) =~ /\s/))) {
3992 2         5 push @parts, 'INTERSECT_ALL';
3993 2         5 $i += 3;
3994 2   66     34 while (($i < $len) && (substr($sql, $i, 1) =~ /\s/)) { $i++ }
  2         13  
3995             }
3996             elsif (($kw eq 'EXCEPT')
3997             && ($i+3 <= $len) && (uc(substr($sql, $i, 3)) eq 'ALL')
3998             && (($i+3 >= $len) || (substr($sql, $i+3, 1) =~ /\s/))) {
3999 3         9 push @parts, 'EXCEPT_ALL';
4000 3         8 $i += 3;
4001 3   66     20 while (($i < $len) && (substr($sql, $i, 1) =~ /\s/)) { $i++ }
  3         14  
4002             }
4003             else {
4004 25         101 push @parts, $kw; # bare UNION / INTERSECT / EXCEPT
4005             }
4006 32         111 next;
4007             }
4008             else {
4009 4347         7587 $cur .= $ch;
4010             }
4011             }
4012             else {
4013 17258         24334 $cur .= $ch;
4014             }
4015 22353         39066 $i++;
4016             }
4017 487 50       2477 push @parts, $cur if $cur =~ /\S/;
4018 487         2056 return @parts;
4019             }
4020              
4021             sub exec_union {
4022 31     31 0 66 my($self, $parts) = @_;
4023 31         101 my @p = @$parts;
4024 31         70 my $first = shift @p;
4025 31         166 my $r0 = $self->execute($first);
4026 31 50       125 return $r0 if $r0->{type} eq 'error';
4027 31         55 my @rows = @{$r0->{data}};
  31         98  
4028 31         113 while (@p >= 2) {
4029 32         62 my $sep = shift @p;
4030 32         104 my $q = shift @p;
4031 32         135 my $r = $self->execute($q);
4032 32 50       162 return $r if $r->{type} eq 'error';
4033 32         63 my @rhs = @{$r->{data}};
  32         99  
4034             # Build a key string for each row for set operations
4035             my $_key = sub {
4036 238     238   361 my($row) = @_;
4037 238 50       538 join("\x00", map { defined($row->{$_}) ? $row->{$_} : "\x01" } sort keys %$row);
  244         1167  
4038 32         261 };
4039 32 100 66     259 if ($sep eq 'UNION' || $sep eq '') {
    100          
    100          
    100          
    100          
    50          
4040             # UNION: combine then deduplicate
4041 2         10 push @rows, @rhs;
4042 2         7 my %s; my @d;
4043 2         6 for my $row (@rows) {
4044 14 100       30 push @d, $row unless $s{$_key->($row)}++;
4045             }
4046 2         27 @rows = @d;
4047             }
4048             elsif ($sep eq 'UNION_ALL') {
4049             # UNION ALL: combine without deduplication
4050 2         19 push @rows, @rhs;
4051             }
4052             elsif ($sep eq 'INTERSECT') {
4053             # INTERSECT: keep only rows present in both (deduplicated)
4054 14         28 my %in_rhs;
4055 14         37 for my $row (@rhs) { $in_rhs{$_key->($row)} = 1 }
  61         119  
4056 14         24 my %seen; my @d;
4057 14         34 for my $row (@rows) {
4058 55         86 my $k = $_key->($row);
4059 55 100 100     227 push @d, $row if $in_rhs{$k} && !$seen{$k}++;
4060             }
4061 14         157 @rows = @d;
4062             }
4063             elsif ($sep eq 'INTERSECT_ALL') {
4064             # INTERSECT ALL: keep rows present in both (with multiplicity)
4065 2         5 my %rhs_cnt;
4066 2         6 for my $row (@rhs) { $rhs_cnt{$_key->($row)}++ }
  6         15  
4067 2         4 my %used; my @d;
4068 2         5 for my $row (@rows) {
4069 8         18 my $k = $_key->($row);
4070 8 100 100     46 if (($rhs_cnt{$k} || 0) > ($used{$k} || 0)) {
      100        
4071 3         5 push @d, $row;
4072 3         8 $used{$k}++;
4073             }
4074             }
4075 2         26 @rows = @d;
4076             }
4077             elsif ($sep eq 'EXCEPT') {
4078             # EXCEPT: remove rows that appear in rhs (deduplicated)
4079 9         15 my %in_rhs;
4080 9         23 for my $row (@rhs) { $in_rhs{$_key->($row)} = 1 }
  36         79  
4081 9         26 my %seen; my @d;
4082 9         24 for my $row (@rows) {
4083 34         60 my $k = $_key->($row);
4084 34 100 100     143 push @d, $row if !$in_rhs{$k} && !$seen{$k}++;
4085             }
4086 9         113 @rows = @d;
4087             }
4088             elsif ($sep eq 'EXCEPT_ALL') {
4089             # EXCEPT ALL: remove rows with multiplicity
4090 3         6 my %rhs_cnt;
4091 3         7 for my $row (@rhs) { $rhs_cnt{$_key->($row)}++ }
  10         20  
4092 3         5 my %removed; my @d;
4093 3         7 for my $row (@rows) {
4094 14         23 my $k = $_key->($row);
4095 14 100 100     64 if (($rhs_cnt{$k} || 0) > ($removed{$k} || 0)) {
      100        
4096 6         13 $removed{$k}++;
4097             }
4098             else {
4099 8         18 push @d, $row;
4100             }
4101             }
4102 3         34 @rows = @d;
4103             }
4104             }
4105 31         433 return { type=>'rows', data=>[ @rows ] };
4106             }
4107              
4108             # =============================================================================
4109             # UPDATE with expression SET
4110             # =============================================================================
4111             sub parse_set_exprs {
4112 25     25 0 59 my($str) = @_;
4113 25         50 my %set;
4114 25         89 for my $part (args($str)) {
4115 26         131 $part =~ s/^\s+|\s+$//g;
4116 26 50       260 $set{$1} = $2 if $part =~ /^(\w+)\s*=\s*(.+)$/;
4117             }
4118 25         151 return %set;
4119             }
4120              
4121             sub update {
4122 25     25 0 71 my($self, $table, $set_exprs, $ws) = @_;
4123 25 50       84 return $self->_err("No database selected") unless $self->{db_name};
4124 25 50       90 my $sch = $self->_load_schema($table) or return undef;
4125 25         72 my $dat = $self->_file($table, 'dat');
4126 25         72 my $rs = $sch->{recsize};
4127 25         60 my $n = 0;
4128 25         77 local *FH;
4129 25 50       1214 open(FH, "+< $dat") or return $self->_err("Cannot open dat: $!");
4130 25         86 binmode FH;
4131 25         115 _lock_ex(\*FH);
4132 25         115 seek(FH, 0, 0);
4133 25         46 my $pos = 0;
4134 25         42 my $rno = 0;
4135 25         39 while (1) {
4136 94         1567 seek(FH, $pos, 0);
4137 94         225 my $raw = '';
4138 94         1397 my $x = read(FH, $raw, $rs);
4139 94 100 66     563 last unless defined($x) && ($x == $rs);
4140 77 100       248 if (substr($raw, 0, 1) ne RECORD_DELETED) {
4141 76         280 my $row = $self->_unpack_record($sch, $raw);
4142 76 100 100     328 if (!$ws || $ws->($row)) {
4143 35         60 my %old;
4144 35         53 for my $ix (values %{$sch->{indexes}}) {
  35         115  
4145             $old{$ix->{name}} = $row->{$ix->{col}}
4146 11         42 }
4147 35         219 my %orig = %$row;
4148 35         264 $row->{$_} = eval_expr($set_exprs->{$_}, { %orig }) for keys %$set_exprs;
4149 35         69 for my $ix (values %{$sch->{indexes}}) {
  35         114  
4150 9 100 100     44 next unless $ix->{unique} && exists $set_exprs->{$ix->{col}};
4151 4         10 my $nv = $row->{$ix->{col}};
4152 4         20 my $ep = $self->_idx_lookup_exact($table, $ix, $nv);
4153 4 50       16 if ($ep >= 0) {
4154 4         17 my $ef = $self->_idx_file($table, $ix->{name});
4155 4         11 my $es = $ix->{keysize} + REC_NO_SIZE;
4156 4         13 local *IF_FH;
4157 4 50       150 open(IF_FH, "< $ef") or next;
4158 4         10 binmode IF_FH;
4159 4         28 seek(IF_FH, IDX_MAGIC_LEN + $ep * $es + $ix->{keysize}, 0);
4160 4         9 my $rn = '';
4161 4         46 read(IF_FH, $rn, REC_NO_SIZE);
4162 4         62 close IF_FH;
4163 4 100       26 if (unpack('N', $rn) != $rno) {
4164 2         10 _unlock(\*FH);
4165 2         13 close FH;
4166 2         13 return $self->_err("UNIQUE constraint violated on '$ix->{name}'");
4167             }
4168             }
4169             }
4170              
4171             # NOT NULL constraint check on UPDATE
4172 33 50       53 for my $cn (keys %{$sch->{notnull} || {}}) {
  33         145  
4173 12 100       33 next unless exists $set_exprs->{$cn};
4174 1 50 33     4 unless (defined($row->{$cn}) && ($row->{$cn} ne '')) {
4175 1         9 _unlock(\*FH);
4176 1         9 close FH;
4177 1         5 return $self->_err("NOT NULL constraint violated on column '$cn'");
4178             }
4179             }
4180             # CHECK constraint check on UPDATE
4181 32 50       57 for my $cn (keys %{$sch->{checks} || {}}) {
  32         137  
4182 15 100       53 next unless exists $set_exprs->{$cn};
4183 9 100       28 unless (eval_bool($sch->{checks}{$cn}, $row)) {
4184 5         17 _unlock(\*FH);
4185 5         65 close FH;
4186 5         41 return $self->_err("CHECK constraint failed on column '$cn'");
4187             }
4188             }
4189 27         172 my $p = $self->_pack_record($sch, $row);
4190 27         324 seek(FH, $pos, 0);
4191 27         84 print FH $p;
4192 27         48 $n++;
4193 27         41 for my $ix (values %{$sch->{indexes}}) {
  27         144  
4194 7 100       37 next unless exists $set_exprs->{$ix->{col}};
4195 3         20 $self->_idx_delete($table, $ix, $old{$ix->{name}}, $rno);
4196 3         22 $self->_idx_insert($table, $ix, $row->{$ix->{col}}, $rno);
4197             }
4198             }
4199             }
4200 69         138 $pos += $rs;
4201 69         147 $rno++;
4202             }
4203 17         79 _unlock(\*FH);
4204 17         217 close FH;
4205 17         116 return $n;
4206             }
4207              
4208             ###############################################################################
4209             # DBI-like API -- DB::Handy::Connection / DB::Handy::Statement
4210             #
4211             # A standalone implementation with a DBI-inspired interface.
4212             #
4213             # Usage:
4214             # my $dbh = DB::Handy->connect("./data", "mydb");
4215             # my $sth = $dbh->prepare("SELECT * FROM emp WHERE id = ?");
4216             # $sth->execute(1);
4217             # while (my $row = $sth->fetchrow_hashref) { ... }
4218             # $sth->finish;
4219             # $dbh->disconnect;
4220             ###############################################################################
4221              
4222             ###############################################################################
4223             # DB::Handy::Connection -- database connection handle (like $dbh)
4224             ###############################################################################
4225             package DB::Handy::Connection;
4226 15     15   246 use vars qw($VERSION);
  15         155  
  15         1352  
4227             $VERSION = $DB::Handy::VERSION;
4228             $VERSION = $VERSION;
4229              
4230 15     15   97 use vars qw($errstr);
  15         57  
  15         24636  
4231             $errstr = '';
4232              
4233             # new($base_dir, $database, \%opts)
4234             sub new {
4235 26     26   55 my($class, $base_dir, $database, $opts) = @_;
4236 26 50       75 $opts = {} unless ref($opts) eq 'HASH';
4237 26         99 my $engine = DB::Handy->new(base_dir => $base_dir);
4238 26 50       75 unless (defined $engine) {
4239 0         0 $errstr = $DB::Handy::errstr;
4240 0 0       0 if ($opts->{RaiseError}) {
4241 0         0 die "DB::Handy connect failed: $errstr\n";
4242             }
4243 0         0 return undef;
4244             }
4245             my $self = {
4246             _engine => $engine,
4247             _database => $database || '',
4248             RaiseError => $opts->{RaiseError} || 0,
4249 26 100 50     357 PrintError => (defined($opts->{PrintError}) ? $opts->{PrintError} : 0),
      100        
4250             errstr => '',
4251             err => 0,
4252             };
4253 26         109 bless $self, $class;
4254 26 100 66     134 if ($database && (!defined($opts->{AutoUse}) || $opts->{AutoUse})) {
      33        
4255 25         108 my $res = $engine->execute("USE $database");
4256 25 100       101 if ($res->{type} eq 'error') {
4257 2         14 $engine->execute("CREATE DATABASE $database");
4258 2         11 $res = $engine->execute("USE $database");
4259             }
4260 25 50       99 if ($res->{type} eq 'error') {
4261 0   0     0 $self->_set_err($DB::Handy::errstr || $res->{message});
4262 0         0 return undef;
4263             }
4264             }
4265 26         101 return $self;
4266             }
4267              
4268             # connect($dsn_or_dir, $database, \%opts)
4269             # Also accepts DSN string: "base_dir=./data;database=mydb"
4270             sub connect {
4271 26     26   60 my($class, $dsn, $database, $opts) = @_;
4272 26         39 my $base_dir;
4273 26 100 66     174 if (defined($dsn) && ($dsn =~ /[=;]/)) {
4274 2         9 my %p = map { split /=/, $_, 2 } split /;/, $dsn;
  4         17  
4275 2   50     15 $base_dir = $p{base_dir} || $p{dir} || '.';
4276 2   33     11 $database = $p{database} || $p{db} || $database;
4277             }
4278             else {
4279 24 50       60 $base_dir = defined($dsn) ? $dsn : '.';
4280             }
4281 26 100       79 $opts = {} unless ref($opts) eq 'HASH';
4282 26         104 return DB::Handy::Connection->new($base_dir, $database, $opts);
4283             }
4284              
4285             # do($sql, @bind) -- shortcut for prepare+execute (useful for DDL/DML)
4286             sub do {
4287 74     74   668 my($self, $sql, @bind) = @_;
4288 74 50       154 my $sth = $self->prepare($sql) or return undef;
4289 74         158 return $sth->execute(@bind);
4290             }
4291              
4292             # prepare($sql) -- returns a statement handle
4293             sub prepare {
4294 160     160   1165 my($self, $sql) = @_;
4295 160 50 33     960 unless (defined($sql) && ($sql =~ /\S/)) {
4296 0         0 $self->_set_err("prepare: empty SQL");
4297 0         0 return undef;
4298             }
4299 160         1192 return DB::Handy::Statement->new($self, $sql);
4300             }
4301              
4302             # selectall_arrayref($sql, \%attr, @bind)
4303             # attr: Slice=>{} for array of hashrefs, Slice=>[] (default) for array of arrayrefs
4304             sub selectall_arrayref {
4305 15     15   325 my($self, $sql, $attr, @bind) = @_;
4306 15 50       58 $attr = {} unless ref($attr) eq 'HASH';
4307 15 50       43 my $sth = $self->prepare($sql) or return undef;
4308 15 50       48 $sth->execute(@bind) or return undef;
4309 15         71 return $sth->fetchall_arrayref($attr->{Slice});
4310             }
4311              
4312             # selectall_hashref($sql, $key_col, \%attr, @bind)
4313             sub selectall_hashref {
4314 2     2   59 my($self, $sql, $key_col, $attr, @bind) = @_;
4315 2 50       14 my $rows = $self->selectall_arrayref($sql, {Slice=>{}}, @bind) or return undef;
4316 2         5 my %h;
4317 2         5 for my $row (@$rows) {
4318 7         19 $h{$row->{$key_col}} = $row;
4319             }
4320 2         12 return { %h };
4321             }
4322              
4323             # selectrow_hashref($sql, \%attr, @bind)
4324             sub selectrow_hashref {
4325 17     17   285 my($self, $sql, $attr, @bind) = @_;
4326 17 50       51 my $sth = $self->prepare($sql) or return undef;
4327 17 50       49 $sth->execute(@bind) or return undef;
4328 17         58 my $row = $sth->fetchrow_hashref;
4329 17         68 $sth->finish;
4330 17         187 return $row;
4331             }
4332              
4333             # selectrow_arrayref($sql, \%attr, @bind)
4334             sub selectrow_arrayref {
4335 4     4   106 my($self, $sql, $attr, @bind) = @_;
4336 4 50       18 my $sth = $self->prepare($sql) or return undef;
4337 4 50       15 $sth->execute(@bind) or return undef;
4338 4         18 my $row = $sth->fetchrow_arrayref;
4339 4         20 $sth->finish;
4340 4         33 return $row;
4341             }
4342              
4343             # quote($val) -- escape a value as a SQL single-quoted literal
4344             sub quote {
4345 9     9   47 my($self, $val) = @_;
4346 9 100       22 return 'NULL' unless defined $val;
4347 7         19 $val =~ s/'/''/g;
4348 7         27 return "'$val'";
4349             }
4350              
4351             # last_insert_id() -- row count recorded by the most recent INSERT
4352 2     2   13 sub last_insert_id { return $_[0]->{_last_insert_id} }
4353              
4354             # table_info() -- list of tables [{TABLE_NAME=>...}, ...]
4355             sub table_info {
4356 1     1   11 my($self) = @_;
4357 1         7 my @tables = $self->{_engine}->list_tables();
4358 1         4 return [ map { {TABLE_NAME=>$_, TABLE_TYPE=>'TABLE'} } @tables ];
  2         14  
4359             }
4360              
4361             # column_info($table) -- column metadata [{COLUMN_NAME=>..., DATA_TYPE=>...}, ...]
4362             sub column_info {
4363 1     1   53 my($self, $table) = @_;
4364 1 50       5 my $cols = $self->{_engine}->describe_table($table) or return undef;
4365 1         2 my $i = 0;
4366 1         3 return [ map { {
4367             COLUMN_NAME => $_->{name},
4368             DATA_TYPE => $_->{type},
4369             ORDINAL_POSITION => ++$i,
4370             IS_NULLABLE => ($_->{not_null} ? 'NO' : 'YES'),
4371             COLUMN_DEF => $_->{default},
4372 4 50       30 } } @$cols ];
4373             }
4374              
4375             # disconnect()
4376             sub disconnect {
4377 26     26   593 my($self) = @_;
4378 26         78 $self->{_disconnected} = 1;
4379 26         253 return 1;
4380             }
4381              
4382             # ping() -- returns 1 if connection is active
4383 3 100   3   93 sub ping { return $_[0]->{_disconnected} ? 0 : 1 }
4384              
4385             # errstr / err accessors
4386 1     1   5 sub errstr { return $_[0]->{errstr} }
4387 4     4   32 sub err { return $_[0]->{err} }
4388              
4389             sub _set_err {
4390 8     8   32 my($self, $msg, $code) = @_;
4391 8 50       21 $code = 1 unless defined $code;
4392 8         14 $self->{errstr} = $msg;
4393 8         16 $self->{err} = $code;
4394 8         16 $errstr = $msg;
4395 8 100       18 if ($self->{PrintError}) {
4396 1         19 warn "DB::Handy: $msg\n";
4397             }
4398 8 100       28 if ($self->{RaiseError}) {
4399 2         17 die "DB::Handy: $msg\n";
4400             }
4401             }
4402              
4403             ###############################################################################
4404             # DB::Handy::Statement -- statement handle (like $sth)
4405             ###############################################################################
4406             package DB::Handy::Statement;
4407 15     15   134 use vars qw($VERSION);
  15         23  
  15         1021  
4408             $VERSION = $DB::Handy::VERSION;
4409             $VERSION = $VERSION;
4410              
4411 15     15   121 use vars qw($errstr);
  15         40  
  15         50342  
4412             $errstr = '';
4413              
4414             sub new {
4415 160     160   317 my($class, $dbh, $sql) = @_;
4416 160         1370 my $self = {
4417             _dbh => $dbh,
4418             _sql => $sql,
4419             _rows => undef,
4420             _cursor => 0,
4421             _executed => 0,
4422             _bind_params => [],
4423             rows => 0,
4424             errstr => '',
4425             err => 0,
4426             NAME => [],
4427             NUM_OF_FIELDS => 0,
4428             };
4429 160         341 bless $self, $class;
4430 160         704 return $self;
4431             }
4432              
4433             # execute(@bind_values) -- substitute ? placeholders and run the statement
4434             sub execute {
4435 164     164   502 my($self, @bind) = @_;
4436              
4437             # merge values pre-set via bind_param()
4438 164 100 100     430 if (!@bind && @{$self->{_bind_params}}) {
  149         459  
4439 2         3 @bind = @{$self->{_bind_params}};
  2         6  
4440             }
4441              
4442 164         289 my $sql = $self->{_sql};
4443              
4444             # substitute ? placeholders with actual values
4445 164 100       341 if (@bind) {
4446 17         38 my @params = @bind;
4447 17         90 $sql =~ s/\?/_dbi_quote(shift @params)/ge;
  22         62  
4448             }
4449              
4450 164         335 my $engine = $self->{_dbh}{_engine};
4451 164         403 my $res = $engine->execute($sql);
4452              
4453 164         684 $self->{_result} = $res;
4454 164         351 $self->{_executed} = 1;
4455              
4456 164 100       448 if ($res->{type} eq 'error') {
4457 8         35 $self->_set_err($res->{message});
4458 6         34 return undef;
4459             }
4460              
4461 156 100       440 if ($res->{type} eq 'rows') {
4462 82         161 my $data = $res->{data};
4463 82         156 $self->{_rows} = $data;
4464 82         145 $self->{_cursor} = 0;
4465 82         153 my $n = scalar @$data;
4466 82         139 $self->{rows} = $n;
4467             # Determine column order: prefer SELECT list order; for SELECT *
4468             # use schema declaration order; fall back to alphabetical.
4469 82         389 my @name_order = $self->_col_order_from_sql($sql, $data, $engine);
4470 82         254 $self->{NAME} = [ @name_order ];
4471 82         147 $self->{NUM_OF_FIELDS} = scalar @name_order;
4472 82   100     457 return $n || '0E0';
4473             }
4474              
4475             # INSERT / UPDATE / DELETE / DDL
4476 74 50       151 if ($res->{type} eq 'ok') {
4477 74         104 my $affected = 0;
4478 74 100 66     621 if (defined($res->{message}) && ($res->{message} =~ /(\d+)\s+row/)) {
4479 57         174 $affected = $1 + 0;
4480             }
4481 74         121 $self->{rows} = $affected;
4482 74         139 $self->{_rows} = undef;
4483 74 100       295 if ($sql =~ /^\s*INSERT\b/i) {
4484 52         122 $self->{_dbh}{_last_insert_id} = $affected;
4485             }
4486 74   100     608 return $affected || '0E0';
4487             }
4488              
4489             # SHOW / DESCRIBE and other statement types
4490 0 0       0 if (ref($res->{data}) eq 'ARRAY') {
4491 0         0 $self->{_rows} = $res->{data};
4492 0         0 $self->{_cursor} = 0;
4493 0         0 $self->{rows} = scalar @{$res->{data}};
  0         0  
4494             }
4495 0         0 return '0E0';
4496             }
4497              
4498             # _col_order_from_sql($sql, $data, $engine)
4499             #
4500             # Return column names in the order they should be presented to the caller.
4501             #
4502             # For named SELECT lists (SELECT a, b, c) the order follows the SELECT list,
4503             # including AS aliases (already handled since 1.01).
4504             #
4505             # For SELECT * on a single table the order follows the CREATE TABLE column
4506             # declaration order, obtained from the schema.
4507             #
4508             # For SELECT * on a JOIN the order follows the table appearance order in
4509             # the FROM/JOIN clause, each table's columns in declaration order, returned
4510             # as 'alias.col' qualified names matching the result-row hash keys.
4511             #
4512             # Falls back to alphabetical (sorted keys of the first data row) when the
4513             # schema cannot be resolved or the SQL cannot be parsed.
4514             #
4515             sub _col_order_from_sql {
4516 82     82   231 my($self, $sql, $data, $engine) = @_;
4517             # Fallback: alphabetical from first row (or empty)
4518 82 100 66     357 my @fallback = ($data && @$data) ? sort keys %{$data->[0]} : ();
  80         462  
4519 82 50       228 return @fallback unless defined $sql;
4520             # Strip leading SELECT keyword
4521 82         117 my $col_str;
4522 82 50       762 if ($sql =~ /^SELECT\s+(.*?)\s+FROM\b/si) {
4523 82         254 $col_str = $1;
4524             }
4525             else {
4526 0         0 return @fallback;
4527             }
4528 82         179 $col_str =~ s/^DISTINCT\s+//si;
4529             # SELECT * (or alias.*): try to use schema declaration order
4530 82 100 66     430 if ($col_str =~ /^\*$/ || $col_str =~ /^\w+\.\*$/) {
4531 12 50       36 return @fallback unless defined $engine;
4532             # Parse FROM clause to get table name and optional alias
4533 12 100 66     202 if ($sql =~ /\bFROM\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?\s*(?:WHERE|ORDER|GROUP|LIMIT|OFFSET|$)/si
4534             && $sql !~ /\bJOIN\b/i) {
4535 10         38 my($tbl, $alias) = ($1, $2);
4536 10         39 my $sch = $engine->_load_schema($tbl);
4537 10 50       31 return @fallback unless $sch;
4538 10         18 my @names = map { $_->{name} } @{$sch->{cols}};
  40         88  
  10         32  
4539             # Verify names match result keys
4540 10 100       28 if (@$data) {
4541 9         16 my %keys = map { $_ => 1 } keys %{$data->[0]};
  36         81  
  9         26  
4542 9 50       25 return @fallback if grep { !$keys{$_} } @names;
  36         102  
4543             }
4544 10         55 return @names;
4545             }
4546             # JOIN: collect tables in FROM/JOIN order, build alias.col names
4547 2 50       12 if ($sql =~ /\bJOIN\b/i) {
4548 2 50       5 return @fallback unless defined $engine;
4549 2         4 my @table_aliases;
4550             # Extract first table from FROM
4551 2 50       9 if ($sql =~ /\bFROM\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/si) {
4552 2 50       24 push @table_aliases, [ $1, (defined $2 ? $2 : $1) ];
4553             }
4554             # Extract subsequent JOIN tables
4555 2         5 my $rest = $sql;
4556 2         14 while ($rest =~ /\bJOIN\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gsi) {
4557 2 50       13 push @table_aliases, [ $1, (defined $2 ? $2 : $1) ];
4558             }
4559 2         4 my @names;
4560 2         4 for my $ta (@table_aliases) {
4561 4         9 my($tbl, $alias) = @$ta;
4562 4         9 my $sch = $engine->_load_schema($tbl);
4563 4 50       8 next unless $sch;
4564 4         5 for my $col (@{$sch->{cols}}) {
  4         7  
4565 14         30 push @names, "$alias.$col->{name}";
4566             }
4567             }
4568 2 50       4 if (@names) {
4569             # Verify names match result keys
4570 2 50       5 if (@$data) {
4571 2         3 my %keys = map { $_ => 1 } keys %{$data->[0]};
  14         25  
  2         7  
4572 2 50       5 return @fallback if grep { !$keys{$_} } @names;
  14         26  
4573             }
4574 2         50 return @names;
4575             }
4576 0         0 return @fallback;
4577             }
4578 0         0 return @fallback;
4579             }
4580             # Split on commas (not inside parentheses)
4581 70         100 my @parts;
4582 70         171 my($cur, $depth) = ('', 0);
4583 70         408 for my $ch (split //, $col_str) {
4584 767 100 100     1820 if ($ch eq '(') { $depth++; $cur .= $ch }
  16 100       20  
  16 100       29  
4585 16         21 elsif ($ch eq ')') { $depth--; $cur .= $ch }
  16         22  
4586 46         89 elsif ($ch eq ',' && $depth == 0) { push @parts, $cur; $cur = '' }
  46         75  
4587 689         896 else { $cur .= $ch }
4588             }
4589 70 50       286 push @parts, $cur if length $cur;
4590 70         117 my @names;
4591 70         173 for my $part (@parts) {
4592 116         482 $part =~ s/^\s+|\s+$//g;
4593             # explicit alias: expr AS alias
4594 116 100       725 if ($part =~ /\bAS\s+(\w+)\s*$/si) {
    100          
    50          
4595 21         53 push @names, $1;
4596             }
4597             # qualified alias.col -> keep as 'alias.col' (JOIN result key format)
4598             elsif ($part =~ /^(\w+)\.(\w+)$/) {
4599 2         6 push @names, "$1.$2";
4600             }
4601             # bare column name
4602             elsif ($part =~ /^(\w+)$/) {
4603 93         262 push @names, $1;
4604             }
4605             # complex expression without alias -> fall back entirely
4606             else {
4607 0         0 return @fallback;
4608             }
4609             }
4610             # Verify that every parsed name exists as a key in the result
4611             # (guards against mis-parses; also handles 0-row results)
4612 70 100       161 if (@$data) {
4613 69         104 my %keys = map { $_ => 1 } keys %{$data->[0]};
  114         380  
  69         201  
4614 69         151 for my $nm (@names) {
4615 114 50       351 return @fallback unless $keys{$nm};
4616             }
4617             }
4618 70         282 return @names;
4619             }
4620              
4621             # fetchrow_hashref -- return next row as hashref (undef at EOF)
4622             sub fetchrow_hashref {
4623 165     165   453 my($self) = @_;
4624 165 100       387 return undef unless defined $self->{_rows};
4625 164 100       254 return undef if $self->{_cursor} >= scalar @{$self->{_rows}};
  164         473  
4626 132         292 my $row = $self->{_rows}[ $self->{_cursor}++ ];
4627 132         575 return { %$row };
4628             }
4629              
4630             # fetchrow_arrayref -- return next row as arrayref (columns in NAME order)
4631             sub fetchrow_arrayref {
4632 46     46   442 my($self) = @_;
4633 46 100       107 my $href = $self->fetchrow_hashref or return undef;
4634 39 50       58 my @cols = @{$self->{NAME}} ? @{$self->{NAME}} : sort keys %$href;
  39         83  
  39         119  
4635 39         114 return [ map { $href->{$_} } @cols ];
  104         344  
4636             }
4637              
4638             # fetchrow_array -- return next row as a list
4639             sub fetchrow_array {
4640 7     7   98 my($self) = @_;
4641 7 100       18 my $aref = $self->fetchrow_arrayref or return ();
4642 6         28 return @$aref;
4643             }
4644              
4645             # fetch -- alias for fetchrow_arrayref
4646 0     0   0 sub fetch { return $_[0]->fetchrow_arrayref }
4647              
4648             # fetchall_arrayref([$slice])
4649             # $slice = {} -> [{col=>val,...}, ...]
4650             # $slice = [] -> [[val,...], ...] (default)
4651             sub fetchall_arrayref {
4652 18     18   64 my($self, $slice) = @_;
4653 18 50       78 return undef unless defined $self->{_rows};
4654 18         30 my @all;
4655 18 100       135 if (ref($slice) eq 'HASH') {
4656 16         63 while (my $row = $self->fetchrow_hashref) {
4657 39         95 push @all, $row;
4658             }
4659             }
4660             else {
4661 2         9 while (my $row = $self->fetchrow_arrayref) {
4662 8         19 push @all, $row;
4663             }
4664             }
4665 18         166 return [ @all ];
4666             }
4667              
4668             # fetchall_hashref($key_col) -- return rows as a hashref keyed by $key_col
4669             sub fetchall_hashref {
4670 2     2   13 my($self, $key_col) = @_;
4671 2         3 my %h;
4672 2         7 while (my $row = $self->fetchrow_hashref) {
4673 7         21 $h{$row->{$key_col}} = $row;
4674             }
4675 2         9 return { %h };
4676             }
4677              
4678             # bind_param($pos, $val [, $attr]) -- pre-bind a placeholder by position
4679             sub bind_param {
4680 2     2   14 my($self, $pos, $val, $attr) = @_;
4681 2         8 $self->{_bind_params}[$pos - 1] = $val;
4682 2         4 return 1;
4683             }
4684              
4685             # finish -- reset cursor and release resources
4686             sub finish {
4687 64     64   944 my($self) = @_;
4688 64         147 $self->{_rows} = undef;
4689 64         117 $self->{_cursor} = 0;
4690 64         139 $self->{_bind_params} = [];
4691 64         158 return 1;
4692             }
4693              
4694             # rows -- number of rows affected or fetched by the last execute
4695 5     5   142 sub rows { return $_[0]->{rows} }
4696              
4697             # errstr / err accessors
4698 2     2   6 sub errstr { return $_[0]->{errstr} }
4699 2     2   47 sub err { return $_[0]->{err} }
4700              
4701             sub _set_err {
4702 8     8   21 my($self, $msg, $code) = @_;
4703 8 50       19 $code = 1 unless defined $code;
4704 8         30 $self->{errstr} = $msg;
4705 8         12 $self->{err} = $code;
4706 8         14 $errstr = $msg;
4707 8         12 my $dbh = $self->{_dbh};
4708 8 50       47 $dbh->_set_err($msg, $code) if ref($dbh);
4709             }
4710              
4711             # _dbi_quote($val) -- internal helper for ? placeholder substitution
4712             sub _dbi_quote {
4713 22     22   44 my($val) = @_;
4714 22 50       55 return 'NULL' unless defined $val;
4715 22 100       160 return $val if $val =~ /^-?\d+\.?\d*$/; # numeric: pass through as-is
4716 7         13 $val =~ s/'/''/g;
4717 7         28 return "'$val'";
4718             }
4719              
4720             ###############################################################################
4721             # Add connect() class method to DB::Handy
4722             ###############################################################################
4723             package DB::Handy;
4724              
4725             sub connect {
4726 26     26 1 332434 my($class, $dsn, $database, $opts) = @_;
4727 26         164 return DB::Handy::Connection->connect($dsn, $database, $opts);
4728             }
4729              
4730             1;
4731              
4732             __END__