load extensions statically to avoid bad object wrapping interactions
[scpubgit/stemmatology.git] / base / lib / Text / Tradition / Directory.pm
CommitLineData
8d9a1cd8 1package Text::Tradition::Directory;
2
3use strict;
4use warnings;
5use Moose;
98a6cab2 6use DBI;
0a900793 7use Encode qw/ decode_utf8 /;
ad1291ee 8use KiokuDB::GC::Naive;
8d9a1cd8 9use KiokuDB::TypeMap;
10use KiokuDB::TypeMap::Entry::Naive;
951ddfe8 11use Safe::Isa;
861c3e27 12use Text::Tradition::Error;
8d9a1cd8 13
cf7e4e7b 14## users
15use KiokuX::User::Util qw(crypt_password);
f3f26624 16use Text::Tradition::Store;
cf7e4e7b 17use Text::Tradition::User;
f3f26624 18use Text::Tradition::TypeMap::Entry;
cf7e4e7b 19
8d9a1cd8 20extends 'KiokuX::Model';
21
12523041 22=head1 NAME
23
24Text::Tradition::Directory - a KiokuDB interface for storing and retrieving traditions
25
26=head1 SYNOPSIS
27
28 use Text::Tradition::Directory;
29 my $d = Text::Tradition::Directory->new(
30 'dsn' => 'dbi:SQLite:mytraditions.db',
31 'extra_args' => { 'create' => 1 },
32 );
33
34 my $tradition = Text::Tradition->new( @args );
37bf09f4 35 my $stemma = $tradition->add_stemma( dotfile => $dotfile ); # if Analysis module installed
12523041 36 $d->save_tradition( $tradition );
12523041 37
38 foreach my $id ( $d->traditions ) {
39 print $d->tradition( $id )->name;
12523041 40 }
770f7a2b 41
42 ## Users:
43 my $userstore = Text::Tradition::UserStore->new(dsn => 'dbi:SQLite:foo.db');
44 my $newuser = $userstore->add_user({ username => 'fred',
45 password => 'somepassword' });
46
47 my $fetchuser = $userstore->find_user({ username => 'fred' });
48 if($fetchuser->check_password('somepassword')) {
49 ## login user or .. whatever
50 }
51
52 my $user = $userstore->deactivate_user({ username => 'fred' });
53 if(!$user->active) {
54 ## shouldnt be able to login etc
55 }
12523041 56
57=head1 DESCRIPTION
58
59Text::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.
60
770f7a2b 61=head1 ATTRIBUTES
62
63=head2 MIN_PASS_LEN
64
65Constant for the minimum password length when validating passwords,
66defaults to "8".
67
68=cut
69
70has MIN_PASS_LEN => ( is => 'ro', isa => 'Num', default => sub { 8 } );
71
12523041 72=head1 METHODS
73
74=head2 new
75
56cf65bd 76Returns a Directory object.
12523041 77
98a6cab2 78=head2 traditionlist
12523041 79
98a6cab2 80Returns a hashref mapping of ID => name for all traditions in the directory.
12523041 81
82=head2 tradition( $id )
83
84Returns the Text::Tradition object of the given ID.
85
56cf65bd 86=head2 save( $tradition )
12523041 87
56cf65bd 88Writes the given tradition to the database, returning its ID.
12523041 89
d7ba60b4 90=head2 delete( $tradition )
91
92Deletes the given tradition object from the database.
93WARNING!! Garbage collection does not yet work. Use this sparingly.
94
12523041 95=begin testing
96
861c3e27 97use TryCatch;
12523041 98use File::Temp;
951ddfe8 99use Safe::Isa;
12523041 100use Text::Tradition;
12523041 101use_ok 'Text::Tradition::Directory';
102
103my $fh = File::Temp->new();
104my $file = $fh->filename;
105$fh->close;
106my $dsn = "dbi:SQLite:dbname=$file";
861c3e27 107my $uuid;
12523041 108my $t = Text::Tradition->new(
56cf65bd 109 'name' => 'inline',
110 'input' => 'Tabular',
111 'file' => 't/data/simple.txt',
112 );
37bf09f4 113my $stemma_enabled = $t->can( 'add_stemma' );
56cf65bd 114
861c3e27 115{
116 my $d = Text::Tradition::Directory->new( 'dsn' => $dsn,
117 'extra_args' => { 'create' => 1 } );
951ddfe8 118 ok( $d->$_isa('Text::Tradition::Directory'), "Got directory object" );
861c3e27 119
120 my $scope = $d->new_scope;
121 $uuid = $d->save( $t );
122 ok( $uuid, "Saved test tradition" );
123
951ddfe8 124 SKIP: {
125 skip "Analysis package not installed", 5 unless $stemma_enabled;
126 my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
127 ok( $d->save( $t ), "Updated tradition with stemma" );
128 is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
129 is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
130 try {
131 $d->save( $s );
132 } catch( Text::Tradition::Error $e ) {
133 is( $e->ident, 'database error', "Got exception trying to save stemma directly" );
134 like( $e->message, qr/Cannot directly save non-Tradition object/,
135 "Exception has correct message" );
136 }
861c3e27 137 }
138}
139my $nt = Text::Tradition->new(
140 'name' => 'CX',
141 'input' => 'CollateX',
142 'file' => 't/data/Collatex-16.xml',
143 );
951ddfe8 144ok( $nt->$_isa('Text::Tradition'), "Made new tradition" );
861c3e27 145
146{
147 my $f = Text::Tradition::Directory->new( 'dsn' => $dsn );
148 my $scope = $f->new_scope;
98a6cab2 149 is( scalar $f->traditionlist, 1, "Directory index has our tradition" );
861c3e27 150 my $nuuid = $f->save( $nt );
151 ok( $nuuid, "Stored second tradition" );
98a6cab2 152 my @tlist = $f->traditionlist;
153 is( scalar @tlist, 2, "Directory index has both traditions" );
861c3e27 154 my $tf = $f->tradition( $uuid );
98a6cab2 155 my( $tlobj ) = grep { $_->{'id'} eq $uuid } @tlist;
156 is( $tlobj->{'name'}, $tf->name, "Directory index has correct tradition name" );
861c3e27 157 is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
951ddfe8 158 my $sid;
159 SKIP: {
160 skip "Analysis package not installed", 4 unless $stemma_enabled;
161 $sid = $f->object_to_id( $tf->stemma(0) );
162 try {
163 $f->tradition( $sid );
164 } catch( Text::Tradition::Error $e ) {
165 is( $e->ident, 'database error', "Got exception trying to fetch stemma directly" );
166 like( $e->message, qr/not a Text::Tradition/, "Exception has correct message" );
167 }
168 try {
169 $f->delete( $sid );
170 } catch( Text::Tradition::Error $e ) {
171 is( $e->ident, 'database error', "Got exception trying to delete stemma directly" );
172 like( $e->message, qr/Cannot directly delete non-Tradition object/,
173 "Exception has correct message" );
174 }
861c3e27 175 }
ad39942e 176
861c3e27 177 $f->delete( $uuid );
178 ok( !$f->exists( $uuid ), "Object is deleted from DB" );
951ddfe8 179 ok( !$f->exists( $sid ), "Object stemma also deleted from DB" ) if $stemma_enabled;
98a6cab2 180 is( scalar $f->traditionlist, 1, "Object is deleted from index" );
861c3e27 181}
182
d7ba60b4 183{
861c3e27 184 my $g = Text::Tradition::Directory->new( 'dsn' => $dsn );
185 my $scope = $g->new_scope;
98a6cab2 186 is( scalar $g->traditionlist, 1, "Now one object in new directory index" );
ad39942e 187 my $ntobj = $g->tradition( 'CX' );
09909f9d 188 my @w1 = sort { $a->sigil cmp $b->sigil } $ntobj->witnesses;
189 my @w2 = sort{ $a->sigil cmp $b->sigil } $nt->witnesses;
ad39942e 190 is_deeply( \@w1, \@w2, "Looked up remaining tradition by name" );
861c3e27 191}
12523041 192
193=end testing
194
195=cut
fc7b6388 196use Text::Tradition::TypeMap::Entry;
12523041 197
12523041 198has +typemap => (
fc7b6388 199 is => 'rw',
200 isa => 'KiokuDB::TypeMap',
201 default => sub {
202 KiokuDB::TypeMap->new(
203 isa_entries => {
f3f26624 204 # now that we fall back to YAML deflation, all attributes of
205 # Text::Tradition will be serialized to YAML as individual objects
206 # Except if we declare a specific entry type here
fc7b6388 207 "Text::Tradition" =>
f3f26624 208 KiokuDB::TypeMap::Entry::MOP->new(),
209 # We need users to be naive entries so that they hold
210 # references to the original tradition objects, not clones
211 "Text::Tradition::User" =>
212 KiokuDB::TypeMap::Entry::MOP->new(),
213 "Text::Tradition::Collation" =>
214 KiokuDB::TypeMap::Entry::MOP->new(),
215 "Text::Tradition::Witness" =>
216 KiokuDB::TypeMap::Entry::MOP->new(),
fb4caab6 217 "Graph" => Text::Tradition::TypeMap::Entry->new(),
7e17346f 218 "Set::Scalar" => Text::Tradition::TypeMap::Entry->new(),
fc7b6388 219 }
220 );
221 },
8d9a1cd8 222);
223
98a6cab2 224# Push some columns into the extra_args
225around BUILDARGS => sub {
226 my $orig = shift;
227 my $class = shift;
228 my $args;
229 if( @_ == 1 ) {
230 $args = $_[0];
231 } else {
232 $args = { @_ };
233 }
f3f26624 234 my @column_args;
98a6cab2 235 if( $args->{'dsn'} =~ /^dbi/ ) { # We're using Backend::DBI
f3f26624 236 @column_args = ( 'columns',
52dcc672 237 [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 },
238 'public' => { 'data_type' => 'bool', 'is_nullable' => 1 } ] );
98a6cab2 239 }
f3f26624 240 my $ea = $args->{'extra_args'};
241 if( ref( $ea ) eq 'ARRAY' ) {
242 push( @$ea, @column_args );
243 } elsif( ref( $ea ) eq 'HASH' ) {
244 $ea = { %$ea, @column_args };
245 } else {
246 $ea = { @column_args };
247 }
248 $args->{'extra_args'} = $ea;
249
98a6cab2 250 return $class->$orig( $args );
251};
252
f3f26624 253override _build_directory => sub {
254 my($self) = @_;
255 Text::Tradition::Store->connect(@{ $self->_connect_args },
256 resolver_constructor => sub {
257 my($class) = @_;
258 $class->new({ typemap => $self->directory->merged_typemap,
259 fallback_entry => Text::Tradition::TypeMap::Entry->new() });
260 });
261};
262
7cb56251 263## These checks don't cover store($id, $obj)
fc7b6388 264# before [ qw/ store update insert delete / ] => sub {
265before [ qw/ delete / ] => sub {
8d9a1cd8 266 my $self = shift;
861c3e27 267 my @nontrad;
268 foreach my $obj ( @_ ) {
951ddfe8 269 if( ref( $obj ) && !$obj->$_isa( 'Text::Tradition' )
270 && !$obj->$_isa('Text::Tradition::User') ) {
861c3e27 271 # Is it an id => Tradition hash?
272 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
273 my( $k ) = keys %$obj;
951ddfe8 274 next if $obj->{$k}->$_isa('Text::Tradition');
8d9a1cd8 275 }
861c3e27 276 push( @nontrad, $obj );
8d9a1cd8 277 }
12523041 278 }
861c3e27 279 if( @nontrad ) {
280 throw( "Cannot directly save non-Tradition object of type "
281 . ref( $nontrad[0] ) );
282 }
283};
12523041 284
d7ba60b4 285# TODO Garbage collection doesn't work. Suck it up and live with the
286# inflated DB.
d94224d9 287after delete => sub {
288 my $self = shift;
289 my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
290 $self->directory->backend->delete( $gc->garbage->members );
291};
56cf65bd 292
293sub save {
861c3e27 294 my $self = shift;
295 return $self->store( @_ );
12523041 296}
297
56cf65bd 298sub tradition {
299 my( $self, $id ) = @_;
300 my $obj = $self->lookup( $id );
ad39942e 301 unless( $obj ) {
302 # Try looking up by name.
303 foreach my $item ( $self->traditionlist ) {
304 if( $item->{'name'} eq $id ) {
305 $obj = $self->lookup( $item->{'id'} );
306 last;
307 }
308 }
309 }
951ddfe8 310 if( $obj && !$obj->$_isa('Text::Tradition') ) {
861c3e27 311 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
12523041 312 }
56cf65bd 313 return $obj;
12523041 314}
8d9a1cd8 315
98a6cab2 316sub traditionlist {
861c3e27 317 my $self = shift;
fefeeeda 318 my ($user) = @_;
319
320 return $self->user_traditionlist($user) if($user);
321
322 my @tlist;
98a6cab2 323 # If we are using DBI, we can do it the easy way; if not, the hard way.
324 # Easy way still involves making a separate DBI connection. Ew.
0a900793 325 if( $self->dsn =~ /^dbi:(\w+):/ ) {
326 my $dbtype = $1;
98a6cab2 327 my @connection = @{$self->directory->backend->connect_info};
328 # Get rid of KiokuDB-specific arg
329 pop @connection if scalar @connection > 4;
0a900793 330 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
331 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
98a6cab2 332 my $dbh = DBI->connect( @connection );
52dcc672 333 my $q = $dbh->prepare( 'SELECT id, name, public from entries WHERE class = "Text::Tradition"' );
98a6cab2 334 $q->execute();
335 while( my @row = $q->fetchrow_array ) {
0a900793 336 my( $id, $name ) = @row;
337 # Horrible horrible hack
338 $name = decode_utf8( $name ) if $dbtype eq 'mysql';
52dcc672 339 push( @tlist, { 'id' => $row[0], 'name' => $row[1], 'public' => $row[2] } );
98a6cab2 340 }
341 } else {
342 $self->scan( sub { my $o = shift;
343 push( @tlist, { 'id' => $self->object_to_id( $o ),
52dcc672 344 'name' => $o->name,
345 'public' => $o->public } ) } );
98a6cab2 346 }
347 return @tlist;
861c3e27 348}
349
350sub throw {
351 Text::Tradition::Error->throw(
352 'ident' => 'database error',
353 'message' => $_[0],
354 );
355}
356
cf7e4e7b 357
358# has 'directory' => (
359# is => 'rw',
360# isa => 'KiokuX::Model',
361# handles => []
362# );
363
364## TODO: Some of these methods should probably optionally take $user objects
365## instead of hashrefs.
366
367## It also occurs to me that all these methods don't need to be named
368## XX_user, but leaving that way for now incase we merge this code
369## into ::Directory for one-store.
370
a445ce40 371=head1 USER DIRECTORY METHODS
cf7e4e7b 372
a445ce40 373=head2 add_user( $userinfo )
cf7e4e7b 374
375Takes a hashref of C<username>, C<password>.
376
377Create a new user object, store in the KiokuDB backend, and return it.
378
379=cut
380
381sub add_user {
382 my ($self, $userinfo) = @_;
10ef7653 383
384 my $username = $userinfo->{username};
cf7e4e7b 385 my $password = $userinfo->{password};
7cb56251 386 my $role = $userinfo->{role} || 'user';
cf7e4e7b 387
b77f6c1b 388 throw( "No username given" ) unless $username;
389 throw( "Invalid password - must be at least " . $self->MIN_PASS_LEN
390 . " characters long" )
391 unless ( $self->validate_password($password) || $username =~ /^https?:/ );
cf7e4e7b 392
393 my $user = Text::Tradition::User->new(
394 id => $username,
395 password => ($password ? crypt_password($password) : ''),
a528f0f6 396 email => ($userinfo->{email} ? $userinfo->{email} : $username),
7cb56251 397 role => $role,
cf7e4e7b 398 );
399
cf7e4e7b 400 $self->store($user->kiokudb_object_id, $user);
401
402 return $user;
403}
404
a445ce40 405=head2 create_user( $userinfo )
406
407Takes a hashref that can either be suitable for add_user (see above) or be
408a hash of OpenID user information from Credential::OpenID.
409
410=cut
411
cf7e4e7b 412sub create_user {
10ef7653 413 my ($self, $userinfo) = @_;
414
415 ## No username means probably an OpenID based user
416 if(!exists $userinfo->{username}) {
a445ce40 417 _extract_openid_data($userinfo);
10ef7653 418 }
419
420 return $self->add_user($userinfo);
421}
422
423## Not quite sure where this method should be.. Auth /
424## Credential::OpenID just pass us back the chunk of extension data
a445ce40 425sub _extract_openid_data {
10ef7653 426 my ($userinfo) = @_;
427
428 ## Spec says SHOULD use url as identifier
429 $userinfo->{username} = $userinfo->{url};
430
431 ## Use email addy as display if available
432 if(exists $userinfo->{extensions} &&
433 exists $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'} &&
434 defined $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'}) {
435 ## Somewhat ugly attribute extension reponse, contains
436 ## google-email string which we can use as the id
437
a528f0f6 438 $userinfo->{email} = $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'};
10ef7653 439 }
440
441 return;
cf7e4e7b 442}
443
a445ce40 444=head2 find_user( $userinfo )
cf7e4e7b 445
10ef7653 446Takes a hashref of C<username>, and possibly openIDish results from
447L<Net::OpenID::Consumer>.
cf7e4e7b 448
449Fetches the user object for the given username and returns it.
450
451=cut
452
453sub find_user {
454 my ($self, $userinfo) = @_;
10ef7653 455
456 ## No username means probably an OpenID based user
457 if(!exists $userinfo->{username}) {
a445ce40 458 _extract_openid_data($userinfo);
10ef7653 459 }
460
461 my $username = $userinfo->{username};
cf7e4e7b 462
df8c12f0 463 ## No logins if user is deactivated (use lookup to fetch to re-activate)
464 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
10ef7653 465 return if(!$user || !$user->active);
466
c80a73ea 467# print STDERR "Found user, $username, email is :", $user->email, ":\n";
df8c12f0 468
469 return $user;
cf7e4e7b 470}
471
a445ce40 472=head2 modify_user( $userinfo )
cf7e4e7b 473
474Takes a hashref of C<username> and C<password> (same as add_user).
475
476Retrieves the user, and updates it with the new information. Username
477changing is not currently supported.
478
479Returns the updated user object, or undef if not found.
480
481=cut
482
483sub modify_user {
484 my ($self, $userinfo) = @_;
485 my $username = $userinfo->{username};
486 my $password = $userinfo->{password};
4d4c5789 487 my $role = $userinfo->{role};
cf7e4e7b 488
52dcc672 489 throw( "Missing username" ) unless $username;
cf7e4e7b 490
cf7e4e7b 491 my $user = $self->find_user({ username => $username });
b77f6c1b 492 throw( "Could not find user $username" ) unless $user;
cf7e4e7b 493
4d4c5789 494 if($password) {
52dcc672 495 throw( "Bad password" ) unless $self->validate_password($password);
4d4c5789 496 $user->password(crypt_password($password));
497 }
498 if($role) {
499 $user->role($role);
500 }
cf7e4e7b 501
502 $self->update($user);
503
504 return $user;
505}
506
a445ce40 507=head2 deactivate_user( $userinfo )
cf7e4e7b 508
509Takes a hashref of C<username>.
510
511Sets the users C<active> flag to false (0), and sets all traditions
512assigned to them to non-public, updates the storage and returns the
513deactivated user.
514
515Returns undef if user not found.
516
517=cut
518
519sub deactivate_user {
520 my ($self, $userinfo) = @_;
521 my $username = $userinfo->{username};
522
b77f6c1b 523 throw( "Need to specify a username for deactivation" ) unless $username;
cf7e4e7b 524
525 my $user = $self->find_user({ username => $username });
b77f6c1b 526 throw( "User $username not found" ) unless $user;
cf7e4e7b 527
528 $user->active(0);
529 foreach my $tradition (@{ $user->traditions }) {
530 ## Not implemented yet
531 # $tradition->public(0);
532 }
cf7e4e7b 533
534 ## Should we be using Text::Tradition::Directory also?
535 $self->update(@{ $user->traditions });
536
537 $self->update($user);
538
539 return $user;
540}
541
a445ce40 542=head2 reactivate_user( $userinfo )
cf7e4e7b 543
544Takes a hashref of C<username>.
545
546Returns the user object if already activated. Activates (sets the
547active flag to true (1)), updates the storage and returns the user.
548
549Returns undef if the user is not found.
550
551=cut
552
553sub reactivate_user {
554 my ($self, $userinfo) = @_;
555 my $username = $userinfo->{username};
556
b77f6c1b 557 throw( "Need to specify a username for reactivation" ) unless $username;
cf7e4e7b 558
df8c12f0 559 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
b77f6c1b 560 throw( "User $username not found" ) unless $user;
cf7e4e7b 561
562 return $user if $user->active;
563
564 $user->active(1);
565 $self->update($user);
566
567 return $user;
568}
569
a445ce40 570=head2 delete_user( $userinfo )
cf7e4e7b 571
770f7a2b 572CAUTION: Deletes actual data!
cf7e4e7b 573
574Takes a hashref of C<username>.
575
576Returns undef if the user doesn't exist.
577
578Removes the user from the store and returns 1.
579
580=cut
581
582sub delete_user {
583 my ($self, $userinfo) = @_;
584 my $username = $userinfo->{username};
585
b77f6c1b 586 throw( "Need to specify a username for deletion" ) unless $username;
cf7e4e7b 587
cf7e4e7b 588 my $user = $self->find_user({ username => $username });
b77f6c1b 589 throw( "User $username not found" ) unless $user;
cf7e4e7b 590
591 ## Should we be using Text::Tradition::Directory for this bit?
592 $self->delete( @{ $user->traditions });
593
594 ## Poof, gone.
595 $self->delete($user);
596
597 return 1;
598}
599
a445ce40 600=head2 validate_password( $password )
cf7e4e7b 601
602Takes a password string. Returns true if it is longer than
603L</MIN_PASS_LEN>, false otherwise.
604
605Used internally by L</add_user>.
606
607=cut
608
609sub validate_password {
610 my ($self, $password) = @_;
611
612 return if !$password;
613 return if length($password) < $self->MIN_PASS_LEN;
614
615 return 1;
616}
617
a445ce40 618=head2 user_traditionlist( $user )
619
620Returns a tradition list (see specification above) but containing only
621those traditions visible to the specified user. If $user is the string
622'public', returns only publicly-viewable traditions.
623
624=cut
625
626sub user_traditionlist {
627 my ($self, $user) = @_;
628
629 my @tlist;
630 if(ref $user && $user->is_admin) {
631 ## Admin sees all
632 return $self->traditionlist();
633 } elsif(ref $user) {
634 ## We have a user object already, so just fetch its traditions and use tose
635 foreach my $t (@{ $user->traditions }) {
636 push( @tlist, { 'id' => $self->object_to_id( $t ),
637 'name' => $t->name } );
638 }
639 return @tlist;
640 } elsif($user ne 'public') {
641 die "Passed neither a user object nor 'public' to user_traditionlist";
642 }
643
644 ## Search for all traditions which allow public viewing
645 my @list = grep { $_->{public} } $self->traditionlist();
646 return @list;
647}
648
8d9a1cd8 6491;
12523041 650
027d819c 651=head1 LICENSE
652
653This package is free software and is provided "as is" without express
654or implied warranty. You can redistribute it and/or modify it under
655the same terms as Perl itself.
656
657=head1 AUTHOR
658
659Tara L Andrews E<lt>aurum@cpan.orgE<gt>