The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Test::DBIx::Class::Schema;

{
  $Test::DBIx::Class::Schema::DIST = 'Test-DBIx-Class-Schema';
}
$Test::DBIx::Class::Schema::VERSION = '1.0.12';
# vim: ts=8 sts=4 et sw=4 sr sta
use strict;
use warnings;

# ensure we have "done_testing"
# we also want to be sure we're using the recent enough version to fix a bug
# in the output (https://github.com/chiselwright/test-dbix-class-schema/issues/12)
use Test::More 1.302015;

sub new {
    my ($proto, $options) = @_;
    my $self = (defined $options) ? $options : {};
    bless $self, ref($proto) || $proto;
    return $self;
}

# for populating the correct part of $self
sub methods {
    my ($self, $hashref) = @_;

    $self->{methods} = $hashref;

    return;
}

sub run_tests {
    my ($self) = @_;
    my ($schema, $rs, $record);

    # make sure we can use the schema (namespace) module
    use_ok( $self->{namespace} );

    # let users pass in an existing $schema if they (somehow) have one
    if (defined $self->{schema}) {
        $schema = $self->{schema};
    }
    else {
        # get a schema to query
        $schema = $self->{namespace}->connect(
            $self->{dsn},
            $self->{username},
            $self->{password},
        );
    }
    isa_ok($schema, $self->{namespace});

    # create a new resultset object and perform tests on it
    # - this allows us to test ->my_column() without requiring data
    $rs = $schema->resultset( $self->{moniker} );
    $record = $schema->resultset( $self->{moniker} )->new_result({});

    # make sure our record presents itself as the correct object type
    if (defined $self->{glue}) {
        isa_ok(
            $record,
                $self->{namespace}
            . '::' . $self->{glue}
            . '::' . $self->{moniker}
        );
    }
    else {
        # It looks like the namespace has changed with newer record objects so
        # that they don't get ::Schema in their name.
        # So that wew can work with either we now want our record to be the
        # namespace+moniker with an option '::Schema' in the name.
        # This means moving away from isa_ok() to like() on a ref()
        my $expected_type_re = $self->{namespace} . '::' . $self->{moniker};
           $expected_type_re =~ s{::Schema}{(?:::Schema)?};
        my $regexp = qr{$expected_type_re};
        like(ref($record), $regexp, "The record object is a ::$self->{moniker}");
    }

    $self->_test_normal_methods($rs);
    $self->_test_special_methods($record);
    $self->_test_resultset_methods($rs);
    $self->_test_unexpected_normal_methods($rs);

    # TODO: test custom, resultsets

    my $ctx = Test::More->builder->ctx;
    my $tb2_already_done_testing =
        defined $ctx->snapshot->hub->meta('Test::Builder')->{Done_Testing};
    $ctx->release;
    done_testing
        unless ($tb2_already_done_testing || $ENV{TEST_AGGREGATE});
}

