Database-Join
view release on metacpan or search on metacpan
#!/usr/bin/env perl
# t/logic.t -- Formal logic proofs for Database::Join
#
# Proves: truth tables, state invariants, and contradiction trapping.
# Coverage:
# L01 Key-set resolution truth table (join_type x had_criteria -- 6 paths)
# L02 De Morgan complementarity of the key-set gate
# L03 Syllogism: filter forces inner-join semantics regardless of join_type
# L04 _merge_criteria truth table (4 input-type combinations)
# L05 _partition_criteria routing invariants
# L06 collision_prefix boolean gate (index > 0 guard)
# L07 AUTOLOAD dispatch logic (join_map|filters => full join path)
# L08 _cache_fresh conditions (observable via DA call counts)
# L09 columns() invariants: always sorted, join_column always present
# L10 Memoisation cache invalidation state machine
# L11 Contradiction trapping (impossible states croak immediately)
# L12 remove_column: join_column is irremovable (contradiction proof)
# L13 _copy_criteria deep-copy isolation (post-construction mutation proof)
# L23 $ob_override boolean gate truth table (join_col vs non-join-col sort_by)
# L24 Non-commutativity of offset â limit (offset applied before limit)
# L25 Schwarzian undef coercion (undef ob_col value sorted as empty string)
# L26 reverse correctness for join_col DESC (O(R) reverse == explicit Schwarzian)
# L27 _validate_pagination predicate truth table (De Morgan for limit / offset)
use strict;
use warnings;
use Test::Most tests => 94;
use Readonly;
use lib 't/lib';
use_ok('Database::Join') or BAIL_OUT('Database::Join failed to load');
# ===========================================================================
# Inline stub DA -- self-contained, no disk I/O.
# Supports equality and numeric-operator filtering on its rows.
# ===========================================================================
{
package LogicDA;
use parent -norequire, 'Database::Abstraction';
sub new {
my ($class, %args) = @_;
return bless {
id => $args{id} // 'entry',
_cols => $args{cols} // ['entry'],
_rows => $args{rows} // [],
_schema => $args{schema} // {},
_ts => $args{updated} // 1_000_000,
_calls => 0,
}, $class;
}
sub columns { return $_[0]->{_cols} }
sub schema { return $_[0]->{_schema} }
sub updated { return $_[0]->{_ts} }
sub call_count { return $_[0]->{_calls} }
sub set_logger { $_[0]->{logger} = $_[1]; return $_[0] }
# Defined directly so defined(&{"LogicDA::count"}) is TRUE -- needed for
# backend='auto' threshold probe which distinguishes own-defined vs inherited.
sub count { return scalar @{ $_[0]->{_rows} } }
sub selectall_arrayref {
my ($self, $criteria) = @_;
$self->{_calls}++;
my @rows = @{ $self->{_rows} };
for my $col (keys %{ $criteria // {} }) {
my $v = $criteria->{$col};
if (ref($v) eq 'HASH') {
for my $op (keys %{$v}) {
my $lim = $v->{$op};
if ($op eq '>') { @rows = grep { defined $_->{$col} && $_->{$col} > $lim } @rows }
elsif ($op eq '<') { @rows = grep { defined $_->{$col} && $_->{$col} < $lim } @rows }
elsif ($op eq '>=') { @rows = grep { defined $_->{$col} && $_->{$col} >= $lim } @rows }
elsif ($op eq '<=') { @rows = grep { defined $_->{$col} && $_->{$col} <= $lim } @rows }
elsif ($op eq '!=') { @rows = grep { defined $_->{$col} && $_->{$col} != $lim } @rows }
elsif ($op eq '=') { @rows = grep { defined $_->{$col} && $_->{$col} == $lim } @rows }
}
} elsif (defined $v) {
@rows = grep { defined $_->{$col} && $_->{$col} eq $v } @rows;
}
}
return \@rows;
}
sub DESTROY {}
}
# DA that returns undef from updated() -- used for cache-skip tests.
{
package LogicNoTsDA;
use parent -norequire, 'LogicDA';
sub updated { return undef }
}
# Minimal stub that has selectall_arrayref but NOT columns().
# Used by L14 to prove that the P1 invariant is enforced at every entry point.
{
package NoCols;
sub new { bless {}, shift }
sub selectall_arrayref { return [] }
sub DESTROY {}
}
# LogicBareDA: duck-type DA with NO updated() method at all.
# Used by L22 to prove that updated() returns undef when no component has timestamps.
{
package LogicBareDA;
sub new {
my ($class, %args) = @_;
return bless { _cols => $args{cols} // ['entry'], _rows => $args{rows} // [] }, $class;
}
sub columns { return $_[0]->{_cols} }
sub schema { return {} }
sub set_logger { $_[0]->{_logger} = $_[1]; return $_[0] }
sub selectall_arrayref { return $_[0]->{_rows} }
sub DESTROY {}
# No updated() -- deliberately absent
}
# LogicThrowUpdDA: DA whose updated() throws unconditionally.
# Used by L22 to prove that throwing DAs are skipped and do not propagate errors.
{
package LogicThrowUpdDA;
use parent -norequire, 'LogicDA';
use Carp qw(croak);
sub updated { croak 'simulated updated() failure' }
sub DESTROY {}
}
# ===========================================================================
# Shared fixtures: primary has keys {A,B,C}; secondary has keys {B,D}.
# ===========================================================================
Readonly::Hash my %ROW => (
pA => { entry => 'A', name => 'alice' },
pB => { entry => 'B', name => 'bob' },
pC => { entry => 'C', name => 'carol' },
sB => { entry => 'B', score => 10 },
sD => { entry => 'D', score => 20 },
);
sub _prim { LogicDA->new(cols => ['entry','name'], rows => [@ROW{qw(pA pB pC)}]) }
sub _sec { LogicDA->new(cols => ['entry','score'], rows => [@ROW{qw(sB sD)}]) }
sub _build_join {
my (%extra) = @_;
return Database::Join->new(
databases => [_prim(), _sec()],
join_column => 'entry',
backend => 'array',
%extra,
);
}
sub _keys { [ sort map { $_->{entry} } @{ $_[0] } ] }
# ===========================================================================
# L01: Key-set resolution truth table
#
# Gate formula: had_criteria[i] OR join_type eq 'inner' => INTERSECT
# Otherwise: left => primary unchanged | outer => UNION
#
# Truth table (had_criteria h, join_type j):
# h=0, j=left => key_set = primary = {A,B,C} (3 rows)
# h=0, j=inner => key_set = {A,B,C} â© {B,D} = {B} (1 row)
# h=0, j=outer => key_set = {A,B,C} ⪠{B,D} = {A,B,C,D} (4 rows)
# h=1, j=left => gate TRUE => intersect => {A,B,C} â© {B} = {B} (1 row)
# h=1, j=inner => gate TRUE => intersect => {B} (1 row)
# h=1, j=outer => gate TRUE => intersect, not union => {B} (1 row)
# ===========================================================================
subtest 'L01: left + no secondary criteria => primary key-set unchanged' => sub {
plan tests => 2;
my $rows = _build_join(join_type => 'left')->selectall_arrayref();
is(scalar @{$rows}, 3, 'left+nocrit: 3 rows');
is_deeply(_keys($rows), [qw(A B C)], 'left+nocrit: keys={A,B,C}');
};
subtest 'L01: inner + no criteria => intersection' => sub {
plan tests => 2;
my $rows = _build_join(join_type => 'inner')->selectall_arrayref();
is(scalar @{$rows}, 1, 'inner+nocrit: 1 row');
is_deeply(_keys($rows), ['B'], 'inner+nocrit: keys={B}');
};
subtest 'L01: outer + no criteria => union' => sub {
( run in 0.426 second using v1.01-cache-2.11-cpan-7da0f45c409 )