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;
377 grep { $_->type eq 'i18n_language' } $rel->foreign_class->meta->columns;
378 my $i18n_lang_column = $i18n_lang->name;
380 my ( $i18n_istran ) =
381 grep { $_->type eq 'i18n_is_translation' } $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{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 Return 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 Deletes record with translations.
540 Delete currently loaded translation and loads original.
547 return if $self->i18n_is_original_loaded();
549 my $orig_lang_column = $self->_i18n_lang_column;
550 my $i18n_lang_column = $self->i18n->_i18n_lang_column;
551 my $i18n_istran_column = $self->i18n->_i18n_istran_column;
553 return unless $self->i18n->$i18n_istran_column;
555 my $translation_rel_name = $self->meta->i18n_translation_rel_name();
556 my $method = "find_$translation_rel_name";
558 my $original_i18n = $self->$method(
560 $i18n_istran_column => 0,
561 $i18n_lang_column => $self->$orig_lang_column
565 $self->i18n->$i18n_istran_column( 0 );
566 $self->i18n->_i18n_sync_with( $original_i18n );
569 =head2 Rose::DBx::Object::I18N::Manager
571 On selection there is only one join, no need to do any logic selection, because
572 we have all data ready for selection at the right place. If there was no
573 translation, anyway data will be there, it will be original, because no
574 translation was updated.
576 get_objects method is overloaded, so you don't have to provide query with the
577 language selection and table to join, just use is transparently:
579 User::Manager->get_objects( i18n => 'en' );
587 my $language = $args{ i18n
} || $self->i18n_language();
589 my $rel_name = $self->meta->i18n_translation_rel_name();
591 my $meta = $self->meta;
593 my ( $rel ) = grep { $_->name eq $rel_name } $self->meta->relationships;
595 grep { $_->type eq 'i18n_language' } $rel->foreign_class->meta->columns;
596 my $i18n_lang_column = $i18n_lang->name;
598 my $method = "find_$rel_name";
599 my $i18n = $self->$method( [ $i18n_lang_column => $language ] );
601 my $loaded_ok = $i18n ?
$i18n->[ 0 ] ?
1 : 0 : 0;
603 unless ( $loaded_ok ) {
605 exists $args{ 'speculative' }
606 ?
$args{ 'speculative' }
607 : $meta->default_load_speculative;
609 unless ( $speculative ) {
610 $self->error( "load_i18n() - can't find $language translation" );
611 $meta->handle_error( $self );
617 $self->{ _i18n
} = $i18n->[ 0 ];
622 sub not_translated_i18n
{
625 my $translation_rel_name = $self->meta->i18n_translation_rel_name();
626 my $method = "find_$translation_rel_name";
628 my $orig_lang_column = $self->_i18n_lang_column;
629 my $i18n_lang_column = $self->i18n->_i18n_lang_column;
630 my $i18n_istran_column = $self->i18n->_i18n_istran_column;
632 my @i18n = $self->$method(
634 $i18n_istran_column => 0,
635 $i18n_lang_column => { ne => $self->$orig_lang_column }
639 return wantarray ?
@i18n : \
@i18n;
645 my $rel_name = $self->meta->i18n_static_rel_name();
646 return $self->$rel_name;
649 sub _i18n_sync_with
{
653 my $i18n_lang_column = $self->_i18n_lang_column;
654 my $i18n_istran_column = $self->_i18n_istran_column;
656 my ( $pk ) = $self->meta->primary_key_column_names;
659 grep { $_ !~ m/(?:$pk|$i18n_istran_column|$i18n_lang_column)/ }
660 $self->meta->column_names();
663 foreach my $column ( @columns ) {
664 my $old = $self->$column;
665 $self->$column( $from->$column );
667 $self->SUPER::update
();
674 my $rel_name = $self->meta->i18n_translation_rel_name();
676 return $self->has_loaded_related( $rel_name ) || $self->{ _i18n
} ?
1 : 0;
679 sub _i18n_istran_column
{
683 grep { $_->type eq 'i18n_is_translation' } @
{ $self->meta->columns };
685 return $column->name;
688 sub _i18n_lang_column
{
692 grep { $_->type eq 'i18n_language' } @
{ $self->meta->columns };
694 return $column->name;
697 use constant LAZY_LOADED_KEY
=>
698 Rose
::DB
::Object
::Util
::lazy_column_values_loaded_key
();
702 my($self) = $_[0]; # XXX: Must maintain alias to actual "self" object arg
704 my %args = (self
=> @_); # faster than @_[1 .. $#_];
706 $self->SUPER::load
( %args ) if $self->meta->i18n_static_rel_name();
708 my $db = $self->db or return 0;
709 my $dbh = $self->dbh or return 0;
711 my $meta = $self->meta;
714 exists $args{'prepare_cached'} ?
$args{'prepare_cached'} :
715 $meta->dbi_prepare_cached;
717 local $self->{STATE_SAVING
()} = 1;
719 my(@key_columns, @key_methods, @key_values);
724 if ( my $i18n = (delete $args{ i18n
}) ) {
725 my $rel_name = $self->meta->i18n_translation_rel_name();
726 my $new_args = merge
{
727 query
=> ["$rel_name.lang" => $i18n],
728 with
=> [ $rel_name ]
734 if(my $key = delete $args{'use_key'})
736 my @uk = grep { $_->name eq $key } $meta->unique_keys;
741 @key_columns = $uk[0]->column_names;
742 @key_methods = map { $meta->column_accessor_method_name($_) } @key_columns;
743 @key_values = map { $defined++ if(defined $_); $_ }
744 map { $self->$_() } @key_methods;
748 $self->error("Could not load() based on key '$key' - column(s) have undefined values");
749 $meta->handle_error($self);
753 if(@key_values != $defined)
758 else { Carp
::croak
"No unique key named '$key' is defined in ", ref($self) }
762 @key_columns = $meta->primary_key_column_names;
763 @key_methods = $meta->primary_key_column_accessor_names;
764 @key_values = grep { defined } map { $self->$_() } @key_methods;
766 unless(@key_values == @key_columns)
770 # Prefer unique keys where we have defined values for all
771 # key columns, but fall back to the first unique key found
772 # where we have at least one defined value.
773 foreach my $cols ($meta->unique_keys_column_names)
776 @key_columns = @
$cols;
777 @key_methods = map { $meta->column_accessor_method_name($_) } @key_columns;
778 @key_values = map { $defined++ if(defined $_); $_ }
779 map { $self->$_() } @key_methods;
781 if($defined == @key_columns)
787 $alt_columns ||= $cols if($defined);
790 if(!$found_key && $alt_columns)
792 @key_columns = @
$alt_columns;
793 @key_methods = map { $meta->column_accessor_method_name($_) } @key_columns;
794 @key_values = map { $self->$_() } @key_methods;
801 @key_columns = $meta->primary_key_column_names;
804 Rose
::DB
::Object
::Exception
->new(
805 message
=> "Cannot load " . ref($self) . " without a primary key (" .
806 join(', ', @key_columns) . ') with ' .
807 (@key_columns > 1 ?
'non-null values in all columns' :
808 'a non-null value') .
809 ' or another unique key with at least one non-null value.',
810 code
=> EXCEPTION_CODE_NO_KEY
);
813 $meta->handle_error($self);
819 my $has_lazy_columns = $args{'nonlazy'} ?
0 : $meta->has_lazy_columns;
822 if($has_lazy_columns)
824 $column_names = $meta->nonlazy_column_names;
825 $self->{LAZY_LOADED_KEY
()} = {};
829 $column_names = $meta->column_names;
833 # Handle sub-object load in separate code path
836 if(my $with = $args{'with'})
838 my $mgr_class = $args{'manager_class'} || 'Rose::DB::Object::Manager';
841 @query{map { "t1.$_" } @key_columns} = @key_values;
846 %query = ( @
{ $args{query
} }, %query );
849 #print Dumper $args{query};
850 #print Dumper \%query;
857 $mgr_class->get_objects(object_class
=> ref $self,
860 with_objects
=> $with,
862 nonlazy
=> $args{'nonlazy'},
863 inject_results
=> $args{'inject_results'},
864 (exists $args{'prepare_cached'} ?
865 (prepare_cached
=> $args{'prepare_cached'}) :
867 or Carp
::confess
$mgr_class->error;
871 die "Found ", @
$objects, " objects instead of one";
877 $self->error("load(with => ...) - $@");
878 $meta->handle_error($self);
884 # Sneaky init by object replacement
885 $self = $_[0] = $objects->[0];
887 # Init by copying attributes (broken; need to do fks and relationships too)
888 #my $methods = $meta->column_mutator_method_names;
889 #my $object = $objects->[0];
891 #local $self->{STATE_LOADING()} = 1;
892 #local $object->{STATE_SAVING()} = 1;
894 #foreach my $method (@$methods)
896 # $self->$method($object->$method());
902 $self->error("No such " . ref($self) . ' where ' .
903 join(', ', @key_columns) . ' = ' . join(', ', @key_values));
904 $self->{'not_found'} = 1;
906 $self->{STATE_IN_DB
()} = 0;
909 exists $args{'speculative'} ?
$args{'speculative'} :
910 $meta->default_load_speculative;
914 $meta->handle_error($self);
920 $self->{STATE_IN_DB
()} = 1;
921 $self->{LOADED_FROM_DRIVER
()} = $db->{'driver'};
922 $self->{MODIFIED_COLUMNS
()} = {};
932 $self->{'not_found'} = 0;
936 local $self->{STATE_LOADING
()} = 1;
937 local $dbh->{'RaiseError'} = 1;
943 if($has_lazy_columns)
945 $sql = $meta->load_sql_with_null_key(\
@key_columns, \
@key_values, $db);
949 $sql = $meta->load_all_sql_with_null_key(\
@key_columns, \
@key_values, $db);
954 if($has_lazy_columns)
956 $sql = $meta->load_sql(\
@key_columns, $db);
960 $sql = $meta->load_all_sql(\
@key_columns, $db);
964 # $meta->prepare_select_options (defunct)
965 $sth = $prepare_cached ?
$dbh->prepare_cached($sql, undef, 3) :
968 $Debug && warn "$sql - bind params: ", join(', ', grep { defined } @key_values), "\n";
969 $sth->execute(grep { defined } @key_values);
973 $sth->bind_columns(undef, \
@row{@
$column_names});
975 $loaded_ok = defined $sth->fetch;
977 # The load() query shouldn't find more than one row anyway,
978 # but DBD::SQLite demands this :-/
983 my $methods = $meta->column_mutator_method_names_hash;
985 # Empty existing object?
986 #%$self = (db => $self->db, meta => $meta, STATE_LOADING() => 1);
988 foreach my $name (@
$column_names)
990 my $method = $methods->{$name};
991 $self->$method($row{$name});
994 # Sneaky init by object replacement
995 #my $object = (ref $self)->new(db => $self->db);
997 #foreach my $name (@$column_names)
999 # my $method = $methods->{$name};
1000 # $object->$method($row{$name});
1003 #$self = $_[0] = $object;
1008 $self->error("No such " . ref($self) . ' where ' .
1009 join(', ', @key_columns) . ' = ' . join(', ', @key_values));
1010 $self->{'not_found'} = 1;
1011 $self->{STATE_IN_DB
()} = 0;
1017 $self->error("load() - $@");
1018 $meta->handle_error($self);
1025 exists $args{'speculative'} ?
$args{'speculative'} :
1026 $meta->default_load_speculative;
1028 unless($speculative)
1030 $meta->handle_error($self);
1036 $self->{STATE_IN_DB
()} = 1;
1037 $self->{LOADED_FROM_DRIVER
()} = $db->{'driver'};
1038 $self->{MODIFIED_COLUMNS
()} = {};
1042 =head1 COPYRIGHT & LICENSE
1044 Copyright 2008 Viacheslav Tikhanovskii, all rights reserved.
1046 This program is free software; you can redistribute it and/or modify it
1047 under the same terms as Perl itself.