sub _test_normal_methods {
    my $self    = shift;
    my $rs  = shift;

    my @std_method_types        = qw(columns relations);

    # 'normal' methods; row & relation
    # we can try calling these as they gave no side-effects
    my @proxied;
    foreach my $method_type (@std_method_types) {
        SKIP: {
            if (not exists $self->{methods}{$method_type} or not @{ $self->{methods}{$method_type} }) {
                skip qq{no $method_type methods}, 1;
            }

            # try calling each method
            METHOD: foreach my $method ( @{ $self->{methods}{$method_type} } ) {
                # make sure we can call the method
                my $source = $rs->result_source;
                my $related_source;

                # 'normal' relationship
                if ($source->has_relationship($method)) {
                    eval {
                        $related_source = $source->related_source($method);
                    };
                    is($@, q{}, qq{related source for '$method' exists});

                    # test self.* and foreign.* columns are valid
                    my $cond_ref = $source->relationship_info($method)->{cond};
                    $cond_ref = ref $cond_ref eq 'ARRAY' ? $cond_ref : [ $cond_ref ];
                    COND: foreach my $cond ( @$cond_ref ) {
                        # you can have CODE as the cond_ref - that's unexpected!
                        TODO: {
                            if ('CODE' eq ref($cond)) {
                                local $TODO = qq{skipping column tests for CODE defined condition};
                                fail(qq{test '$method' with CODE definition});
                                next COND;
                            }
                        }
                        foreach my $foreign_col (keys %{$cond} ) {
                            my $self_col = $cond->{$foreign_col};
                            s{^\w+\.}{} for ( $self_col, $foreign_col );
                            eval {
                                $source->resultset->slice(0,0)->get_column($self_col)->all;
                            };
                            is($@, q{}, qq{self.$self_col valid for '$method' relationship});
                            eval {
                                $related_source->resultset->slice(0,0)->get_column($foreign_col)->all;
                            };
                            is($@, q{}, qq{foreign.$foreign_col valid for '$method' relationship});
                        }
                    }
                }

                # many_to_many and proxy
                elsif ( $method_type eq 'relations' ) {
                    # TODO: Factor this out with the same code under proxied
                    # 'columns' accessors
                    RELATIONSHIP:
                    for my $relationship ( $source->relationships ) {
                        my $proxy = $source->relationship_info($relationship)->{attrs}{proxy};
                        next RELATIONSHIP if not $proxy;
                        if ( grep m{$method}, @$proxy ) {
                            pass qq{'$method' relationship exists via proxied relationship '$relationship'};
                            next METHOD;
                        }
                    }
                    my $result = $rs->new({});
                    # many_to_many
                    if ( $result->can($method)
                     and $result->$method->isa('DBIx::Class::ResultSet') ) {
                        pass("'$method' relation is a many-to-many");
                    }
                    else {
                        fail("'$method' is not a valid relationship" );
                    }
                }

                # column accessor
                elsif ( $method_type eq 'columns' ) {
                    if ( $source->has_column($method) ) {
                        pass qq{'$method' column defined in result_source};
                        eval {
                            # https://rt.cpan.org/Ticket/Display.html?id=65521
                            my $col = $rs->slice(0,0)->get_column($method)->all;
                        };
                        is($@, q{}, qq{'$method' column exists in database});
                        next METHOD;
                    }
                    # Proxied columns
                    RELATIONSHIP:
                    for my $relationship ( $source->relationships ) {
                        my $proxy = $source->relationship_info($relationship)->{attrs}{proxy};
                        next RELATIONSHIP if not $proxy;
                        if ( grep m{$method}, @$proxy ) {
                            pass(qq{'$method' column exists via proxied relationship '$relationship'});
                            next METHOD;
                        }
                    }
                    fail qq{'$method' column does not exist and is not proxied};
                }
                # ... erm ... what's this?
                else {
                    die qq{unknown method type: $method_type};
                }
            }
        }
    } # foreach
    return;
}

sub _test_special_methods {
    shift->_test_methods(shift, [qw/custom/]);
}

sub _test_resultset_methods {
    shift->_test_methods(shift, [qw/resultsets/]);
}

sub _test_methods {
    my $self            = shift;
    my $thingy          = shift;
    my $method_types    = shift;

    # 'special' methods; custom
    # we can't call these as they may have unknown parameters,
    # side effects, etc
    foreach my $method_type (@{ $method_types} ) {
        SKIP: {
            skip qq{no $method_type methods}, 1
                    unless 
                        exists     $self->{methods}{$method_type}
                           and  @{ $self->{methods}{$method_type} };
            ok(
                @{ $self->{methods}{$method_type} },
                qq{$method_type list found for testing}
            );
        }

        # call can on each method to make it obvious what's being tested
        foreach my $method (@{ $self->{methods}{$method_type} } ) {
            can_ok( $thingy, $method );
        }
    } # foreach
    return;
}

sub _test_unexpected_normal_methods {
    my($self,$rs) = @_;
    my $source    = $rs->result_source;

    my $set = {
        'columns'   => [ $source->columns ],
        'relations' => [ $source->relationships ],
    };

    foreach my $method_type (sort keys %{$set}) {
        SKIP: {
            if (not exists $self->{methods}->{$method_type} or not @{ $self->{methods}->{$method_type} }) {
                skip qq{no $method_type methods}, 1;
            }

            my @diff = $self->_diff_arrays(
                $self->{methods}->{$method_type},
                $set->{$method_type},
            );
    
            my $plural = (scalar @diff == 1) ? '' : 's';
            my $message =
                qq{'$method_type' method${plural} defined in }
                . $self->{moniker}
                . ' but untested: '
                . join(', ',@diff);
    
            if ($self->{test_missing}) {
                is_deeply(
                    \@diff,
                    [],
                    "All known $method_type method${plural} defined in test"
                ) || diag $message;
            }
            else {
                if (scalar @diff) {
                   diag $message;
                }
            }
        }
    }
}

