Tidy up UserStore/Directory merge
[scpubgit/stemmatology.git] / 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;
861c3e27 11use Text::Tradition::Error;
8d9a1cd8 12
cf7e4e7b 13## users
14use KiokuX::User::Util qw(crypt_password);
15use Text::Tradition::User;
16
8d9a1cd8 17extends 'KiokuX::Model';
18
12523041 19=head1 NAME
20
21Text::Tradition::Directory - a KiokuDB interface for storing and retrieving traditions
22
23=head1 SYNOPSIS
24
25 use Text::Tradition::Directory;
26 my $d = Text::Tradition::Directory->new(
27 'dsn' => 'dbi:SQLite:mytraditions.db',
28 'extra_args' => { 'create' => 1 },
29 );
30
31 my $tradition = Text::Tradition->new( @args );
9ba651b9 32 my $stemma = $tradition->add_stemma( dotfile => $dotfile );
12523041 33 $d->save_tradition( $tradition );
12523041 34
35 foreach my $id ( $d->traditions ) {
36 print $d->tradition( $id )->name;
12523041 37 }
770f7a2b 38
39 ## Users:
40 my $userstore = Text::Tradition::UserStore->new(dsn => 'dbi:SQLite:foo.db');
41 my $newuser = $userstore->add_user({ username => 'fred',
42 password => 'somepassword' });
43
44 my $fetchuser = $userstore->find_user({ username => 'fred' });
45 if($fetchuser->check_password('somepassword')) {
46 ## login user or .. whatever
47 }
48
49 my $user = $userstore->deactivate_user({ username => 'fred' });
50 if(!$user->active) {
51 ## shouldnt be able to login etc
52 }
12523041 53
54=head1 DESCRIPTION
55
56Text::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.
57
770f7a2b 58=head1 ATTRIBUTES
59
60=head2 MIN_PASS_LEN
61
62Constant for the minimum password length when validating passwords,
63defaults to "8".
64
65=cut
66
67has MIN_PASS_LEN => ( is => 'ro', isa => 'Num', default => sub { 8 } );
68
12523041 69=head1 METHODS
70
71=head2 new
72
56cf65bd 73Returns a Directory object.
12523041 74
98a6cab2 75=head2 traditionlist
12523041 76
98a6cab2 77Returns a hashref mapping of ID => name for all traditions in the directory.
12523041 78
79=head2 tradition( $id )
80
81Returns the Text::Tradition object of the given ID.
82
56cf65bd 83=head2 save( $tradition )
12523041 84
56cf65bd 85Writes the given tradition to the database, returning its ID.
12523041 86
d7ba60b4 87=head2 delete( $tradition )
88
89Deletes the given tradition object from the database.
90WARNING!! Garbage collection does not yet work. Use this sparingly.
91
12523041 92=begin testing
93
861c3e27 94use TryCatch;
12523041 95use File::Temp;
96use Text::Tradition;
12523041 97use_ok 'Text::Tradition::Directory';
98
99my $fh = File::Temp->new();
100my $file = $fh->filename;
101$fh->close;
102my $dsn = "dbi:SQLite:dbname=$file";
861c3e27 103my $uuid;
12523041 104my $t = Text::Tradition->new(
56cf65bd 105 'name' => 'inline',
106 'input' => 'Tabular',
107 'file' => 't/data/simple.txt',
108 );
56cf65bd 109
861c3e27 110{
111 my $d = Text::Tradition::Directory->new( 'dsn' => $dsn,
112 'extra_args' => { 'create' => 1 } );
113 is( ref $d, 'Text::Tradition::Directory', "Got directory object" );
114
115 my $scope = $d->new_scope;
116 $uuid = $d->save( $t );
117 ok( $uuid, "Saved test tradition" );
118
9ba651b9 119 my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
861c3e27 120 ok( $d->save( $t ), "Updated tradition with stemma" );
121 is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
e0d617e6 122 is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
861c3e27 123 try {
124 $d->save( $s );
125 } catch( Text::Tradition::Error $e ) {
126 is( $e->ident, 'database error', "Got exception trying to save stemma directly" );
127 like( $e->message, qr/Cannot directly save non-Tradition object/,
128 "Exception has correct message" );
129 }
130}
131my $nt = Text::Tradition->new(
132 'name' => 'CX',
133 'input' => 'CollateX',
134 'file' => 't/data/Collatex-16.xml',
135 );
136is( ref( $nt ), 'Text::Tradition', "Made new tradition" );
137
138{
139 my $f = Text::Tradition::Directory->new( 'dsn' => $dsn );
140 my $scope = $f->new_scope;
98a6cab2 141 is( scalar $f->traditionlist, 1, "Directory index has our tradition" );
861c3e27 142 my $nuuid = $f->save( $nt );
143 ok( $nuuid, "Stored second tradition" );
98a6cab2 144 my @tlist = $f->traditionlist;
145 is( scalar @tlist, 2, "Directory index has both traditions" );
861c3e27 146 my $tf = $f->tradition( $uuid );
98a6cab2 147 my( $tlobj ) = grep { $_->{'id'} eq $uuid } @tlist;
148 is( $tlobj->{'name'}, $tf->name, "Directory index has correct tradition name" );
861c3e27 149 is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
e0d617e6 150 my $sid = $f->object_to_id( $tf->stemma(0) );
861c3e27 151 try {
152 $f->tradition( $sid );
153 } catch( Text::Tradition::Error $e ) {
154 is( $e->ident, 'database error', "Got exception trying to fetch stemma directly" );
155 like( $e->message, qr/not a Text::Tradition/, "Exception has correct message" );
156 }
157 try {
158 $f->delete( $sid );
159 } catch( Text::Tradition::Error $e ) {
160 is( $e->ident, 'database error', "Got exception trying to delete stemma directly" );
161 like( $e->message, qr/Cannot directly delete non-Tradition object/,
162 "Exception has correct message" );
163 }
ad39942e 164
861c3e27 165 $f->delete( $uuid );
166 ok( !$f->exists( $uuid ), "Object is deleted from DB" );
167 ok( !$f->exists( $sid ), "Object stemma also deleted from DB" );
98a6cab2 168 is( scalar $f->traditionlist, 1, "Object is deleted from index" );
861c3e27 169}
170
d7ba60b4 171{
861c3e27 172 my $g = Text::Tradition::Directory->new( 'dsn' => $dsn );
173 my $scope = $g->new_scope;
98a6cab2 174 is( scalar $g->traditionlist, 1, "Now one object in new directory index" );
ad39942e 175 my $ntobj = $g->tradition( 'CX' );
09909f9d 176 my @w1 = sort { $a->sigil cmp $b->sigil } $ntobj->witnesses;
177 my @w2 = sort{ $a->sigil cmp $b->sigil } $nt->witnesses;
ad39942e 178 is_deeply( \@w1, \@w2, "Looked up remaining tradition by name" );
861c3e27 179}
12523041 180
181=end testing
182
183=cut
184
12523041 185has +typemap => (
8d9a1cd8 186 is => 'rw',
187 isa => 'KiokuDB::TypeMap',
188 default => sub {
189 KiokuDB::TypeMap->new(
190 isa_entries => {
8d9a1cd8 191 "Graph" => KiokuDB::TypeMap::Entry::Naive->new,
192 "Graph::AdjacencyMap" => KiokuDB::TypeMap::Entry::Naive->new,
193 }
194 );
195 },
196);
197
98a6cab2 198# Push some columns into the extra_args
199around BUILDARGS => sub {
200 my $orig = shift;
201 my $class = shift;
202 my $args;
203 if( @_ == 1 ) {
204 $args = $_[0];
205 } else {
206 $args = { @_ };
207 }
208 if( $args->{'dsn'} =~ /^dbi/ ) { # We're using Backend::DBI
209 my @column_args = ( 'columns',
210 [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 } ] );
211 my $ea = $args->{'extra_args'};
212 if( ref( $ea ) eq 'ARRAY' ) {
213 push( @$ea, @column_args );
214 } elsif( ref( $ea ) eq 'HASH' ) {
215 $ea = { %$ea, @column_args };
216 } else {
217 $ea = { @column_args };
218 }
219 $args->{'extra_args'} = $ea;
220 }
221 return $class->$orig( $args );
222};
223
861c3e27 224before [ qw/ store update insert delete / ] => sub {
8d9a1cd8 225 my $self = shift;
861c3e27 226 my @nontrad;
227 foreach my $obj ( @_ ) {
cf7e4e7b 228# if( ref( $obj ) && ref( $obj ) ne 'Text::Tradition' ) {
229
230 if( ref( $obj ) && ref( $obj ) ne 'Text::Tradition'
231 && ref ($obj) ne 'Text::Tradition::User' ) {
861c3e27 232 # Is it an id => Tradition hash?
233 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
234 my( $k ) = keys %$obj;
235 next if ref( $obj->{$k} ) eq 'Text::Tradition';
8d9a1cd8 236 }
861c3e27 237 push( @nontrad, $obj );
8d9a1cd8 238 }
12523041 239 }
861c3e27 240 if( @nontrad ) {
241 throw( "Cannot directly save non-Tradition object of type "
242 . ref( $nontrad[0] ) );
243 }
244};
12523041 245
d7ba60b4 246# TODO Garbage collection doesn't work. Suck it up and live with the
247# inflated DB.
248# after delete => sub {
249# my $self = shift;
250# my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
251# $self->directory->backend->delete( $gc->garbage->members );
252# };
56cf65bd 253
254sub save {
861c3e27 255 my $self = shift;
256 return $self->store( @_ );
12523041 257}
258
56cf65bd 259sub tradition {
260 my( $self, $id ) = @_;
261 my $obj = $self->lookup( $id );
ad39942e 262 unless( $obj ) {
263 # Try looking up by name.
264 foreach my $item ( $self->traditionlist ) {
265 if( $item->{'name'} eq $id ) {
266 $obj = $self->lookup( $item->{'id'} );
267 last;
268 }
269 }
270 }
271 if( $obj && ref( $obj ) ne 'Text::Tradition' ) {
861c3e27 272 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
12523041 273 }
56cf65bd 274 return $obj;
12523041 275}
8d9a1cd8 276
98a6cab2 277sub traditionlist {
861c3e27 278 my $self = shift;
98a6cab2 279 # If we are using DBI, we can do it the easy way; if not, the hard way.
280 # Easy way still involves making a separate DBI connection. Ew.
281 my @tlist;
0a900793 282 if( $self->dsn =~ /^dbi:(\w+):/ ) {
283 my $dbtype = $1;
98a6cab2 284 my @connection = @{$self->directory->backend->connect_info};
285 # Get rid of KiokuDB-specific arg
286 pop @connection if scalar @connection > 4;
0a900793 287 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
288 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
98a6cab2 289 my $dbh = DBI->connect( @connection );
290 my $q = $dbh->prepare( 'SELECT id, name from entries WHERE class = "Text::Tradition"' );
291 $q->execute();
292 while( my @row = $q->fetchrow_array ) {
0a900793 293 my( $id, $name ) = @row;
294 # Horrible horrible hack
295 $name = decode_utf8( $name ) if $dbtype eq 'mysql';
98a6cab2 296 push( @tlist, { 'id' => $row[0], 'name' => $row[1] } );
297 }
298 } else {
299 $self->scan( sub { my $o = shift;
300 push( @tlist, { 'id' => $self->object_to_id( $o ),
301 'name' => $o->name } ) } );
302 }
303 return @tlist;
861c3e27 304}
305
306sub throw {
307 Text::Tradition::Error->throw(
308 'ident' => 'database error',
309 'message' => $_[0],
310 );
311}
312
cf7e4e7b 313
314# has 'directory' => (
315# is => 'rw',
316# isa => 'KiokuX::Model',
317# handles => []
318# );
319
320## TODO: Some of these methods should probably optionally take $user objects
321## instead of hashrefs.
322
323## It also occurs to me that all these methods don't need to be named
324## XX_user, but leaving that way for now incase we merge this code
325## into ::Directory for one-store.
326
327## To die or not to die, on error, this is the question.
328
770f7a2b 329=head2 add_user
cf7e4e7b 330
331Takes a hashref of C<username>, C<password>.
332
333Create a new user object, store in the KiokuDB backend, and return it.
334
335=cut
336
337sub add_user {
338 my ($self, $userinfo) = @_;
339 my $username = $userinfo->{url} || $userinfo->{username};
340 my $password = $userinfo->{password};
341
342 return unless ($username =~ /^https?:/
343 || ($username && $self->validate_password($password))) ;
344
345 my $user = Text::Tradition::User->new(
346 id => $username,
347 password => ($password ? crypt_password($password) : ''),
348 );
349
cf7e4e7b 350 $self->store($user->kiokudb_object_id, $user);
351
352 return $user;
353}
354
355sub create_user {
356 my $self = shift;
357 return $self->add_user(@_);
358}
359
770f7a2b 360=head2 find_user
cf7e4e7b 361
362Takes a hashref of C<username>, optionally C<openid_identifier>.
363
364Fetches the user object for the given username and returns it.
365
366=cut
367
368sub find_user {
369 my ($self, $userinfo) = @_;
370 ## url or display?
371 # 'display' => 'castaway.myopenid.com',
372 # 'url' => 'http://castaway.myopenid.com/',
373 my $username = $userinfo->{url} || $userinfo->{username};
374
375 return $self->lookup(Text::Tradition::User->id_for_user($username));
376
377}
378
770f7a2b 379=head2 modify_user
cf7e4e7b 380
381Takes a hashref of C<username> and C<password> (same as add_user).
382
383Retrieves the user, and updates it with the new information. Username
384changing is not currently supported.
385
386Returns the updated user object, or undef if not found.
387
388=cut
389
390sub modify_user {
391 my ($self, $userinfo) = @_;
392 my $username = $userinfo->{username};
393 my $password = $userinfo->{password};
394
395 return unless $username && $self->validate_password($password);
396
cf7e4e7b 397 my $user = $self->find_user({ username => $username });
398 return unless $user;
399
400 $user->password(crypt_password($password));
401
402 $self->update($user);
403
404 return $user;
405}
406
770f7a2b 407=head2 deactivate_user
cf7e4e7b 408
409Takes a hashref of C<username>.
410
411Sets the users C<active> flag to false (0), and sets all traditions
412assigned to them to non-public, updates the storage and returns the
413deactivated user.
414
415Returns undef if user not found.
416
417=cut
418
419sub deactivate_user {
420 my ($self, $userinfo) = @_;
421 my $username = $userinfo->{username};
422
423 return if !$username;
424
425 my $user = $self->find_user({ username => $username });
426 return if !$user;
427
428 $user->active(0);
429 foreach my $tradition (@{ $user->traditions }) {
430 ## Not implemented yet
431 # $tradition->public(0);
432 }
cf7e4e7b 433
434 ## Should we be using Text::Tradition::Directory also?
435 $self->update(@{ $user->traditions });
436
437 $self->update($user);
438
439 return $user;
440}
441
770f7a2b 442=head2 reactivate_user
cf7e4e7b 443
444Takes a hashref of C<username>.
445
446Returns the user object if already activated. Activates (sets the
447active flag to true (1)), updates the storage and returns the user.
448
449Returns undef if the user is not found.
450
451=cut
452
453sub reactivate_user {
454 my ($self, $userinfo) = @_;
455 my $username = $userinfo->{username};
456
457 return if !$username;
458
cf7e4e7b 459 my $user = $self->find_user({ username => $username });
460 return if !$user;
461
462 return $user if $user->active;
463
464 $user->active(1);
465 $self->update($user);
466
467 return $user;
468}
469
770f7a2b 470=head2 delete_user
cf7e4e7b 471
770f7a2b 472CAUTION: Deletes actual data!
cf7e4e7b 473
474Takes a hashref of C<username>.
475
476Returns undef if the user doesn't exist.
477
478Removes the user from the store and returns 1.
479
480=cut
481
482sub delete_user {
483 my ($self, $userinfo) = @_;
484 my $username = $userinfo->{username};
485
486 return if !$username;
487
cf7e4e7b 488 my $user = $self->find_user({ username => $username });
489 return if !$user;
490
491 ## Should we be using Text::Tradition::Directory for this bit?
492 $self->delete( @{ $user->traditions });
493
494 ## Poof, gone.
495 $self->delete($user);
496
497 return 1;
498}
499
770f7a2b 500=head2 validate_password
cf7e4e7b 501
502Takes a password string. Returns true if it is longer than
503L</MIN_PASS_LEN>, false otherwise.
504
505Used internally by L</add_user>.
506
507=cut
508
509sub validate_password {
510 my ($self, $password) = @_;
511
512 return if !$password;
513 return if length($password) < $self->MIN_PASS_LEN;
514
515 return 1;
516}
517
8d9a1cd8 5181;
12523041 519
027d819c 520=head1 LICENSE
521
522This package is free software and is provided "as is" without express
523or implied warranty. You can redistribute it and/or modify it under
524the same terms as Perl itself.
525
526=head1 AUTHOR
527
528Tara L Andrews E<lt>aurum@cpan.orgE<gt>