add support for direct SQL query of directory
[scpubgit/stemmatology.git] / lib / Text / Tradition / Directory.pm
1 package Text::Tradition::Directory;
2
3 use strict;
4 use warnings;
5 use Moose;
6 use DBI;
7 use KiokuDB::GC::Naive;
8 use KiokuDB::TypeMap;
9 use KiokuDB::TypeMap::Entry::Naive;
10 use Text::Tradition::Error;
11
12 extends 'KiokuX::Model';
13
14 =head1 NAME
15
16 Text::Tradition::Directory - a KiokuDB interface for storing and retrieving traditions
17
18 =head1 SYNOPSIS
19
20   use Text::Tradition::Directory;
21   my $d = Text::Tradition::Directory->new( 
22     'dsn' => 'dbi:SQLite:mytraditions.db',
23     'extra_args' => { 'create' => 1 },
24   );
25   
26   my $tradition = Text::Tradition->new( @args );
27   my $stemma = $tradition->add_stemma( dotfile => $dotfile ); 
28   $d->save_tradition( $tradition );
29   
30   foreach my $id ( $d->traditions ) {
31         print $d->tradition( $id )->name;
32   }
33     
34 =head1 DESCRIPTION
35
36 Text::Tradition::Directory is an interface for storing and retrieving text traditions and all their data, including an associated stemma hypothesis.  It is an instantiation of a KiokuDB::Model, storing traditions and associated stemmas by UUID.
37
38 =head1 METHODS
39
40 =head2 new
41
42 Returns a Directory object. 
43
44 =head2 traditionlist
45
46 Returns a hashref mapping of ID => name for all traditions in the directory.
47
48 =head2 tradition( $id )
49
50 Returns the Text::Tradition object of the given ID.
51
52 =head2 save( $tradition )
53
54 Writes the given tradition to the database, returning its ID.
55
56 =head2 delete( $tradition )
57
58 Deletes the given tradition object from the database.
59 WARNING!! Garbage collection does not yet work. Use this sparingly.
60
61 =begin testing
62
63 use TryCatch;
64 use File::Temp;
65 use Text::Tradition;
66 use_ok 'Text::Tradition::Directory';
67
68 my $fh = File::Temp->new();
69 my $file = $fh->filename;
70 $fh->close;
71 my $dsn = "dbi:SQLite:dbname=$file";
72 my $uuid;
73 my $t = Text::Tradition->new( 
74         'name'  => 'inline', 
75         'input' => 'Tabular',
76         'file'  => 't/data/simple.txt',
77         );
78
79 {
80         my $d = Text::Tradition::Directory->new( 'dsn' => $dsn,
81                 'extra_args' => { 'create' => 1 } );
82         is( ref $d, 'Text::Tradition::Directory', "Got directory object" );
83         
84         my $scope = $d->new_scope;
85         $uuid = $d->save( $t );
86         ok( $uuid, "Saved test tradition" );
87         
88         my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
89         ok( $d->save( $t ), "Updated tradition with stemma" );
90         is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
91         is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
92         try {
93                 $d->save( $s );
94         } catch( Text::Tradition::Error $e ) {
95                 is( $e->ident, 'database error', "Got exception trying to save stemma directly" );
96                 like( $e->message, qr/Cannot directly save non-Tradition object/, 
97                         "Exception has correct message" );
98         }
99 }
100 my $nt = Text::Tradition->new(
101         'name' => 'CX',
102         'input' => 'CollateX',
103         'file' => 't/data/Collatex-16.xml',
104         );
105 is( ref( $nt ), 'Text::Tradition', "Made new tradition" );
106
107 {
108         my $f = Text::Tradition::Directory->new( 'dsn' => $dsn );
109         my $scope = $f->new_scope;
110         is( scalar $f->traditionlist, 1, "Directory index has our tradition" );
111         my $nuuid = $f->save( $nt );
112         ok( $nuuid, "Stored second tradition" );
113         my @tlist = $f->traditionlist;
114         is( scalar @tlist, 2, "Directory index has both traditions" );
115         my $tf = $f->tradition( $uuid );
116         my( $tlobj ) = grep { $_->{'id'} eq $uuid } @tlist;
117         is( $tlobj->{'name'}, $tf->name, "Directory index has correct tradition name" );
118         is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
119         my $sid = $f->object_to_id( $tf->stemma(0) );
120         try {
121                 $f->tradition( $sid );
122         } catch( Text::Tradition::Error $e ) {
123                 is( $e->ident, 'database error', "Got exception trying to fetch stemma directly" );
124                 like( $e->message, qr/not a Text::Tradition/, "Exception has correct message" );
125         }
126         try {
127                 $f->delete( $sid );
128         } catch( Text::Tradition::Error $e ) {
129                 is( $e->ident, 'database error', "Got exception trying to delete stemma directly" );
130                 like( $e->message, qr/Cannot directly delete non-Tradition object/, 
131                         "Exception has correct message" );
132         }
133         $f->delete( $uuid );
134         ok( !$f->exists( $uuid ), "Object is deleted from DB" );
135         ok( !$f->exists( $sid ), "Object stemma also deleted from DB" );
136         is( scalar $f->traditionlist, 1, "Object is deleted from index" );
137 }
138
139 {
140         my $g = Text::Tradition::Directory->new( 'dsn' => $dsn );
141         my $scope = $g->new_scope;
142         is( scalar $g->traditionlist, 1, "Now one object in new directory index" );
143 }
144
145 =end testing
146
147 =cut
148
149 has +typemap => (
150         is => 'rw',
151         isa => 'KiokuDB::TypeMap',
152         default => sub { 
153                 KiokuDB::TypeMap->new(
154                         isa_entries => {
155                                 "Graph" => KiokuDB::TypeMap::Entry::Naive->new,
156                                 "Graph::AdjacencyMap" => KiokuDB::TypeMap::Entry::Naive->new,
157                         }
158                 );
159         },
160 );
161
162 # Push some columns into the extra_args
163 around BUILDARGS => sub {
164         my $orig = shift;
165         my $class = shift;
166         my $args;
167         if( @_ == 1 ) {
168                 $args = $_[0];
169         } else {
170                 $args = { @_ };
171         }
172         if( $args->{'dsn'} =~ /^dbi/ ) { # We're using Backend::DBI
173                 my @column_args = ( 'columns',
174                         [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 } ] );
175                 my $ea = $args->{'extra_args'};
176                 if( ref( $ea ) eq 'ARRAY' ) {
177                         push( @$ea, @column_args );
178                 } elsif( ref( $ea ) eq 'HASH' ) {
179                         $ea = { %$ea, @column_args };
180                 } else {
181                         $ea = { @column_args };
182                 }
183                 $args->{'extra_args'} = $ea;
184         }
185         return $class->$orig( $args );
186 };
187
188 before [ qw/ store update insert delete / ] => sub {
189         my $self = shift;
190         my @nontrad;
191         foreach my $obj ( @_ ) {
192                 if( ref( $obj ) && ref( $obj ) ne 'Text::Tradition' ) {
193                         # Is it an id => Tradition hash?
194                         if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
195                                 my( $k ) = keys %$obj;
196                                 next if ref( $obj->{$k} ) eq 'Text::Tradition';
197                         }
198                         push( @nontrad, $obj );
199                 }
200         }
201         if( @nontrad ) {
202                 throw( "Cannot directly save non-Tradition object of type "
203                         . ref( $nontrad[0] ) );
204         }
205 };
206
207 # TODO Garbage collection doesn't work. Suck it up and live with the 
208 # inflated DB.
209 # after delete => sub {
210 #       my $self = shift;
211 #       my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
212 #       $self->directory->backend->delete( $gc->garbage->members );
213 # };
214
215 sub save {
216         my $self = shift;
217         return $self->store( @_ );
218 }
219
220 sub tradition {
221         my( $self, $id ) = @_;
222         my $obj = $self->lookup( $id );
223         unless( ref( $obj ) eq 'Text::Tradition' ) {
224                 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
225         }
226         return $obj;
227 }
228
229 sub traditionlist {
230         my $self = shift;
231         # If we are using DBI, we can do it the easy way; if not, the hard way.
232         # Easy way still involves making a separate DBI connection. Ew.
233         my @tlist;
234         if( $self->dsn =~ /^dbi/ ) {
235                 $DB::single = 1;
236                 my @connection = @{$self->directory->backend->connect_info};
237                 # Get rid of KiokuDB-specific arg
238                 pop @connection if scalar @connection > 4;
239                 $connection[3]->{'sqlite_unicode'} = 1 if $connection[0] =~ /^dbi:SQLite/;
240                 $connection[3]->{'mysql_enable_utf8'} = 1 if $connection[0] =~ /^dbi:mysql/;
241                 $connection[3]->{'pg_enable_utf8'} = 1 if $connection[0] =~ /^dbi:Pg/;
242                 my $dbh = DBI->connect( @connection );
243                 my $q = $dbh->prepare( 'SELECT id, name from entries WHERE class = "Text::Tradition"' );
244                 $q->execute();
245                 while( my @row = $q->fetchrow_array ) {
246                         push( @tlist, { 'id' => $row[0], 'name' => $row[1] } );
247                 }
248         } else {
249                 $self->scan( sub { my $o = shift; 
250                                                    push( @tlist, { 'id' => $self->object_to_id( $o ), 
251                                                                                    'name' => $o->name } ) } );
252         }
253         return @tlist;
254 }
255
256 sub throw {
257         Text::Tradition::Error->throw( 
258                 'ident' => 'database error',
259                 'message' => $_[0],
260                 );
261 }
262
263 1;
264         
265 =head1 LICENSE
266
267 This package is free software and is provided "as is" without express
268 or implied warranty.  You can redistribute it and/or modify it under
269 the same terms as Perl itself.
270
271 =head1 AUTHOR
272
273 Tara L Andrews E<lt>aurum@cpan.orgE<gt>