Database-Join
view release on metacpan or search on metacpan
t/integration.t view on Meta::CPAN
use Test::Most;
use Test::Returns;
use Test::Mockingbird;
use Test::Without::Module;
use Readonly;
use Scalar::Util qw(blessed refaddr);
BEGIN {
eval { require DBD::SQLite; require DBI; require Database::Abstraction };
plan skip_all => 'DBD::SQLite, DBI, and Database::Abstraction required' if $@;
plan tests => 104;
use_ok('Database::Join');
}
use DBI;
use File::Temp qw(tempdir);
# ---------------------------------------------------------------------------
# SQLite-backed DA stubs. -norequire because Database::Abstraction was
# already require()d in the BEGIN block above.
#
# Class-name last-component must match the SQL filename exactly (case-sensitive).
# Using all-lowercase names to match the intcust.sql / intscore.sql filenames.
# ---------------------------------------------------------------------------
{
package Database::intcust;
use parent -norequire, 'Database::Abstraction';
}
{
package Database::intscore;
use parent -norequire, 'Database::Abstraction';
}
{
package Database::intregion;
use parent -norequire, 'Database::Abstraction';
}
# ---------------------------------------------------------------------------
# InMemDA: in-process stub for tests that do not need SQLite.
# Implements the subset of the Database::Abstraction API that Database::Join
# calls: columns, schema, updated, set_logger, selectall_arrayref.
# Operator hashrefs (>, <, >=, <=, !=) are supported for numeric columns.
# ---------------------------------------------------------------------------
{
package InMemDA;
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,
}, $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) = @_;
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 }
}
} else {
@rows = grep { defined $_->{$col} && $_->{$col} eq $val } @rows;
}
}
return \@rows;
}
sub DESTROY {}
}
# ---------------------------------------------------------------------------
# Fake logger: records calls so that set_logger propagation can be verified
# without triggering real logging side-effects.
# Database::Abstraction calls $logger->debug/info/warn/error at query time,
# so all four levels must exist.
# ---------------------------------------------------------------------------
{
package IntFakeLogger;
sub new { return bless { _calls => [] }, shift }
sub debug { push @{$_[0]->{_calls}}, ['debug', $_[1]] }
sub info { push @{$_[0]->{_calls}}, ['info', $_[1]] }
sub warn { push @{$_[0]->{_calls}}, ['warn', $_[1]] }
sub error { push @{$_[0]->{_calls}}, ['error', $_[1]] }
}
# ---------------------------------------------------------------------------
# Constants -- no magic numbers or strings in test assertions.
# ---------------------------------------------------------------------------
Readonly::Scalar my $JC => 'entry';
Readonly::Scalar my $ALICE_KEY => 'c001';
Readonly::Scalar my $BOB_KEY => 'c002';
Readonly::Scalar my $CAROL_KEY => 'c003';
Readonly::Scalar my $ALL_CUST => 3; # total rows in intcust
Readonly::Scalar my $ALL_SCORED => 3; # total rows in intscore
Readonly::Scalar my $REGIOND => 2; # rows in intregion (c003 absent)
Readonly::Scalar my $GOLD_COUNT => 2; # Alice and Carol are tier=gold
# ---------------------------------------------------------------------------
# SQLite fixture setup
#
# intcust: entry | name | email | tier
# c001 | Alice | alice@example.com | gold
# c002 | Bob | bob@example.com | silver
# c003 | Carol | carol@example.com | gold
t/integration.t view on Meta::CPAN
# count() on the SQLite path executes SELECT COUNT(*) against the cached join
# tables rather than fetching all rows. The result must agree with the length
# of selectall_arrayref for equivalent criteria.
# ===========================================================================
subtest 'count(): SQLite backend count agrees with selectall_arrayref length (no criteria)' => sub {
plan tests => 1;
# Reuse the s18 fixtures (Alpha/Beta/Gamma with tier and score) which are
# already built -- no new fixture needed.
my $j = Database::Join->new(
databases => [$s18_db_a, $s18_db_b], join_column => $JC,
backend => 'sqlite',
);
is($j->count(),
scalar @{ $j->selectall_arrayref() },
'count() on SQLite path equals the length of selectall_arrayref() with no criteria');
};
subtest 'count(): SQLite backend count with criteria agrees with filtered selectall_arrayref' => sub {
plan tests => 1;
my $j = Database::Join->new(
databases => [$s18_db_a, $s18_db_b], join_column => $JC,
backend => 'sqlite',
);
my $filtered = $j->selectall_arrayref(tier => 'gold');
is($j->count(tier => 'gold'),
scalar @{$filtered},
'count(tier => gold) on SQLite path equals the filtered selectall_arrayref length');
};
diag('section 26 done') if $ENV{TEST_VERBOSE};
# ===========================================================================
# SECTION 27 -- updated() resilience: missing and throwing implementations (2 subtests)
#
# updated() silently skips component DAs that do not implement updated() or
# whose updated() throws an exception. Returns undef when no component DA
# implements updated() at all. Regression for the 0.007.0 fix.
# ===========================================================================
# IntBareDA: a minimal duck-type DA with NO updated() method. Has only the
# two methods Database::Join requires by its duck-type guard: columns() and
# selectall_arrayref().
{
package IntBareDA;
sub new {
my ($class, %args) = @_;
return bless { _cols => $args{cols} // [], _rows => $args{rows} // [] }, $class;
}
sub columns { return $_[0]->{_cols} }
sub schema { return {} }
sub selectall_arrayref { return $_[0]->{_rows} }
sub DESTROY {}
}
# IntThrowUpdDA: inherits from InMemDA but overrides updated() to die,
# exercising the "skip DAs whose updated() throws" branch.
{
package IntThrowUpdDA;
our @ISA = ('InMemDA');
sub updated { die "simulated updated() failure\n" }
sub DESTROY {}
}
subtest 'updated(): returns undef when no component DA implements updated()' => sub {
plan tests => 1;
# IntBareDA has no updated() method; the join must return undef, not croak.
my $db = IntBareDA->new(
cols => [$JC, 'x'],
rows => [ { entry => 'K', x => 1 } ],
);
my $j = Database::Join->new(databases => [$db], join_column => $JC);
ok(!defined $j->updated(),
'updated() returns undef when no component DA implements updated()');
};
subtest 'updated(): silently skips a component DA whose updated() throws' => sub {
plan tests => 2;
# One good DA with a known timestamp; one that throws from updated().
# The join must return the good timestamp without propagating the exception.
Readonly::Scalar my $GOOD_TS => 9_999_999;
my $good_db = InMemDA->new(
cols => [$JC, 'x'],
rows => [ { entry => 'K', x => 1 } ],
updated => $GOOD_TS,
);
my $bad_db = IntThrowUpdDA->new(
cols => [$JC, 'y'],
rows => [ { entry => 'K', y => 2 } ],
updated => 0, # irrelevant -- updated() will die before returning
);
my $j = Database::Join->new(databases => [$good_db, $bad_db], join_column => $JC);
my $ts;
lives_ok { $ts = $j->updated() }
'updated() does not propagate a die thrown by a component DA';
is($ts, $GOOD_TS,
'updated() returns the good component timestamp, silently skipping the one that threw');
};
diag('section 27 done -- integration tests complete') if $ENV{TEST_VERBOSE};
( run in 0.344 second using v1.01-cache-2.11-cpan-7da0f45c409 )