1 package Text::Tradition::Directory;
7 use Encode qw/ decode_utf8 /;
8 use KiokuDB::GC::Naive;
10 use KiokuDB::TypeMap::Entry::Naive;
11 use Text::Tradition::Error;
14 use KiokuX::User::Util qw(crypt_password);
15 use Text::Tradition::User;
17 extends 'KiokuX::Model';
21 Text::Tradition::Directory - a KiokuDB interface for storing and retrieving traditions
25 use Text::Tradition::Directory;
26 my $d = Text::Tradition::Directory->new(
27 'dsn' => 'dbi:SQLite:mytraditions.db',
28 'extra_args' => { 'create' => 1 },
31 my $tradition = Text::Tradition->new( @args );
32 my $stemma = $tradition->add_stemma( dotfile => $dotfile );
33 $d->save_tradition( $tradition );
35 foreach my $id ( $d->traditions ) {
36 print $d->tradition( $id )->name;
40 my $userstore = Text::Tradition::UserStore->new(dsn => 'dbi:SQLite:foo.db');
41 my $newuser = $userstore->add_user({ username => 'fred',
42 password => 'somepassword' });
44 my $fetchuser = $userstore->find_user({ username => 'fred' });
45 if($fetchuser->check_password('somepassword')) {
46 ## login user or .. whatever
49 my $user = $userstore->deactivate_user({ username => 'fred' });
51 ## shouldnt be able to login etc
56 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.
62 Constant for the minimum password length when validating passwords,
67 has MIN_PASS_LEN => ( is => 'ro', isa => 'Num', default => sub { 8 } );
73 Returns a Directory object.
77 Returns a hashref mapping of ID => name for all traditions in the directory.
79 =head2 tradition( $id )
81 Returns the Text::Tradition object of the given ID.
83 =head2 save( $tradition )
85 Writes the given tradition to the database, returning its ID.
87 =head2 delete( $tradition )
89 Deletes the given tradition object from the database.
90 WARNING!! Garbage collection does not yet work. Use this sparingly.
97 use_ok 'Text::Tradition::Directory';
99 my $fh = File::Temp->new();
100 my $file = $fh->filename;
102 my $dsn = "dbi:SQLite:dbname=$file";
104 my $t = Text::Tradition->new(
106 'input' => 'Tabular',
107 'file' => 't/data/simple.txt',
111 my $d = Text::Tradition::Directory->new( 'dsn' => $dsn,
112 'extra_args' => { 'create' => 1 } );
113 is( ref $d, 'Text::Tradition::Directory', "Got directory object" );
115 my $scope = $d->new_scope;
116 $uuid = $d->save( $t );
117 ok( $uuid, "Saved test tradition" );
119 my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
120 ok( $d->save( $t ), "Updated tradition with stemma" );
121 is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
122 is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
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" );
131 my $nt = Text::Tradition->new(
133 'input' => 'CollateX',
134 'file' => 't/data/Collatex-16.xml',
136 is( ref( $nt ), 'Text::Tradition', "Made new tradition" );
139 my $f = Text::Tradition::Directory->new( 'dsn' => $dsn );
140 my $scope = $f->new_scope;
141 is( scalar $f->traditionlist, 1, "Directory index has our tradition" );
142 my $nuuid = $f->save( $nt );
143 ok( $nuuid, "Stored second tradition" );
144 my @tlist = $f->traditionlist;
145 is( scalar @tlist, 2, "Directory index has both traditions" );
146 my $tf = $f->tradition( $uuid );
147 my( $tlobj ) = grep { $_->{'id'} eq $uuid } @tlist;
148 is( $tlobj->{'name'}, $tf->name, "Directory index has correct tradition name" );
149 is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
150 my $sid = $f->object_to_id( $tf->stemma(0) );
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" );
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" );
166 ok( !$f->exists( $uuid ), "Object is deleted from DB" );
167 ok( !$f->exists( $sid ), "Object stemma also deleted from DB" );
168 is( scalar $f->traditionlist, 1, "Object is deleted from index" );
172 my $g = Text::Tradition::Directory->new( 'dsn' => $dsn );
173 my $scope = $g->new_scope;
174 is( scalar $g->traditionlist, 1, "Now one object in new directory index" );
175 my $ntobj = $g->tradition( 'CX' );
176 my @w1 = sort { $a->sigil cmp $b->sigil } $ntobj->witnesses;
177 my @w2 = sort{ $a->sigil cmp $b->sigil } $nt->witnesses;
178 is_deeply( \@w1, \@w2, "Looked up remaining tradition by name" );
184 use Text::Tradition::TypeMap::Entry;
188 isa => 'KiokuDB::TypeMap',
190 KiokuDB::TypeMap->new(
193 KiokuDB::TypeMap::Entry::Naive->new(),
194 "Graph" => Text::Tradition::TypeMap::Entry->new(),
195 "Graph::AdjacencyMap" => Text::Tradition::TypeMap::Entry->new(),
196 "Set::Scalar" => Text::Tradition::TypeMap::Entry->new(),
202 # Push some columns into the extra_args
203 around BUILDARGS => sub {
212 if( $args->{'dsn'} =~ /^dbi/ ) { # We're using Backend::DBI
213 my @column_args = ( 'columns',
214 [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 } ] );
215 my $ea = $args->{'extra_args'};
216 if( ref( $ea ) eq 'ARRAY' ) {
217 push( @$ea, @column_args );
218 } elsif( ref( $ea ) eq 'HASH' ) {
219 $ea = { %$ea, @column_args };
221 $ea = { @column_args };
223 $args->{'extra_args'} = $ea;
225 return $class->$orig( $args );
228 ## These checks don't cover store($id, $obj)
229 # before [ qw/ store update insert delete / ] => sub {
230 before [ qw/ delete / ] => sub {
233 foreach my $obj ( @_ ) {
234 if( ref( $obj ) && ref( $obj ) ne 'Text::Tradition'
235 && ref ($obj) ne 'Text::Tradition::User' ) {
236 # Is it an id => Tradition hash?
237 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
238 my( $k ) = keys %$obj;
239 next if ref( $obj->{$k} ) eq 'Text::Tradition';
241 push( @nontrad, $obj );
245 throw( "Cannot directly save non-Tradition object of type "
246 . ref( $nontrad[0] ) );
250 # TODO Garbage collection doesn't work. Suck it up and live with the
252 after delete => sub {
254 my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
255 $self->directory->backend->delete( $gc->garbage->members );
260 return $self->store( @_ );
264 my( $self, $id ) = @_;
265 my $obj = $self->lookup( $id );
267 # Try looking up by name.
268 foreach my $item ( $self->traditionlist ) {
269 if( $item->{'name'} eq $id ) {
270 $obj = $self->lookup( $item->{'id'} );
275 if( $obj && ref( $obj ) ne 'Text::Tradition' ) {
276 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
281 sub user_traditionlist {
282 my ($self, $user) = @_;
285 if(ref $user && $user->is_admin) {
287 return $self->traditionlist();
289 ## We have a user object already, so just fetch its traditions and use tose
290 foreach my $t (@{ $user->traditions }) {
291 push( @tlist, { 'id' => $self->object_to_id( $t ),
292 'name' => $t->name } );
295 } elsif($user ne 'public') {
296 die "Passed neither a user object nor 'public' to user_traditionlist";
299 ## Search for all traditions which allow public viewing
301 ## This needs to be more sophisticated, probably needs Search::GIN
302 # my $list = $self->search({ public => 1 });
304 ## For now, just fetch all
305 ## (could use all_objects or grep down there?)
306 return $self->traditionlist();
313 return $self->user_traditionlist($user) if($user);
316 # If we are using DBI, we can do it the easy way; if not, the hard way.
317 # Easy way still involves making a separate DBI connection. Ew.
318 if( $self->dsn =~ /^dbi:(\w+):/ ) {
320 my @connection = @{$self->directory->backend->connect_info};
321 # Get rid of KiokuDB-specific arg
322 pop @connection if scalar @connection > 4;
323 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
324 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
325 my $dbh = DBI->connect( @connection );
326 my $q = $dbh->prepare( 'SELECT id, name from entries WHERE class = "Text::Tradition"' );
328 while( my @row = $q->fetchrow_array ) {
329 my( $id, $name ) = @row;
330 # Horrible horrible hack
331 $name = decode_utf8( $name ) if $dbtype eq 'mysql';
332 push( @tlist, { 'id' => $row[0], 'name' => $row[1] } );
335 $self->scan( sub { my $o = shift;
336 push( @tlist, { 'id' => $self->object_to_id( $o ),
337 'name' => $o->name } ) } );
343 Text::Tradition::Error->throw(
344 'ident' => 'database error',
350 # has 'directory' => (
352 # isa => 'KiokuX::Model',
356 ## TODO: Some of these methods should probably optionally take $user objects
357 ## instead of hashrefs.
359 ## It also occurs to me that all these methods don't need to be named
360 ## XX_user, but leaving that way for now incase we merge this code
361 ## into ::Directory for one-store.
363 ## To die or not to die, on error, this is the question.
367 Takes a hashref of C<username>, C<password>.
369 Create a new user object, store in the KiokuDB backend, and return it.
374 my ($self, $userinfo) = @_;
376 my $username = $userinfo->{username};
377 my $password = $userinfo->{password};
378 my $role = $userinfo->{role} || 'user';
380 throw( "No username given" ) unless $username;
381 throw( "Invalid password - must be at least " . $self->MIN_PASS_LEN
382 . " characters long" )
383 unless ( $self->validate_password($password) || $username =~ /^https?:/ );
385 my $user = Text::Tradition::User->new(
387 password => ($password ? crypt_password($password) : ''),
388 email => ($userinfo->{email} ? $userinfo->{email} : $username),
392 $self->store($user->kiokudb_object_id, $user);
398 my ($self, $userinfo) = @_;
400 ## No username means probably an OpenID based user
401 if(!exists $userinfo->{username}) {
402 extract_openid_data($userinfo);
405 return $self->add_user($userinfo);
408 ## Not quite sure where this method should be.. Auth /
409 ## Credential::OpenID just pass us back the chunk of extension data
410 sub extract_openid_data {
413 ## Spec says SHOULD use url as identifier
414 $userinfo->{username} = $userinfo->{url};
416 ## Use email addy as display if available
417 if(exists $userinfo->{extensions} &&
418 exists $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'} &&
419 defined $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'}) {
420 ## Somewhat ugly attribute extension reponse, contains
421 ## google-email string which we can use as the id
423 $userinfo->{email} = $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'};
431 Takes a hashref of C<username>, and possibly openIDish results from
432 L<Net::OpenID::Consumer>.
434 Fetches the user object for the given username and returns it.
439 my ($self, $userinfo) = @_;
441 ## No username means probably an OpenID based user
442 if(!exists $userinfo->{username}) {
443 extract_openid_data($userinfo);
446 my $username = $userinfo->{username};
448 ## No logins if user is deactivated (use lookup to fetch to re-activate)
449 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
450 return if(!$user || !$user->active);
452 # print STDERR "Found user, $username, email is :", $user->email, ":\n";
459 Takes a hashref of C<username> and C<password> (same as add_user).
461 Retrieves the user, and updates it with the new information. Username
462 changing is not currently supported.
464 Returns the updated user object, or undef if not found.
469 my ($self, $userinfo) = @_;
470 my $username = $userinfo->{username};
471 my $password = $userinfo->{password};
472 my $role = $userinfo->{role};
474 throw( "Missing username or bad password" )
475 unless $username && $self->validate_password($password);
477 my $user = $self->find_user({ username => $username });
478 throw( "Could not find user $username" ) unless $user;
481 $user->password(crypt_password($password));
487 $self->update($user);
492 =head2 deactivate_user
494 Takes a hashref of C<username>.
496 Sets the users C<active> flag to false (0), and sets all traditions
497 assigned to them to non-public, updates the storage and returns the
500 Returns undef if user not found.
504 sub deactivate_user {
505 my ($self, $userinfo) = @_;
506 my $username = $userinfo->{username};
508 throw( "Need to specify a username for deactivation" ) unless $username;
510 my $user = $self->find_user({ username => $username });
511 throw( "User $username not found" ) unless $user;
514 foreach my $tradition (@{ $user->traditions }) {
515 ## Not implemented yet
516 # $tradition->public(0);
519 ## Should we be using Text::Tradition::Directory also?
520 $self->update(@{ $user->traditions });
522 $self->update($user);
527 =head2 reactivate_user
529 Takes a hashref of C<username>.
531 Returns the user object if already activated. Activates (sets the
532 active flag to true (1)), updates the storage and returns the user.
534 Returns undef if the user is not found.
538 sub reactivate_user {
539 my ($self, $userinfo) = @_;
540 my $username = $userinfo->{username};
542 throw( "Need to specify a username for reactivation" ) unless $username;
544 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
545 throw( "User $username not found" ) unless $user;
547 return $user if $user->active;
550 $self->update($user);
557 CAUTION: Deletes actual data!
559 Takes a hashref of C<username>.
561 Returns undef if the user doesn't exist.
563 Removes the user from the store and returns 1.
568 my ($self, $userinfo) = @_;
569 my $username = $userinfo->{username};
571 throw( "Need to specify a username for deletion" ) unless $username;
573 my $user = $self->find_user({ username => $username });
574 throw( "User $username not found" ) unless $user;
576 ## Should we be using Text::Tradition::Directory for this bit?
577 $self->delete( @{ $user->traditions });
580 $self->delete($user);
585 =head2 validate_password
587 Takes a password string. Returns true if it is longer than
588 L</MIN_PASS_LEN>, false otherwise.
590 Used internally by L</add_user>.
594 sub validate_password {
595 my ($self, $password) = @_;
597 return if !$password;
598 return if length($password) < $self->MIN_PASS_LEN;
607 This package is free software and is provided "as is" without express
608 or implied warranty. You can redistribute it and/or modify it under
609 the same terms as Perl itself.
613 Tara L Andrews E<lt>aurum@cpan.orgE<gt>