[Bps-public-commit] dbix-searchbuilder branch search-support-bind created. 1.69-2-g1f3fc64
BPS Git Server
git at git.bestpractical.com
Wed Sep 1 20:08:30 UTC 2021
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "dbix-searchbuilder".
The branch, search-support-bind has been created
at 1f3fc646ffb9f23b1b2ab19efd866435f5283a9a (commit)
- Log -----------------------------------------------------------------
commit 1f3fc646ffb9f23b1b2ab19efd866435f5283a9a
Author: sunnavy <sunnavy at bestpractical.com>
Date: Thu Sep 2 03:18:22 2021 +0800
Test bind values for searches
diff --git a/t/03searches_bind.t b/t/03searches_bind.t
new file mode 100644
index 0000000..07906cc
--- /dev/null
+++ b/t/03searches_bind.t
@@ -0,0 +1,366 @@
+use strict;
+use Test::More;
+
+BEGIN { require "t/utils.pl" }
+our (@AvailableDrivers);
+
+use constant TESTS_PER_DRIVER => 39;
+
+my $total = scalar(@AvailableDrivers) * TESTS_PER_DRIVER;
+plan tests => $total;
+
+foreach my $d (@AvailableDrivers) {
+SKIP: {
+ unless ( has_schema( 'TestApp', $d ) ) {
+ skip "No schema for '$d' driver", TESTS_PER_DRIVER;
+ }
+ unless ( should_test($d) ) {
+ skip "ENV is not defined for driver '$d'", TESTS_PER_DRIVER;
+ }
+
+ my $handle = get_handle($d);
+ connect_handle($handle);
+ isa_ok( $handle->dbh, 'DBI::db' );
+
+ my $ret = init_schema( 'TestApp', $handle );
+ isa_ok( $ret, 'DBI::st', "Inserted the schema. got a statement handle back" );
+
+ my $count_users = init_data( 'TestApp::User', $handle );
+ ok( $count_users, "init users data" );
+ my $count_groups = init_data( 'TestApp::Group', $handle );
+ ok( $count_groups, "init groups data" );
+ my $count_us2gs = init_data( 'TestApp::UsersToGroup', $handle );
+ ok( $count_us2gs, "init users&groups relations data" );
+
+ my $clean_obj = TestApp::Users->new($handle);
+
+ local $ENV{SB_PREFER_BIND};
+
+ my $users_obj = $clean_obj->Clone;
+ for my $login ( 'Gandalf', "Bilbo\\Baggins", "Baggins' Frodo" ) {
+ $users_obj->Limit( FIELD => 'Login', VALUE => $login );
+ is( $users_obj->Count, 1, "only one value" );
+ is( $users_obj->First->Login, $login, "$login is the only match" );
+
+ # Using \W here because Login might be wrapped in LOWER().
+ ok( $users_obj->BuildSelectQuery =~ /Login\W*=\s*\?/i,
+ 'found a placeholder in select query' );
+ ok( $users_obj->BuildSelectCountQuery =~ /Login\W*=\s*\?/i,
+ 'found a placeholder in select count query'
+ );
+ $users_obj->CleanSlate;
+ }
+
+ $users_obj->Limit(
+ FIELD => 'Login',
+ VALUE => [ "Bilbo\\Baggins", "Baggins' Frodo" ],
+ OPERATOR => 'IN',
+ );
+ is( $users_obj->Count, 2, "2 values" );
+ is_deeply(
+ [ sort map { $_->Login } @{ $users_obj->ItemsArrayRef } ],
+ [ "Baggins' Frodo", "Bilbo\\Baggins" ],
+ '2 Baggins',
+ );
+ $users_obj->CleanSlate;
+
+ for my $name ( "Shire's Bag End", 'The Fellowship of the Ring' ) {
+ my $groups_obj = TestApp::Groups->new($handle);
+ $groups_obj->Limit( FIELD => 'Name', VALUE => $name, OPERATOR => 'LIKE' );
+ $groups_obj->Limit( FIELD => 'id', VALUE => 0, OPERATOR => '>' );
+ is( $groups_obj->Count, 1, "only one value" );
+ is( $groups_obj->First->Name, $name, "$name is the only match" );
+
+ # Using \W here because Login might be wrapped in LOWER().
+ ok( $groups_obj->BuildSelectQuery =~ /Name\W*I?LIKE\s*\?/i,
+ 'found a placeholder for Name in select query'
+ );
+ ok( $groups_obj->BuildSelectQuery =~ /id\s*>\s*\?/i,
+ 'found a placeholder for id in select query'
+ );
+ ok( $groups_obj->BuildSelectCountQuery =~ /Name\W*I?LIKE\s*\?/i,
+ 'found a placeholder for Name in select count query'
+ );
+ ok( $groups_obj->BuildSelectCountQuery =~ /id\s*>\s*\?/i,
+ 'found a placeholder for id in select count query'
+ );
+ }
+
+ my $alias = $users_obj->Join(
+ FIELD1 => 'id',
+ TABLE2 => 'UsersToGroups',
+ FIELD2 => 'UserId'
+ );
+
+ my $group_alias = $users_obj->Join(
+ ALIAS1 => $alias,
+ FIELD1 => 'GroupID',
+ ALIAS2 => $users_obj->NewAlias('Groups'),
+ FIELD2 => 'id'
+ );
+ $users_obj->Limit(
+ LEFTJOIN => $group_alias,
+ FIELD => 'Name',
+ VALUE => "Shire's Bag End",
+ );
+
+ is( $users_obj->Count, 2, "2 values" );
+ is_deeply(
+ [ sort map { $_->Login } @{ $users_obj->ItemsArrayRef } ],
+ [ "Baggins' Frodo", "Bilbo\\Baggins" ],
+ '2 Baggins',
+ );
+
+ # ? in JOIN condition
+ ok( $users_obj->BuildSelectQuery( PreferBind => 0 ) !~ /\?/,
+ 'found placeholder in select query' );
+ ok( $users_obj->BuildSelectCountQuery( PreferBind => 0 ) !~ /\?/,
+ 'found placeholder in select count query' );
+
+ ok( $users_obj->BuildSelectQuery( PreferBind => 0 ) !~ /\?/,
+ 'no placeholder in select query' );
+ ok( $users_obj->BuildSelectCountQuery( PreferBind => 0 ) !~ /\?/,
+ 'no placeholder in select count query' );
+ local $DBIx::SearchBuilder::PREFER_BIND = 0;
+ ok( $users_obj->BuildSelectQuery !~ /\?/, 'no placeholder in select query' );
+ ok( $users_obj->BuildSelectCountQuery !~ /\?/, 'no placeholder in select count query' );
+
+ cleanup_schema( 'TestApp', $handle );
+
+ }
+} # SKIP, foreach blocks
+
+1;
+
+package TestApp;
+
+sub schema_sqlite {
+ [ q{
+CREATE TABLE Users (
+ id integer primary key,
+ Login varchar(36)
+) },
+ q{
+CREATE TABLE UsersToGroups (
+ id integer primary key,
+ UserId integer,
+ GroupId integer
+) },
+ q{
+CREATE TABLE Groups (
+ id integer primary key,
+ Name varchar(36)
+) },
+ ]
+}
+
+sub schema_mysql {
+ [ q{
+CREATE TEMPORARY TABLE Users (
+ id integer primary key AUTO_INCREMENT,
+ Login varchar(36)
+) },
+ q{
+CREATE TEMPORARY TABLE UsersToGroups (
+ id integer primary key AUTO_INCREMENT,
+ UserId integer,
+ GroupId integer
+) },
+ q{
+CREATE TEMPORARY TABLE `Groups` (
+ id integer primary key AUTO_INCREMENT,
+ Name varchar(36)
+) },
+ ]
+}
+
+sub schema_pg {
+ [ q{
+CREATE TEMPORARY TABLE Users (
+ id serial primary key,
+ Login varchar(36)
+) },
+ q{
+CREATE TEMPORARY TABLE UsersToGroups (
+ id serial primary key,
+ UserId integer,
+ GroupId integer
+) },
+ q{
+CREATE TEMPORARY TABLE Groups (
+ id serial primary key,
+ Name varchar(36)
+) },
+ ]
+}
+
+sub schema_oracle {
+ [ "CREATE SEQUENCE Users_seq",
+ "CREATE TABLE Users (
+ id integer CONSTRAINT Users_Key PRIMARY KEY,
+ Login varchar(36)
+ )",
+ "CREATE SEQUENCE UsersToGroups_seq",
+ "CREATE TABLE UsersToGroups (
+ id integer CONSTRAINT UsersToGroups_Key PRIMARY KEY,
+ UserId integer,
+ GroupId integer
+ )",
+ "CREATE SEQUENCE Groups_seq",
+ "CREATE TABLE Groups (
+ id integer CONSTRAINT Groups_Key PRIMARY KEY,
+ Name varchar(36)
+ )",
+ ]
+}
+
+sub cleanup_schema_oracle {
+ [ "DROP SEQUENCE Users_seq",
+ "DROP TABLE Users",
+ "DROP SEQUENCE Groups_seq",
+ "DROP TABLE Groups",
+ "DROP SEQUENCE UsersToGroups_seq",
+ "DROP TABLE UsersToGroups",
+ ]
+}
+
+package TestApp::User;
+
+use base $ENV{SB_TEST_CACHABLE}
+ ? qw/DBIx::SearchBuilder::Record::Cachable/
+ : qw/DBIx::SearchBuilder::Record/;
+
+sub _Init {
+ my $self = shift;
+ my $handle = shift;
+ $self->Table('Users');
+ $self->_Handle($handle);
+}
+
+sub _ClassAccessible {
+ {
+
+ id => { read => 1, type => 'int(11)' },
+ Login => { read => 1, write => 1, type => 'varchar(36)' },
+
+ }
+}
+
+sub init_data {
+ return (
+ ['Login'],
+
+ ['Gandalf'],
+ ["Bilbo\\Baggins"],
+ ["Baggins' Frodo"],
+ );
+}
+
+package TestApp::Users;
+
+use base qw/DBIx::SearchBuilder/;
+
+sub _Init {
+ my $self = shift;
+ $self->SUPER::_Init( Handle => shift );
+ $self->Table('Users');
+}
+
+sub NewItem {
+ my $self = shift;
+ return TestApp::User->new( $self->_Handle );
+}
+
+1;
+
+package TestApp::Group;
+
+use base $ENV{SB_TEST_CACHABLE}
+ ? qw/DBIx::SearchBuilder::Record::Cachable/
+ : qw/DBIx::SearchBuilder::Record/;
+
+sub _Init {
+ my $self = shift;
+ my $handle = shift;
+ $self->Table('Groups');
+ $self->_Handle($handle);
+}
+
+sub _ClassAccessible {
+ { id => { read => 1, type => 'int(11)' },
+ Name => { read => 1, write => 1, type => 'varchar(36)' },
+ }
+}
+
+sub init_data {
+ return (
+ ['Name'],
+
+ ["Shire's Bag End"],
+ ['The Fellowship of the Ring'],
+ );
+}
+
+package TestApp::Groups;
+
+use base qw/DBIx::SearchBuilder/;
+
+sub _Init {
+ my $self = shift;
+ $self->SUPER::_Init( Handle => shift );
+ $self->Table('Groups');
+}
+
+sub NewItem { return TestApp::Group->new( (shift)->_Handle ) }
+
+1;
+
+package TestApp::UsersToGroup;
+
+use base $ENV{SB_TEST_CACHABLE}
+ ? qw/DBIx::SearchBuilder::Record::Cachable/
+ : qw/DBIx::SearchBuilder::Record/;
+
+sub _Init {
+ my $self = shift;
+ my $handle = shift;
+ $self->Table('UsersToGroups');
+ $self->_Handle($handle);
+}
+
+sub _ClassAccessible {
+ {
+
+ id => { read => 1, type => 'int(11)' },
+ UserId => { read => 1, type => 'int(11)' },
+ GroupId => { read => 1, type => 'int(11)' },
+ }
+}
+
+sub init_data {
+ return (
+ [ 'GroupId', 'UserId' ],
+
+ # Shire
+ [ 1, 2 ],
+ [ 1, 3 ],
+
+ # Fellowship of the Ring
+ [ 2, 1 ],
+ [ 2, 3 ],
+ );
+}
+
+package TestApp::UsersToGroups;
+
+use base qw/DBIx::SearchBuilder/;
+
+sub _Init {
+ my $self = shift;
+ $self->Table('UsersToGroups');
+ return $self->SUPER::_Init( Handle => shift );
+}
+
+sub NewItem { return TestApp::UsersToGroup->new( (shift)->_Handle ) }
+
+1;
commit 161a7a3ce537edbd2c7cb4b861af87c5b140f07c
Author: sunnavy <sunnavy at bestpractical.com>
Date: Mon Aug 30 04:19:13 2021 +0800
Support bind values for searches
This is mainly to make database(especially Oracle) happy and cache
plans, which improves performance.
diff --git a/lib/DBIx/SearchBuilder.pm b/lib/DBIx/SearchBuilder.pm
index 2356f3b..3e658e9 100755
--- a/lib/DBIx/SearchBuilder.pm
+++ b/lib/DBIx/SearchBuilder.pm
@@ -10,6 +10,7 @@ use Clone qw();
use Encode qw();
use Scalar::Util qw(blessed);
use DBIx::SearchBuilder::Util qw/ sorted_values /;
+our $PREFER_BIND = $ENV{SB_PREFER_BIND} // 1;
=head1 NAME
@@ -151,6 +152,7 @@ sub CleanSlate {
group_by
columns
query_hint
+ _bind_values
);
#we have no limit statements. DoSearch won't work.
@@ -238,7 +240,7 @@ sub _DoSearch {
delete $self->{'items'};
$self->{'itemscount'} = 0;
- my $records = $self->_Handle->SimpleQuery($QueryString);
+ my $records = $self->_Handle->SimpleQuery( $QueryString, @{ $self->{_bind_values} || [] } );
return 0 unless $records;
while ( my $row = $records->fetchrow_hashref() ) {
@@ -294,7 +296,7 @@ sub _DoCount {
my $all = shift || 0;
my $QueryString = $self->BuildSelectCountQuery();
- my $records = $self->_Handle->SimpleQuery($QueryString);
+ my $records = $self->_Handle->SimpleQuery( $QueryString, @{ $self->{_bind_values} || [] } );
return 0 unless $records;
my @row = $records->fetchrow_array();
@@ -353,7 +355,7 @@ Build up all of the joins we need to perform this query.
sub _BuildJoins {
my $self = shift;
- return ( $self->_Handle->_BuildJoins($self) );
+ return ( $self->_Handle->_BuildJoins($self, @_) );
}
@@ -419,19 +421,25 @@ sub _isLimited {
-=head2 BuildSelectQuery
+=head2 BuildSelectQuery PreferBind => 1|0
Builds a query string for a "SELECT rows from Tables" statement for this SearchBuilder object
+If C<PreferBind> is true, the generated query will use bind variables where
+possible. If C<PreferBind> is not passed, it defaults to package variable
+C<$DBIx::SearchBuilder::PREFER_BIND>, which defaults to
+C<$ENV{SB_PREFER_BIND}>(if it's defined) or true.
+
=cut
sub BuildSelectQuery {
my $self = shift;
+ my %args = ( PreferBind => $PREFER_BIND, @_ );
# The initial SELECT or SELECT DISTINCT is decided later
- my $QueryString = $self->_BuildJoins . " ";
- $QueryString .= $self->_WhereClause . " "
+ my $QueryString = $self->_BuildJoins(%args) . " ";
+ $QueryString .= $self->_WhereClause(%args) . " "
if ( $self->_isLimited > 0 );
my $QueryHint = $self->QueryHintFormatted;
@@ -461,7 +469,7 @@ sub BuildSelectQuery {
-=head2 BuildSelectCountQuery
+=head2 BuildSelectCountQuery PreferBind => 1|0
Builds a SELECT statement to find the number of rows this SearchBuilder object would find.
@@ -469,12 +477,13 @@ Builds a SELECT statement to find the number of rows this SearchBuilder object w
sub BuildSelectCountQuery {
my $self = shift;
+ my %args = ( PreferBind => $PREFER_BIND, @_ );
#TODO refactor DoSearch and DoCount such that we only have
# one place where we build most of the querystring
- my $QueryString = $self->_BuildJoins . " ";
+ my $QueryString = $self->_BuildJoins(%args) . " ";
- $QueryString .= $self->_WhereClause . " "
+ $QueryString .= $self->_WhereClause(%args) . " "
if ( $self->_isLimited > 0 );
@@ -640,7 +649,7 @@ sub DistinctFieldValues {
}
my $dbh = $self->_Handle->dbh;
- my $list = $dbh->selectcol_arrayref( $query_string, { MaxRows => $args{'Max'} } );
+ my $list = $dbh->selectcol_arrayref( $query_string, { MaxRows => $args{'Max'} }, @{ $self->{_bind_values} || [] } );
return $list? @$list : ();
}
@@ -877,7 +886,7 @@ sub Limit {
warn "Collection in '$args{OPERATOR}' with more than one column selected, using first";
splice @{ $args{'VALUE'}{'columns'} }, 1;
}
- $args{'VALUE'} = '('. $args{'VALUE'}->BuildSelectQuery .')';
+ $args{'VALUE'} = '('. $args{'VALUE'}->BuildSelectQuery(PreferBind => 0) .')';
$args{'QUOTEVALUE'} = 0;
}
elsif ( ref $args{'VALUE'} ) {
@@ -1061,7 +1070,7 @@ sub _WhereClause {
#Go through all the generic restrictions and build up the "generic_restrictions" subclause
# That's the only one that SearchBuilder builds itself.
# Arguably, the abstraction should be better, but I don't really see where to put it.
- $self->_CompileGenericRestrictions();
+ $self->_CompileGenericRestrictions(@_);
#Go through all restriction types. Build the where clause from the
#Various subclauses.
@@ -1081,6 +1090,7 @@ sub _WhereClause {
sub _CompileGenericRestrictions {
my $self = shift;
+ my %args = @_;
my $result = '';
#Go through all the restrictions of this type. Buld up the generic subclause
@@ -1092,6 +1102,14 @@ sub _CompileGenericRestrictions {
$result .= ' '. $entry . ' ';
}
else {
+ if ( $args{PreferBind} ) {
+ my ( $placeholder, @bind_values ) = $self->_ExtractBindValues( $entry->{value} );
+ if ( $placeholder ) {
+ push @{ $self->{_bind_values} }, @bind_values;
+ $result .= join ' ', @{$entry}{qw(field op)}, $placeholder;
+ next;
+ }
+ }
$result .= join ' ', @{$entry}{qw(field op value)};
}
}
@@ -1907,6 +1925,27 @@ DEPRECATED AND DOES NOTHING.
sub ImportRestrictions { }
+
+sub _ExtractBindValues {
+ my $self = shift;
+ my $value = shift;
+ my $placeholder;
+ my @bind_values;
+ if ( $value =~ /^\d+$/ ) {
+ $placeholder = '?';
+ push @bind_values, $value;
+ }
+ elsif ( $value =~ /^E?'.*'$/i ) {
+ $placeholder = '?';
+ push @bind_values, $self->_Handle->Dequote($value);
+ }
+ elsif ( $value =~ /^\s*\(\s*(?!SELECT)(.+)\)\s*$/is ) {
+ push @bind_values, $self->_Handle->SplitAndDequote($1);
+ $placeholder = '(' . join( ', ', ('?') x @bind_values ) . ')';
+ }
+ return ( $placeholder, @bind_values );
+}
+
# not even documented
sub DEBUG { warn "DEBUG is deprecated" }
diff --git a/lib/DBIx/SearchBuilder/Handle.pm b/lib/DBIx/SearchBuilder/Handle.pm
index ed49ff7..e128071 100755
--- a/lib/DBIx/SearchBuilder/Handle.pm
+++ b/lib/DBIx/SearchBuilder/Handle.pm
@@ -1222,6 +1222,7 @@ sub _NormalJoin {
sub _BuildJoins {
my $self = shift;
my $sb = shift;
+ my %args = @_;
$self->OptimizeJoins( SearchBuilder => $sb );
my $table = $self->{'QuoteTableNames'} ? $self->QuoteName($sb->Table) : $sb->Table;
@@ -1230,6 +1231,7 @@ sub _BuildJoins {
my %processed = map { /^\S+\s+(\S+)$/; $1 => 1 } @{ $sb->{'aliases'} };
$processed{'main'} = 1;
+ $sb->{_bind_values} = [];
# get a @list of joins that have not been processed yet, but depend on processed join
my $joins = $sb->{'left_joins'};
while ( my @list =
@@ -1245,9 +1247,24 @@ sub _BuildJoins {
$join_clause .= $meta->{'alias_string'} . " ON ";
my @tmp = map {
- ref($_)?
- $_->{'field'} .' '. $_->{'op'} .' '. $_->{'value'}:
+ if ( ref $_ ) {
+ if ( $args{PreferBind} ) {
+ my ( $placeholder, @bind_values ) = $sb->_ExtractBindValues( $_->{'value'} );
+ if ( $placeholder ) {
+ push @{ $sb->{_bind_values} }, @bind_values;
+ $_->{'field'} . ' ' . $_->{'op'} . ' ' . $placeholder;
+ }
+ else {
+ $_->{'field'} .' '. $_->{'op'} .' '. $_->{'value'};
+ }
+ }
+ else {
+ $_->{'field'} .' '. $_->{'op'} .' '. $_->{'value'};
+ }
+ }
+ else {
$_
+ }
}
map { ('(', @$_, ')', $aggregator) } sorted_values($meta->{'criteria'});
pop @tmp;
@@ -1813,6 +1830,98 @@ sub DequoteName {
return $name;
}
+=head2 Dequote
+
+Undo the quote effects for quoted values
+
+=cut
+
+sub Dequote {
+ my ( $self, $value ) = @_;
+ if ( $value && $value =~ /^(E?)'(.*)'$/i ) {
+ $value = $2;
+ if ($1) {
+ $value =~ s!\\(.)!$1!g;
+ }
+ else {
+ $value =~ s!''!'!g;
+ }
+ }
+ return $value;
+}
+
+=head2 SplitAndDequote
+
+Split comma separated quoted values and return a list of dequoted values.
+
+This is useful to parse value of C<IN> operator.
+
+=cut
+
+sub SplitAndDequote {
+ my $self = shift;
+ my $string = shift;
+ my $default_escape_char = shift || q{'};
+ return $string unless defined $string;
+
+ my @chars = split //, $string;
+ my $value = '';
+ my $escape_char = $default_escape_char;
+
+ my @values;
+ my $in = 0; # keep state in the loop: is it in a value?
+ while ( defined( my $c = shift @chars ) ) {
+ my $escaped;
+ if ( $c eq $escape_char && $in ) {
+ if ( $escape_char eq q{'} ) {
+ if ( ( $chars[0] || '' ) eq q{'} ) {
+ $c = shift @chars;
+ $escaped = 1;
+ }
+ }
+ else {
+ $c = shift @chars;
+ $escaped = 1;
+ }
+ }
+
+ if ($in) {
+ if ( $c eq q{'} ) {
+ if ( !$escaped ) {
+ push @values, $value;
+ $in = 0;
+ $value = '';
+ $escape_char = $default_escape_char;
+ next;
+ }
+ }
+ $value .= $c;
+ }
+ else {
+ if ( $c eq q{'} ) {
+ $in = 1;
+ }
+ elsif ( $c eq 'E' ) {
+ $escape_char = '\\';
+ }
+ elsif ( $c =~ /[-\d]/ ) {
+ $value .= $c;
+ while ( my $next = shift @chars ) {
+ if ( $next =~ /\d/ ) {
+ $value .= $c;
+ }
+ else {
+ push @values, $value;
+ $value = '';
+ last;
+ }
+ }
+ }
+ }
+ }
+ return @values;
+}
+
sub _RequireQuotedTables { return 0 };
=head2 DESTROY
diff --git a/lib/DBIx/SearchBuilder/Handle/mysql.pm b/lib/DBIx/SearchBuilder/Handle/mysql.pm
index 0e24364..b6de8b6 100755
--- a/lib/DBIx/SearchBuilder/Handle/mysql.pm
+++ b/lib/DBIx/SearchBuilder/Handle/mysql.pm
@@ -323,6 +323,22 @@ sub DequoteName {
return $name;
}
+sub Dequote {
+ my ($self, $value) = @_;
+ if ( $value && $value =~ /^'(.*)'$/ ) {
+ $value = $1;
+ $value =~ s!\\(.)!$1!g;
+ }
+ return $value;
+}
+
+sub SplitAndDequote {
+ my $self = shift;
+ my $value = shift;
+ return $self->SUPER::SplitAndDequote( $value, '\\' );
+}
+
+
sub _IsMariaDB {
my $self = shift;
-----------------------------------------------------------------------
hooks/post-receive
--
dbix-searchbuilder
More information about the Bps-public-commit
mailing list