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::Store;
16 use Text::Tradition::User;
17 use Text::Tradition::TypeMap::Entry;
19 extends 'KiokuX::Model';
23 Text::Tradition::Directory - a KiokuDB interface for storing and retrieving traditions
27 use Text::Tradition::Directory;
28 my $d = Text::Tradition::Directory->new(
29 'dsn' => 'dbi:SQLite:mytraditions.db',
30 'extra_args' => { 'create' => 1 },
33 my $tradition = Text::Tradition->new( @args );
34 my $stemma = $tradition->add_stemma( dotfile => $dotfile );
35 $d->save_tradition( $tradition );
37 foreach my $id ( $d->traditions ) {
38 print $d->tradition( $id )->name;
42 my $userstore = Text::Tradition::UserStore->new(dsn => 'dbi:SQLite:foo.db');
43 my $newuser = $userstore->add_user({ username => 'fred',
44 password => 'somepassword' });
46 my $fetchuser = $userstore->find_user({ username => 'fred' });
47 if($fetchuser->check_password('somepassword')) {
48 ## login user or .. whatever
51 my $user = $userstore->deactivate_user({ username => 'fred' });
53 ## shouldnt be able to login etc
58 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.
64 Constant for the minimum password length when validating passwords,
69 has MIN_PASS_LEN => ( is => 'ro', isa => 'Num', default => sub { 8 } );
75 Returns a Directory object.
79 Returns a hashref mapping of ID => name for all traditions in the directory.
81 =head2 tradition( $id )
83 Returns the Text::Tradition object of the given ID.
85 =head2 save( $tradition )
87 Writes the given tradition to the database, returning its ID.
89 =head2 delete( $tradition )
91 Deletes the given tradition object from the database.
92 WARNING!! Garbage collection does not yet work. Use this sparingly.
99 use_ok 'Text::Tradition::Directory';
101 my $fh = File::Temp->new();
102 my $file = $fh->filename;
104 my $dsn = "dbi:SQLite:dbname=$file";
106 my $t = Text::Tradition->new(
108 'input' => 'Tabular',
109 'file' => 't/data/simple.txt',
113 my $d = Text::Tradition::Directory->new( 'dsn' => $dsn,
114 'extra_args' => { 'create' => 1 } );
115 is( ref $d, 'Text::Tradition::Directory', "Got directory object" );
117 my $scope = $d->new_scope;
118 $uuid = $d->save( $t );
119 ok( $uuid, "Saved test tradition" );
121 my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
122 ok( $d->save( $t ), "Updated tradition with stemma" );
123 is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
124 is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
127 } catch( Text::Tradition::Error $e ) {
128 is( $e->ident, 'database error', "Got exception trying to save stemma directly" );
129 like( $e->message, qr/Cannot directly save non-Tradition object/,
130 "Exception has correct message" );
133 my $nt = Text::Tradition->new(
135 'input' => 'CollateX',
136 'file' => 't/data/Collatex-16.xml',
138 is( ref( $nt ), 'Text::Tradition', "Made new tradition" );
141 my $f = Text::Tradition::Directory->new( 'dsn' => $dsn );
142 my $scope = $f->new_scope;
143 is( scalar $f->traditionlist, 1, "Directory index has our tradition" );
144 my $nuuid = $f->save( $nt );
145 ok( $nuuid, "Stored second tradition" );
146 my @tlist = $f->traditionlist;
147 is( scalar @tlist, 2, "Directory index has both traditions" );
148 my $tf = $f->tradition( $uuid );
149 my( $tlobj ) = grep { $_->{'id'} eq $uuid } @tlist;
150 is( $tlobj->{'name'}, $tf->name, "Directory index has correct tradition name" );
151 is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
152 my $sid = $f->object_to_id( $tf->stemma(0) );
154 $f->tradition( $sid );
155 } catch( Text::Tradition::Error $e ) {
156 is( $e->ident, 'database error', "Got exception trying to fetch stemma directly" );
157 like( $e->message, qr/not a Text::Tradition/, "Exception has correct message" );
161 } catch( Text::Tradition::Error $e ) {
162 is( $e->ident, 'database error', "Got exception trying to delete stemma directly" );
163 like( $e->message, qr/Cannot directly delete non-Tradition object/,
164 "Exception has correct message" );
168 ok( !$f->exists( $uuid ), "Object is deleted from DB" );
169 ok( !$f->exists( $sid ), "Object stemma also deleted from DB" );
170 is( scalar $f->traditionlist, 1, "Object is deleted from index" );
174 my $g = Text::Tradition::Directory->new( 'dsn' => $dsn );
175 my $scope = $g->new_scope;
176 is( scalar $g->traditionlist, 1, "Now one object in new directory index" );
177 my $ntobj = $g->tradition( 'CX' );
178 my @w1 = sort { $a->sigil cmp $b->sigil } $ntobj->witnesses;
179 my @w2 = sort{ $a->sigil cmp $b->sigil } $nt->witnesses;
180 is_deeply( \@w1, \@w2, "Looked up remaining tradition by name" );
186 use Text::Tradition::TypeMap::Entry;
190 isa => 'KiokuDB::TypeMap',
192 KiokuDB::TypeMap->new(
194 # now that we fall back to YAML deflation, all attributes of
195 # Text::Tradition will be serialized to YAML as individual objects
196 # Except if we declare a specific entry type here
198 KiokuDB::TypeMap::Entry::MOP->new(),
199 # We need users to be naive entries so that they hold
200 # references to the original tradition objects, not clones
201 "Text::Tradition::User" =>
202 KiokuDB::TypeMap::Entry::MOP->new(),
203 "Text::Tradition::Collation" =>
204 KiokuDB::TypeMap::Entry::MOP->new(),
205 "Text::Tradition::Witness" =>
206 KiokuDB::TypeMap::Entry::MOP->new(),
207 "Graph" => Text::Tradition::TypeMap::Entry->new(),
208 "Set::Scalar" => Text::Tradition::TypeMap::Entry->new(),
214 # Push some columns into the extra_args
215 around BUILDARGS => sub {
225 if( $args->{'dsn'} =~ /^dbi/ ) { # We're using Backend::DBI
226 @column_args = ( 'columns',
227 [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 } ] );
229 my $ea = $args->{'extra_args'};
230 if( ref( $ea ) eq 'ARRAY' ) {
231 push( @$ea, @column_args );
232 } elsif( ref( $ea ) eq 'HASH' ) {
233 $ea = { %$ea, @column_args };
235 $ea = { @column_args };
237 $args->{'extra_args'} = $ea;
239 return $class->$orig( $args );
242 override _build_directory => sub {
244 Text::Tradition::Store->connect(@{ $self->_connect_args },
245 resolver_constructor => sub {
247 $class->new({ typemap => $self->directory->merged_typemap,
248 fallback_entry => Text::Tradition::TypeMap::Entry->new() });
252 ## These checks don't cover store($id, $obj)
253 # before [ qw/ store update insert delete / ] => sub {
254 before [ qw/ delete / ] => sub {
257 foreach my $obj ( @_ ) {
258 if( ref( $obj ) && ref( $obj ) ne 'Text::Tradition'
259 && ref ($obj) ne 'Text::Tradition::User' ) {
260 # Is it an id => Tradition hash?
261 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
262 my( $k ) = keys %$obj;
263 next if ref( $obj->{$k} ) eq 'Text::Tradition';
265 push( @nontrad, $obj );
269 throw( "Cannot directly save non-Tradition object of type "
270 . ref( $nontrad[0] ) );
274 # TODO Garbage collection doesn't work. Suck it up and live with the
276 after delete => sub {
278 my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
279 $self->directory->backend->delete( $gc->garbage->members );
284 return $self->store( @_ );
288 my( $self, $id ) = @_;
289 my $obj = $self->lookup( $id );
291 # Try looking up by name.
292 foreach my $item ( $self->traditionlist ) {
293 if( $item->{'name'} eq $id ) {
294 $obj = $self->lookup( $item->{'id'} );
299 if( $obj && ref( $obj ) ne 'Text::Tradition' ) {
300 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
305 sub user_traditionlist {
306 my ($self, $user) = @_;
309 if(ref $user && $user->is_admin) {
311 return $self->traditionlist();
313 ## We have a user object already, so just fetch its traditions and use tose
314 foreach my $t (@{ $user->traditions }) {
315 push( @tlist, { 'id' => $self->object_to_id( $t ),
316 'name' => $t->name } );
319 } elsif($user ne 'public') {
320 die "Passed neither a user object nor 'public' to user_traditionlist";
323 ## Search for all traditions which allow public viewing
325 ## This needs to be more sophisticated, probably needs Search::GIN
326 # my $list = $self->search({ public => 1 });
328 ## For now, just fetch all
329 ## (could use all_objects or grep down there?)
330 return $self->traditionlist();
337 return $self->user_traditionlist($user) if($user);
340 # If we are using DBI, we can do it the easy way; if not, the hard way.
341 # Easy way still involves making a separate DBI connection. Ew.
342 if( $self->dsn =~ /^dbi:(\w+):/ ) {
344 my @connection = @{$self->directory->backend->connect_info};
345 # Get rid of KiokuDB-specific arg
346 pop @connection if scalar @connection > 4;
347 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
348 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
349 my $dbh = DBI->connect( @connection );
350 my $q = $dbh->prepare( 'SELECT id, name from entries WHERE class = "Text::Tradition"' );
352 while( my @row = $q->fetchrow_array ) {
353 my( $id, $name ) = @row;
354 # Horrible horrible hack
355 $name = decode_utf8( $name ) if $dbtype eq 'mysql';
356 push( @tlist, { 'id' => $row[0], 'name' => $row[1] } );
359 $self->scan( sub { my $o = shift;
360 push( @tlist, { 'id' => $self->object_to_id( $o ),
361 'name' => $o->name } ) } );
367 Text::Tradition::Error->throw(
368 'ident' => 'database error',
374 # has 'directory' => (
376 # isa => 'KiokuX::Model',
380 ## TODO: Some of these methods should probably optionally take $user objects
381 ## instead of hashrefs.
383 ## It also occurs to me that all these methods don't need to be named
384 ## XX_user, but leaving that way for now incase we merge this code
385 ## into ::Directory for one-store.
387 ## To die or not to die, on error, this is the question.
391 Takes a hashref of C<username>, C<password>.
393 Create a new user object, store in the KiokuDB backend, and return it.
398 my ($self, $userinfo) = @_;
400 my $username = $userinfo->{username};
401 my $password = $userinfo->{password};
402 my $role = $userinfo->{role} || 'user';
404 throw( "No username given" ) unless $username;
405 throw( "Invalid password - must be at least " . $self->MIN_PASS_LEN
406 . " characters long" )
407 unless ( $self->validate_password($password) || $username =~ /^https?:/ );
409 my $user = Text::Tradition::User->new(
411 password => ($password ? crypt_password($password) : ''),
412 email => ($userinfo->{email} ? $userinfo->{email} : $username),
416 $self->store($user->kiokudb_object_id, $user);
422 my ($self, $userinfo) = @_;
424 ## No username means probably an OpenID based user
425 if(!exists $userinfo->{username}) {
426 extract_openid_data($userinfo);
429 return $self->add_user($userinfo);
432 ## Not quite sure where this method should be.. Auth /
433 ## Credential::OpenID just pass us back the chunk of extension data
434 sub extract_openid_data {
437 ## Spec says SHOULD use url as identifier
438 $userinfo->{username} = $userinfo->{url};
440 ## Use email addy as display if available
441 if(exists $userinfo->{extensions} &&
442 exists $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'} &&
443 defined $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'}) {
444 ## Somewhat ugly attribute extension reponse, contains
445 ## google-email string which we can use as the id
447 $userinfo->{email} = $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'};
455 Takes a hashref of C<username>, and possibly openIDish results from
456 L<Net::OpenID::Consumer>.
458 Fetches the user object for the given username and returns it.
463 my ($self, $userinfo) = @_;
465 ## No username means probably an OpenID based user
466 if(!exists $userinfo->{username}) {
467 extract_openid_data($userinfo);
470 my $username = $userinfo->{username};
472 ## No logins if user is deactivated (use lookup to fetch to re-activate)
473 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
474 return if(!$user || !$user->active);
476 # print STDERR "Found user, $username, email is :", $user->email, ":\n";
483 Takes a hashref of C<username> and C<password> (same as add_user).
485 Retrieves the user, and updates it with the new information. Username
486 changing is not currently supported.
488 Returns the updated user object, or undef if not found.
493 my ($self, $userinfo) = @_;
494 my $username = $userinfo->{username};
495 my $password = $userinfo->{password};
496 my $role = $userinfo->{role};
498 throw( "Missing username or bad password" )
499 unless $username && $self->validate_password($password);
501 my $user = $self->find_user({ username => $username });
502 throw( "Could not find user $username" ) unless $user;
505 $user->password(crypt_password($password));
511 $self->update($user);
516 =head2 deactivate_user
518 Takes a hashref of C<username>.
520 Sets the users C<active> flag to false (0), and sets all traditions
521 assigned to them to non-public, updates the storage and returns the
524 Returns undef if user not found.
528 sub deactivate_user {
529 my ($self, $userinfo) = @_;
530 my $username = $userinfo->{username};
532 throw( "Need to specify a username for deactivation" ) unless $username;
534 my $user = $self->find_user({ username => $username });
535 throw( "User $username not found" ) unless $user;
538 foreach my $tradition (@{ $user->traditions }) {
539 ## Not implemented yet
540 # $tradition->public(0);
543 ## Should we be using Text::Tradition::Directory also?
544 $self->update(@{ $user->traditions });
546 $self->update($user);
551 =head2 reactivate_user
553 Takes a hashref of C<username>.
555 Returns the user object if already activated. Activates (sets the
556 active flag to true (1)), updates the storage and returns the user.
558 Returns undef if the user is not found.
562 sub reactivate_user {
563 my ($self, $userinfo) = @_;
564 my $username = $userinfo->{username};
566 throw( "Need to specify a username for reactivation" ) unless $username;
568 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
569 throw( "User $username not found" ) unless $user;
571 return $user if $user->active;
574 $self->update($user);
581 CAUTION: Deletes actual data!
583 Takes a hashref of C<username>.
585 Returns undef if the user doesn't exist.
587 Removes the user from the store and returns 1.
592 my ($self, $userinfo) = @_;
593 my $username = $userinfo->{username};
595 throw( "Need to specify a username for deletion" ) unless $username;
597 my $user = $self->find_user({ username => $username });
598 throw( "User $username not found" ) unless $user;
600 ## Should we be using Text::Tradition::Directory for this bit?
601 $self->delete( @{ $user->traditions });
604 $self->delete($user);
609 =head2 validate_password
611 Takes a password string. Returns true if it is longer than
612 L</MIN_PASS_LEN>, false otherwise.
614 Used internally by L</add_user>.
618 sub validate_password {
619 my ($self, $password) = @_;
621 return if !$password;
622 return if length($password) < $self->MIN_PASS_LEN;
631 This package is free software and is provided "as is" without express
632 or implied warranty. You can redistribute it and/or modify it under
633 the same terms as Perl itself.
637 Tara L Andrews E<lt>aurum@cpan.orgE<gt>