sub _diff_arrays {
    my($self,$min,$full) = @_;
    my @min = @{$min};
    my @full = @{$full};

    my %mapped = map{ $_ => 1 } @min;
    my @diff = grep (!defined $mapped{$_}, @full);

    if (wantarray) {
        return @diff;
    }
    return \@diff;
}


1;
# ABSTRACT: DBIx::Class schema sanity checking tests

__END__

=pod

=encoding UTF-8

=head1 NAME

Test::DBIx::Class::Schema - DBIx::Class schema sanity checking tests

=head1 VERSION

version 1.0.12

=head1 SYNOPSIS

Create a test script that looks like this:

    #!/usr/bin/perl
    # vim: ts=8 sts=4 et sw=4 sr sta
    use strict;
    use warnings;

    # load the module that provides all of the common test functionality
    use Test::DBIx::Class::Schema;

    # create a new test object
    my $schematest = Test::DBIx::Class::Schema->new(
        {
            # required
            dsn       => 'dbi:Pg:dbname=mydb', # or use schema option
            namespace => 'MyDB::Schema',
            moniker   => 'SomeTable',
            # optional
            username  => 'some_user',
            password  => 'opensesame',
            glue      => 'Result',             # fix class name if needed
            # rather than calling diag will test that all columns/relationships
            # are accounted for in your test and fail the test if not
            test_missing => 1,
        }
    );

    # tell it what to test
    $schematest->methods(
        {
            columns => [
                qw[
                    id
                    column1
                    column2
                    columnX
                    foo_id
                ]
            ],

            relations => [
                qw[
                    foo
                ]
            ],

            custom => [
                qw[
                    some_method
                ]
            ],

            resultsets => [
                qw[
                ]
            ],
        }
    );

    # run the tests
    $schematest->run_tests();

Run the test script:

  prove -l t/schematest/xx.mydb.t

=head2 Options

Either C<dsn> (eg C<dbi:Pg:dbname=mydb>) or C<schema> (an already
created schema object) must be set.

If the database requires credentials, set C<username> and C<password>.

C<namespace>, C<glue> and C<moniker> define the class being tested.
For example, if your class is C<MyDB::Schema::Result::SomeTable> then use:

  namespace => 'MyDB::Schema',
  glue      => 'Result,
  moniker   => 'SomeTable',

C<glue> is not required if the combination of C<namespace> and C<moniker>
is enough to define the class, e.g. C<MyDB::Schema::SomeTable>.

=head2 done_testing

Under normal circumstances there is no need to add C<done_testing> to your
test script; it's automatically called at the end of C<run_tests()> I<unless>
you are running tests under L<Test::Aggregate>.

If you are running aggregated tests you will need to add

  done_testing;

to your top-level script.

If you are running under L<Test::Class::Moose> or L<Test::Class> you will need to
disable this behaviour manually as there is no way to detect it. To do that,
set C<$ENV{TEST_AGGREGATE} = 1> before calling C<run_tests> or your test suite
might blow up.

=head1 DESCRIPTION

It's really useful to be able to test and confirm that DBIC classes have and
support a known set of methods.

Testing these one-by-one is more than tedious and likely to discourage you
from writing the relevant test scripts.

As a lazy person myself I don't want to write numerous near-identical scripts.

Test::DBIx::Class::Schema takes the copy-and-paste out of DBIC schema class testing.

=head1 SEE ALSO

L<DBIx::Class>,
L<Test::More>,
L<Test::Aggregate>

=begin markdown

## BUILD STATUS

### master

[![Build Status](https://travis-ci.org/chiselwright/test-dbix-class-schema.svg?branch=master)](https://travis-ci.org/chiselwright/test-dbix-class-schema)

=end markdown

=head1 AUTHOR

Chisel Wright <chisel@chizography.net>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2017 by Chisel Wright.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=head1 CONTRIBUTORS

=for stopwords Darius Jokilehto Dave Cross Jason Tang Rupert Lane simbabque

=over 4

=item *

Darius Jokilehto <darius.jokilehto@net-a-porter.com>

=item *

Dave Cross <davidc@broadbean.com>

=item *

Jason Tang <tang.jason.ch@gmail.com>

=item *

Rupert Lane <rupert@rupert-lane.org>

=item *

simbabque <simbabque@cpan.org>

=back

=cut