Database-Join
view release on metacpan or search on metacpan
t/edge_cases.t view on Meta::CPAN
BEGIN {
eval { require Database::Abstraction };
plan skip_all => 'Database::Abstraction required' if $@;
plan tests => 70;
use_ok('Database::Join');
}
# ---------------------------------------------------------------------------
# Readonly attack payloads and structural constants
# ---------------------------------------------------------------------------
Readonly::Scalar my $JC => 'entry'; # canonical join column
Readonly::Scalar my $K_ALPHA => 'k001';
Readonly::Scalar my $K_BETA => 'k002';
Readonly::Scalar my $K_GAMMA => 'k003';
Readonly::Scalar my $KEY_ZERO => '0'; # false-but-defined join key
Readonly::Scalar my $ERR_NO_DBS => qr/At least one Database::Abstraction/;
Readonly::Scalar my $ERR_INVALID_DB => qr/is not a Database::Abstraction/;
Readonly::Scalar my $ERR_MISSING_JC => qr/is absent from databases/;
Readonly::Scalar my $ERR_REMOVE_JC => qr/Cannot remove join_column/;
Readonly::Scalar my $ERR_PRIVATE => qr/cannot call private method/;
Readonly::Scalar my $ERR_UNKNOWN_COL => qr/unknown column/;
Readonly::Scalar my $ERR_QUERY => qr/query\(\) chained builder is not supported/;
Readonly::Scalar my $ERR_EXECUTE => qr/execute\(\) raw SQL is not supported/;
Readonly::Scalar my $ERR_HEAP => qr/\(join_map\[0\] must be a string\)/;
# ---------------------------------------------------------------------------
# MockEdgeDA: configurable in-process stub with explicit failure modes.
#
# Constructor options:
# cols => \@list -- columns() return value
# rows => \@hrefs -- data for selectall_arrayref
# id => 'colname' -- internal primary-key field (_autoload_pk source)
# updated => $epoch -- updated() return value
# schema => \%or_undef -- schema() return value (undef to simulate failure)
# return => $anything -- literal return from selectall_arrayref (overrides rows)
# croak_msg => 'text' -- selectall_arrayref will croak with this message
# ---------------------------------------------------------------------------
## no critic (Modules::ProhibitMultiplePackages)
{
package MockEdgeDA;
use parent -norequire, 'Database::Abstraction';
use Carp qw(croak);
sub new {
my ($class, %args) = @_;
return bless {
id => $args{id} // 'entry',
_cols => $args{cols} // ['entry'],
_rows => $args{rows} // [],
_schema => $args{schema}, # deliberately no default -- undef is valid input
_ts => $args{updated} // 1_000_000,
_return => $args{return},
_do_ret => (exists $args{return}), # distinguish return=>undef from absent key
_croak => $args{croak_msg},
}, $class;
}
sub columns { return $_[0]->{_cols} }
sub schema { return $_[0]->{_schema} }
sub updated { return $_[0]->{_ts} }
sub set_logger { $_[0]->{_logger} = $_[1]; return $_[0] }
sub selectall_arrayref {
my ($self, $criteria) = @_;
croak($self->{_croak}) if $self->{_croak};
return $self->{_return} if $self->{_do_ret};
my @rows = @{ $self->{_rows} };
for my $col (keys %{ $criteria // {} }) {
my $val = $criteria->{$col};
if (ref($val) eq 'HASH') {
for my $op (keys %{$val}) {
my $v = $val->{$op};
if ($op eq '>') { @rows = grep { defined $_->{$col} && $_->{$col} > $v } @rows }
elsif ($op eq '<') { @rows = grep { defined $_->{$col} && $_->{$col} < $v } @rows }
elsif ($op eq '>=') { @rows = grep { defined $_->{$col} && $_->{$col} >= $v } @rows }
elsif ($op eq '<=') { @rows = grep { defined $_->{$col} && $_->{$col} <= $v } @rows }
elsif ($op eq '!=') { @rows = grep { defined $_->{$col} && $_->{$col} != $v } @rows }
}
} elsif (defined $val) {
@rows = grep { defined $_->{$col} && $_->{$col} eq $val } @rows;
} else {
@rows = grep { !defined $_->{$col} } @rows;
}
}
return \@rows;
}
sub DESTROY {}
}
# MutateCriteriaDA: simulates a hostile DA that mutates the criteria hashref
# it receives. Used to prove that the broadcast shallow-copy mechanism
# prevents one DA from corrupting the criteria delivered to sibling databases.
{
package MutateCriteriaDA;
use parent -norequire, 'Database::Abstraction';
sub new {
my ($class, %args) = @_;
return bless {
id => $args{id} // 'entry',
_cols => $args{cols} // ['entry'],
_rows => $args{rows} // [],
}, $class;
}
sub columns { return $_[0]->{_cols} }
sub schema { return {} }
sub updated { return 1 }
sub set_logger { $_[0]->{_logger} = $_[1]; return $_[0] }
sub selectall_arrayref {
my ($self, $criteria) = @_;
# HOSTILE: overwrite the '>' operator inside the entry criteria hashref to
# attempt to corrupt sibling databases' criteria for the same broadcast key.
if (ref($criteria) eq 'HASH' && ref($criteria->{entry}) eq 'HASH') {
$criteria->{entry}{'>'} = 999_999;
}
return $self->{_rows};
}
sub DESTROY {}
}
# ---------------------------------------------------------------------------
# Helper: build a minimal two-DB join with stock fixtures.
# Returns ($join, $primary_da, $secondary_da).
# ---------------------------------------------------------------------------
sub _minimal_join {
my (%opts) = @_;
my $prim = MockEdgeDA->new(
cols => ['entry', 'name'],
rows => [
{ entry => $K_ALPHA, name => 'Alice' },
{ entry => $K_BETA, name => 'Bob' },
],
);
my $sec = MockEdgeDA->new(
cols => ['entry', 'score'],
rows => [
{ entry => $K_ALPHA, score => 90 },
{ entry => $K_BETA, score => 70 },
],
);
my $j = Database::Join->new(
databases => [$prim, $sec],
join_column => $JC,
%opts,
);
return ($j, $prim, $sec);
}
# ===========================================================================
# Section 1: Constructor hostility
# ===========================================================================
subtest 'constructor: empty databases arrayref croaks error_no_databases' => sub {
# An empty arrayref satisfies the type check but fails the emptiness guard.
throws_ok { Database::Join->new(databases => []) }
$ERR_NO_DBS, 'empty databases => [] croaks';
};
subtest 'constructor: undef element in databases croaks error_invalid_db' => sub {
throws_ok { Database::Join->new(databases => [undef]) }
$ERR_INVALID_DB, 'databases => [undef] croaks';
};
subtest 'constructor: unblessed scalar in databases croaks' => sub {
throws_ok { Database::Join->new(databases => [42]) }
( run in 2.025 seconds using v1.01-cache-2.11-cpan-6736b670a1e )