1 package Rose
::DBx
::Object
::I18N
;
3 use base qw
/ Rose::DB::Object /;
6 use Hash
::Merge
'merge';
8 use Rose
::DB
::Object
::Constants
qw(:all);
9 use Rose
::DB
::Constants
qw(IN_TRANSACTION);
11 use Rose
::DB
::Object
::Helpers qw
/ has_loaded_related /;
13 use Rose
::DBx
::Object
::I18N
::Helpers
':all';
21 Rose::DBx::Object::I18N - set of modules to deal with multilingual database
25 # create user with multilingual data
33 # load german translation
34 $u->load( i18n => 'de' );
35 $u->signature; # hello
37 # retrieve available translations
38 $u->i18n_available_translations; # undef
41 $u->i18n->signature( 'hallo' );
43 $u->i18n_available_translations; # [ 'en' ]
46 $u->i18n( 'en' )->signature( 'hi' );
49 # check if original translation is loaded
50 $u->is_original_loaded; # 1
54 # delete loaded translation
56 $u->i18n_available_translations; # undef
57 $u->i18n( 'de' )->signature; # hi
61 There are different ways to deal with multilingual problem. We will look at a
64 =head2 Separate Data For Each Language
67 +----+-----------+----------+-------+
68 | id | author_id | language | title |
69 +----+-----------+----------+-------+
71 +----+-----------+----------+-------+
73 +----+-----------+----------+-------+
75 +----+-----------+----------+-------+
77 This is a easiest one to imagine. You have all data separated. If user wants
78 something in English just give him what he wants. There is no relation between
79 data, so if nothing is found in English there is no way how to know if there is
80 something in German, etc.
82 Also, the data that is shared between translations, like link, author id,
83 something else that can't be translated should be synchronized on every change
84 in other translations.
86 The good is the speed. No joins, no lookups in other tables, etc.
88 =head2 Static Data, Language Data, Translation Data
91 +----+-----------+-------------------+
92 | id | author_id | original_language |
93 +----+-----------+-------------------+
95 +----+-----------+-------------------+
97 +----+-----------+-------------------+
100 +------------+---------+----------+
101 | article_id | i18n_id | language |
102 +------------+---------+----------+
104 +------------+---------+----------+
106 +------------+---------+----------+
108 +------------+---------+----------+
121 Here we have three tables. One is for static data that is not going to be
122 translated, one is for languages that will hold what language is mapped to what
123 translation in translations table and the translation table, that holds
124 translatable information.
126 The problem is that there too many thins to do even for the one request. We
127 should make 3 joins and one IF statement in a join.
129 =head2 One Static, Many Translations
132 +----+-----------+-------------------+
133 | id | author_id | original_language |
134 +----+-----------+-------------------+
136 +----+-----------+-------------------+
138 +----+-----------+-------------------+
141 +------------+----------+-------+
142 | article_id | language | title |
143 +------------+----------+-------+
145 +------------+----------+-------+
147 +------------+----------+-------+
149 +------------+----------+-------+
151 Current approach for Rose::DBx::Object::I18N is to have two tables, one is for
152 the static data, and another for all translations.
154 =head2 Rose::DBx::Object::I18N
156 Plugging in Rose::DBx::Object::I18N is simply, instead of subclassing from
157 Rose::DB::Object use this namespace. But you must have two tables: one for the
158 Static data and another for Translation data.
160 package DB::Object::I18N;
164 use base qw/ Rose::DBx::Object::I18N / ;
171 DB->new_or_cached( @_ );
175 my @languages = qw/ en de ru /;
177 wantarray ? @languages : \@languages;
180 Class for Static data can look like this.
186 use base qw(DB::Object::I18N::Static);
188 use Rose::DBx::Object::I18N::Metadata;
189 sub meta_class { 'Rose::DBx::Object::I18N::Metadata' };
191 __PACKAGE__->meta->setup(
196 orig_lang => { type => 'i18n_language' }
199 primary_key_columns => [ qw/ id / ],
201 unique_key => [ qw/ name / ],
205 type => 'one to many',
207 column_map => { id => 'user_id' }
211 i18n_translation_rel_name => 'user_i18n'
214 And class for Translation
220 use base qw/ DB::Object::I18N::Translation /;
222 use Rose::DBx::Object::I18N::Metadata;
223 sub meta_class { 'Rose::DBx::Object::I18N::Metadata' };
225 __PACKAGE__->meta->setup(
226 table => 'user_i18n',
234 lang => { type => 'i18n_language' },
235 istran => { type => 'i18n_is_translation' }
238 primary_key_columns => [ 'i18nid' ],
243 key_columns => { user_id => 'id' },
244 rel_type => 'many to one',
248 i18n_static_rel_name => 'user'
251 There is also I18N::Manager that can help you with selection i18n data.
253 package User::Manager;
257 use base 'Rose::DBx::Object::I18N::Manager';
259 sub object_class { 'User' }
261 __PACKAGE__->make_manager_methods( 'users' );
267 Rose::DB::Object init method is overloaded, so you can use one of these
273 user_i18n => { signature => 'hello' }
293 $u->user_i18n( { signature => 'hello' } );
298 my ( $self ) = shift;
302 if ( my $rel_name = $self->meta->i18n_translation_rel_name() ) {
305 while ( my ( $key, $val ) = each %params ) {
306 $i18n->{ $key } = delete $params{ $key } unless $self->can( $key );
310 $params{ $rel_name } ||= {};
311 $params{ $rel_name } = { %$i18n, %{ $params{ $rel_name } } };
315 $self->SUPER::init
( %params );
322 Data that is static is added to static table, then for each language
323 translatable data is added to translations table with a flag (istran) that there
328 If updating original language data update it and then synchronize with all
329 translations that are not translations (the data is the same, istran flag is 0)
331 If updating translation set istran to 1 and update all columns as usual.
339 if (my $rel_name = $self->meta->i18n_translation_rel_name()) {
341 #if ( !$self->has_loaded_related( $rel_name ) && $self->{ _i18n } ) {
342 if ( $self->{ _i18n
} ) {
346 $self->i18n->save() if $i18n_save && !$params{noi18n
};
347 #$self->i18n->save();
350 $self->SUPER::save
(@_);
356 if ( my $rel_name = $self->meta->i18n_translation_rel_name() ) {
357 die 'no languages provided' unless $self->i18n_languages;
359 if ( $self->$rel_name ) {
360 my $i18n = shift @
{ $self->$rel_name };
362 my $i18n_lang_column = $i18n->_i18n_lang_column;
363 my $i18n_istran_column = $i18n->_i18n_istran_column;
365 my $add_method = "add_$rel_name";
366 foreach my $lang ( @
{ $self->i18n_languages } ) {
367 $i18n->$i18n_lang_column( $lang );
368 $i18n->$i18n_istran_column( 0 );
370 { map { $_ => $i18n->$_ } @
{ $i18n->meta->column_names } }
374 my ( $rel ) = grep { $_->name eq $rel_name } $self->meta->relationships;
376 my ( $i18n_lang ) = grep { $_->type eq 'i18n_language' }
377 $rel->foreign_class->meta->columns;
378 my $i18n_lang_column = $i18n_lang->name;
380 my ( $i18n_istran ) = grep { $_->type eq 'i18n_is_translation' }
381 $rel->foreign_class->meta->columns;
382 my $i18n_istran_column = $i18n_istran->name;
386 { $i18n_lang_column => $_, $i18n_istran_column => 0 }
387 } $self->i18n_languages
392 $self->SUPER::insert
( @_ );
398 if ( $self->meta->i18n_static_rel_name() ) {
399 my $parent = $self->_i18n_parent;
401 my $orig_lang_column = $parent->_i18n_lang_column;
402 my $i18n_lang_column = $self->_i18n_lang_column;
403 my $i18n_istran_column = $self->_i18n_istran_column;
405 if ( $parent->$orig_lang_column eq $self->$i18n_lang_column ) {
406 foreach my $i18n ( $parent->not_translated_i18n() ) {
407 $i18n->_i18n_sync_with( $self );
410 $self->$i18n_istran_column( 1 );
414 $self->SUPER::update
( @_ );
419 When you want to load default language ($ENV{RDBO_I18N_LANG} or original) just load as you
422 $u = User->new( id => 1 );
425 When you want to load en translation:
427 $u = User->new( id => 1 );
428 $u->load( i18n => 'en' );
432 Returns preloaded i18n object or, if the last was not found, preloads it taking the
433 default language or language that is provided as a parameter.
435 $u = User->new( id => 1 );
436 # let's assume that the original language is English ('en').
439 $u->i18n->title; # title is in English
440 $u->i18n('de')->title; # title is in German
441 $u->i18n('en')->title; # title is back in English
446 my ( $self, $i18n ) = @_;
448 my $rel_name = $self->meta->i18n_translation_rel_name();
450 return unless $rel_name;
452 return $self->{ _i18n
} if !$i18n && $self->{ _i18n
};
454 if ( !$i18n && $self->has_loaded_related( $rel_name ) ) {
455 $self->{ _i18n
} = $self->$rel_name->[ 0 ];
457 $self->_load_i18n( i18n
=> $i18n );
460 return $self->{ _i18n
};
463 =head2 i18n_available_translations
465 Returns array reference of another available translations.
469 sub i18n_available_translations
{
472 my $rel_name = $self->meta->i18n_translation_rel_name();
473 return unless $rel_name;
475 my $method = "find_$rel_name";
477 unless ( $self->i18n_is_loaded() ) {
478 $self->error( "first do i18n()" );
479 $self->meta->handle_error( $self );
483 my $orig_lang_column = $self->_i18n_lang_column;
484 my $i18n_lang_column = $self->i18n->_i18n_lang_column;
485 my $i18n_istran_column = $self->i18n->_i18n_istran_column;
487 my $orig_lang = $self->$orig_lang_column;
488 my $lang = $self->i18n->$i18n_lang_column;
491 if ( $self->i18n_is_original_loaded() ) {
492 $subquery = [ $i18n_istran_column => 1, ];
496 $i18n_istran_column => 1,
497 $i18n_lang_column => $orig_lang
502 my $i18n = $self->$method(
504 $i18n_lang_column => { ne => $lang },
507 select => $i18n_lang_column
510 return [ map { $_->$i18n_lang_column } @
$i18n ];
513 =head2 i18n_is_original_loaded
515 Returns if loaded translation is original.
519 sub i18n_is_original_loaded
{
522 my $orig_lang_column = $self->_i18n_lang_column;
523 my $i18n_lang_column = $self->i18n->_i18n_lang_column;
524 my $i18n_istran_column = $self->i18n->_i18n_istran_column;
526 return $self->$orig_lang_column eq $self->i18n->$i18n_lang_column
527 || $self->i18n->$i18n_istran_column == 0 ?
1 : 0;
530 =head2 not_translated_i18n
532 Return array reference of languages that have no translation.
536 Delete currently loaded translation and loads original.
543 return if $self->i18n_is_original_loaded();
545 my $orig_lang_column = $self->_i18n_lang_column;
546 my $i18n_lang_column = $self->i18n->_i18n_lang_column;
547 my $i18n_istran_column = $self->i18n->_i18n_istran_column;
549 return unless $self->i18n->$i18n_istran_column;
551 my $translation_rel_name = $self->meta->i18n_translation_rel_name();
552 my $method = "find_$translation_rel_name";
554 my $original_i18n = $self->$method(
556 $i18n_istran_column => 0,
557 $i18n_lang_column => $self->$orig_lang_column
561 $self->i18n->$i18n_istran_column( 0 );
562 $self->i18n->_i18n_sync_with( $original_i18n );
565 =head2 Rose::DBx::Object::I18N::Manager
567 On selection there is only one join, no need to do any logic selection, because
568 we have all data ready for selection at the right place. If there was no
569 translation, anyway data will be there, it will be original, because no
570 translation was updated.
572 get_objects method is overloaded, so you don't have to provide query with the
573 language selection and table to join, just use is transparently:
575 User::Manager->get_objects( i18n => 'en' );
583 my $language = $args{ i18n
} || $self->i18n_language();
585 my $rel_name = $self->meta->i18n_translation_rel_name();
587 my $meta = $self->meta;
589 my ( $rel ) = grep { $_->name eq $rel_name } $self->meta->relationships;
591 grep { $_->type eq 'i18n_language' } $rel->foreign_class->meta->columns;
592 my $i18n_lang_column = $i18n_lang->name;
594 my $method = "find_$rel_name";
595 my $i18n = $self->$method( [ $i18n_lang_column => $language ] );
597 my $loaded_ok = $i18n ?
$i18n->[ 0 ] ?
1 : 0 : 0;
599 unless ( $loaded_ok ) {
601 exists $args{ 'speculative' }
602 ?
$args{ 'speculative' }
603 : $meta->default_load_speculative;
605 unless ( $speculative ) {
606 $self->error( "load_i18n() - can't find $language translation" );
607 $meta->handle_error( $self );
613 $self->{ _i18n
} = $i18n->[ 0 ];
618 sub not_translated_i18n
{
621 my $translation_rel_name = $self->meta->i18n_translation_rel_name();
622 my $method = "find_$translation_rel_name";
624 my $orig_lang_column = $self->_i18n_lang_column;
625 my $i18n_lang_column = $self->i18n->_i18n_lang_column;
626 my $i18n_istran_column = $self->i18n->_i18n_istran_column;
628 my @i18n = $self->$method(
630 $i18n_istran_column => 0,
631 $i18n_lang_column => { ne => $self->$orig_lang_column }
635 return wantarray ?
@i18n : \
@i18n;
641 my $rel_name = $self->meta->i18n_static_rel_name();
642 return $self->$rel_name;
645 sub _i18n_sync_with
{
649 my $i18n_lang_column = $self->_i18n_lang_column;
650 my $i18n_istran_column = $self->_i18n_istran_column;
652 my ( $pk ) = $self->meta->primary_key_column_names;
655 grep { $_ !~ m/(?:$pk|$i18n_istran_column|$i18n_lang_column)/ }
656 $self->meta->column_names();
659 foreach my $column ( @columns ) {
660 my $old = $self->$column;
661 $self->$column( $from->$column );
663 $self->SUPER::update
();
670 my $rel_name = $self->meta->i18n_translation_rel_name();
672 return $self->has_loaded_related( $rel_name ) || $self->{ _i18n
} ?
1 : 0;
675 sub _i18n_istran_column
{
679 grep { $_->type eq 'i18n_is_translation' } @
{ $self->meta->columns };
681 return $column->name;
684 sub _i18n_lang_column
{
688 grep { $_->type eq 'i18n_language' } @
{ $self->meta->columns };
690 return $column->name;
693 use constant LAZY_LOADED_KEY
=>
694 Rose
::DB
::Object
::Util
::lazy_column_values_loaded_key
();
698 my($self) = $_[0]; # XXX: Must maintain alias to actual "self" object arg
700 my %args = (self
=> @_); # faster than @_[1 .. $#_];
702 $self->SUPER::load
( %args ) if $self->meta->i18n_static_rel_name();
704 my $db = $self->db or return 0;
705 my $dbh = $self->dbh or return 0;
707 my $meta = $self->meta;
710 exists $args{'prepare_cached'} ?
$args{'prepare_cached'} :
711 $meta->dbi_prepare_cached;
713 local $self->{STATE_SAVING
()} = 1;
715 my(@key_columns, @key_methods, @key_values);
720 if ( my $i18n = (delete $args{ i18n
}) ) {
721 my $rel_name = $self->meta->i18n_translation_rel_name();
722 my $new_args = merge
{
723 query
=> ["$rel_name.lang" => $i18n],
724 with
=> [ $rel_name ]
730 if(my $key = delete $args{'use_key'})
732 my @uk = grep { $_->name eq $key } $meta->unique_keys;
737 @key_columns = $uk[0]->column_names;
738 @key_methods = map { $meta->column_accessor_method_name($_) } @key_columns;
739 @key_values = map { $defined++ if(defined $_); $_ }
740 map { $self->$_() } @key_methods;
744 $self->error("Could not load() based on key '$key' - column(s) have undefined values");
745 $meta->handle_error($self);
749 if(@key_values != $defined)
754 else { Carp
::croak
"No unique key named '$key' is defined in ", ref($self) }
758 @key_columns = $meta->primary_key_column_names;
759 @key_methods = $meta->primary_key_column_accessor_names;
760 @key_values = grep { defined } map { $self->$_() } @key_methods;
762 unless(@key_values == @key_columns)
766 # Prefer unique keys where we have defined values for all
767 # key columns, but fall back to the first unique key found
768 # where we have at least one defined value.
769 foreach my $cols ($meta->unique_keys_column_names)
772 @key_columns = @
$cols;
773 @key_methods = map { $meta->column_accessor_method_name($_) } @key_columns;
774 @key_values = map { $defined++ if(defined $_); $_ }
775 map { $self->$_() } @key_methods;
777 if($defined == @key_columns)
783 $alt_columns ||= $cols if($defined);
786 if(!$found_key && $alt_columns)
788 @key_columns = @
$alt_columns;
789 @key_methods = map { $meta->column_accessor_method_name($_) } @key_columns;
790 @key_values = map { $self->$_() } @key_methods;
797 @key_columns = $meta->primary_key_column_names;
800 Rose
::DB
::Object
::Exception
->new(
801 message
=> "Cannot load " . ref($self) . " without a primary key (" .
802 join(', ', @key_columns) . ') with ' .
803 (@key_columns > 1 ?
'non-null values in all columns' :
804 'a non-null value') .
805 ' or another unique key with at least one non-null value.',
806 code
=> EXCEPTION_CODE_NO_KEY
);
809 $meta->handle_error($self);
815 my $has_lazy_columns = $args{'nonlazy'} ?
0 : $meta->has_lazy_columns;
818 if($has_lazy_columns)
820 $column_names = $meta->nonlazy_column_names;
821 $self->{LAZY_LOADED_KEY
()} = {};
825 $column_names = $meta->column_names;
829 # Handle sub-object load in separate code path
832 if(my $with = $args{'with'})
834 my $mgr_class = $args{'manager_class'} || 'Rose::DB::Object::Manager';
837 @query{map { "t1.$_" } @key_columns} = @key_values;
842 %query = ( @
{ $args{query
} }, %query );
845 #print Dumper $args{query};
846 #print Dumper \%query;
853 $mgr_class->get_objects(object_class
=> ref $self,
856 with_objects
=> $with,
858 nonlazy
=> $args{'nonlazy'},
859 inject_results
=> $args{'inject_results'},
860 (exists $args{'prepare_cached'} ?
861 (prepare_cached
=> $args{'prepare_cached'}) :
863 or Carp
::confess
$mgr_class->error;
867 die "Found ", @
$objects, " objects instead of one";
873 $self->error("load(with => ...) - $@");
874 $meta->handle_error($self);
880 # Sneaky init by object replacement
881 $self = $_[0] = $objects->[0];
883 # Init by copying attributes (broken; need to do fks and relationships too)
884 #my $methods = $meta->column_mutator_method_names;
885 #my $object = $objects->[0];
887 #local $self->{STATE_LOADING()} = 1;
888 #local $object->{STATE_SAVING()} = 1;
890 #foreach my $method (@$methods)
892 # $self->$method($object->$method());
898 $self->error("No such " . ref($self) . ' where ' .
899 join(', ', @key_columns) . ' = ' . join(', ', @key_values));
900 $self->{'not_found'} = 1;
902 $self->{STATE_IN_DB
()} = 0;
905 exists $args{'speculative'} ?
$args{'speculative'} :
906 $meta->default_load_speculative;
910 $meta->handle_error($self);
916 $self->{STATE_IN_DB
()} = 1;
917 $self->{LOADED_FROM_DRIVER
()} = $db->{'driver'};
918 $self->{MODIFIED_COLUMNS
()} = {};
928 $self->{'not_found'} = 0;
932 local $self->{STATE_LOADING
()} = 1;
933 local $dbh->{'RaiseError'} = 1;
939 if($has_lazy_columns)
941 $sql = $meta->load_sql_with_null_key(\
@key_columns, \
@key_values, $db);
945 $sql = $meta->load_all_sql_with_null_key(\
@key_columns, \
@key_values, $db);
950 if($has_lazy_columns)
952 $sql = $meta->load_sql(\
@key_columns, $db);
956 $sql = $meta->load_all_sql(\
@key_columns, $db);
960 # $meta->prepare_select_options (defunct)
961 $sth = $prepare_cached ?
$dbh->prepare_cached($sql, undef, 3) :
964 $Debug && warn "$sql - bind params: ", join(', ', grep { defined } @key_values), "\n";
965 $sth->execute(grep { defined } @key_values);
969 $sth->bind_columns(undef, \
@row{@
$column_names});
971 $loaded_ok = defined $sth->fetch;
973 # The load() query shouldn't find more than one row anyway,
974 # but DBD::SQLite demands this :-/
979 my $methods = $meta->column_mutator_method_names_hash;
981 # Empty existing object?
982 #%$self = (db => $self->db, meta => $meta, STATE_LOADING() => 1);
984 foreach my $name (@
$column_names)
986 my $method = $methods->{$name};
987 $self->$method($row{$name});
990 # Sneaky init by object replacement
991 #my $object = (ref $self)->new(db => $self->db);
993 #foreach my $name (@$column_names)
995 # my $method = $methods->{$name};
996 # $object->$method($row{$name});
999 #$self = $_[0] = $object;
1004 $self->error("No such " . ref($self) . ' where ' .
1005 join(', ', @key_columns) . ' = ' . join(', ', @key_values));
1006 $self->{'not_found'} = 1;
1007 $self->{STATE_IN_DB
()} = 0;
1013 $self->error("load() - $@");
1014 $meta->handle_error($self);
1021 exists $args{'speculative'} ?
$args{'speculative'} :
1022 $meta->default_load_speculative;
1024 unless($speculative)
1026 $meta->handle_error($self);
1032 $self->{STATE_IN_DB
()} = 1;
1033 $self->{LOADED_FROM_DRIVER
()} = $db->{'driver'};
1034 $self->{MODIFIED_COLUMNS
()} = {};
1038 =head1 COPYRIGHT & LICENSE
1040 Copyright 2008 Viacheslav Tikhanovskii, all rights reserved.
1042 This program is free software; you can redistribute it and/or modify it
1043 under the same terms as Perl itself.