1 package SL::DB::Helper::LinkedRecords;
6 our @ISA = qw(Exporter);
7 our @EXPORT = qw(linked_records link_to_record);
10 use List::MoreUtils qw(any);
11 use List::UtilsBy qw(uniq_by);
15 use SL::DB::Helper::Mappings;
16 use SL::DB::RecordLink;
19 my ($self, %params) = @_;
21 my %sort_spec = ( by => delete($params{sort_by}),
22 dir => delete($params{sort_dir}) );
23 my $filter = delete $params{filter};
25 my $records = _linked_records_implementation($self, %params);
26 $records = filter_linked_records($self, $filter, @{ $records }) if $filter;
27 $records = sort_linked_records($self, $sort_spec{by}, $sort_spec{dir}, @{ $records }) if $sort_spec{by};
32 sub _linked_records_implementation {
36 my $wanted = $params{direction};
39 if ($params{to} && $params{from}) {
41 } elsif ($params{to}) {
43 } elsif ($params{from}) {
50 if ($wanted eq 'both') {
51 my $both = delete($params{both});
52 my %from_to = ( from => delete($params{from}) || $both,
53 to => delete($params{to}) || $both);
55 if ($params{batch} && $params{by_id}) {
58 _linked_records_implementation($self, %params, direction => 'from', from => $from_to{from}),
59 _linked_records_implementation($self, %params, direction => 'to', to => $from_to{to} ),
62 for my $by_id (@links) {
64 $results{$_} = defined $results{$_}
65 ? [ uniq_by { $_->id } @{ $results{$_} }, @{ $by_id->{$_} } ]
72 my @records = (@{ _linked_records_implementation($self, %params, direction => 'from', from => $from_to{from}) },
73 @{ _linked_records_implementation($self, %params, direction => 'to', to => $from_to{to} ) });
75 my %record_map = map { ( ref($_) . $_->id => $_ ) } @records;
77 return [ values %record_map ];
82 croak("Cannot use 'via' without '${wanted}_table'") if !$params{$wanted};
83 croak("Cannot use 'via' with '${wanted}_table' being an array") if ref $params{$wanted};
86 my $myself = $wanted eq 'from' ? 'to' : $wanted eq 'to' ? 'from' : croak("Invalid parameter `direction'");
87 my $my_table = SL::DB::Helper::Mappings::get_table_for_package(ref($self));
89 my $sub_wanted_table = "${wanted}_table";
90 my $sub_wanted_id = "${wanted}_id";
91 my $sub_myself_id = "${myself}_id";
93 my ($wanted_classes, $wanted_tables);
94 if ($params{$wanted}) {
95 $wanted_classes = ref($params{$wanted}) eq 'ARRAY' ? $params{$wanted} : [ $params{$wanted} ];
96 $wanted_tables = [ map { SL::DB::Helper::Mappings::get_table_for_package($_) || croak("Invalid parameter `${wanted}'") } @{ $wanted_classes } ];
99 my @get_objects_query = ref($params{query}) eq 'ARRAY' ? @{ $params{query} } : ();
100 my $get_objects = sub {
102 return [] unless @$links;
105 push @{ $classes{ $_->$sub_wanted_table } //= [] }, $_->$sub_wanted_id for @$links;
108 for (keys %classes) {
109 my $manager_class = SL::DB::Helper::Mappings::get_manager_package_for_table($_);
110 my $object_class = SL::DB::Helper::Mappings::get_package_for_table($_);
111 eval "require " . $object_class . "; 1;";
113 push @objs, @{ $manager_class->get_all(
114 query => [ id => $classes{$_}, @get_objects_query ],
115 (with_objects => $params{with_objects}) x !!$params{with_objects},
120 my %objs_by_id = map { $_->id => $_ } @objs;
123 if ('ARRAY' eq ref $objs_by_id{$_->$sub_wanted_id}->{_record_link}) {
124 push @{ $objs_by_id{$_->$sub_wanted_id}->{_record_link_direction} }, $wanted;
125 push @{ $objs_by_id{$_->$sub_wanted_id}->{_record_link } }, $_;
126 } elsif ($objs_by_id{$_->$sub_wanted_id}->{_record_link}) {
127 $objs_by_id{$_->$sub_wanted_id}->{_record_link_direction} = [
128 $objs_by_id{$_->$sub_wanted_id}->{_record_link_direction},
131 $objs_by_id{$_->$sub_wanted_id}->{_record_link} = [
132 $objs_by_id{$_->$sub_wanted_id}->{_record_link},
136 $objs_by_id{$_->$sub_wanted_id}->{_record_link_direction} = $wanted;
137 $objs_by_id{$_->$sub_wanted_id}->{_record_link} = $_;
144 # If no 'via' is given then use a simple(r) method for querying the wanted objects.
145 if (!$params{via} && !$params{recursive}) {
146 my @query = ( "${myself}_table" => $my_table,
147 "${myself}_id" => $params{batch} ? $params{batch} : $self->id );
148 push @query, ( "${wanted}_table" => $wanted_tables ) if $wanted_tables;
150 my $links = SL::DB::Manager::RecordLink->get_all(query => [ and => \@query ]);
151 my $objs = $get_objects->($links);
153 if ($params{batch} && $params{by_id}) {
159 $_->{_record_link}->$sub_myself_id == $id
162 } @{ $params{batch} }
169 # More complex handling for the 'via' case.
171 die 'batch mode is not supported with via' if $params{batch};
173 my @sources = ( $self );
174 my @targets = map { SL::DB::Helper::Mappings::get_table_for_package($_) } @{ ref($params{via}) ? $params{via} : [ $params{via} ] };
175 push @targets, @{ $wanted_tables } if $wanted_tables;
177 my %seen = map { ($_->meta->table . $_->id => 1) } @sources;
180 my @new_sources = @sources;
181 foreach my $src (@sources) {
182 my @query = ( "${myself}_table" => $src->meta->table,
183 "${myself}_id" => $src->id,
184 "${wanted}_table" => \@targets );
187 grep { !$seen{$_->$sub_wanted_table . $_->$sub_wanted_id} }
188 @{ SL::DB::Manager::RecordLink->get_all(query => [ and => \@query ]) }
192 @sources = @new_sources;
193 %seen = map { ($_->meta->table . $_->id => 1) } @sources;
197 my %wanted_tables_map = map { ($_ => 1) } @{ $wanted_tables };
198 return [ grep { $wanted_tables_map{$_->meta->table} } @sources ];
201 # And lastly recursive mode
202 if ($params{recursive}) {
203 my ($id_token, @ids);
204 if ($params{batch}) {
205 $id_token = sprintf 'IN (%s)', join ', ', ('?') x @{ $params{batch} };
206 @ids = @{ $params{batch} };
212 # don't use rose retrieval here. too slow.
213 # instead use recursive sql to get all the linked record_links entrys, and retrieve the objects from there
215 WITH RECURSIVE record_links_rec_${wanted}(id, from_table, from_id, to_table, to_id, depth, path, cycle) AS (
216 SELECT id, from_table, from_id, to_table, to_id,
219 WHERE ${myself}_id $id_token and ${myself}_table = ?
221 SELECT rl.id, rl.from_table, rl.from_id, rl.to_table, rl.to_id,
222 rlr.depth + 1, path || rl.id, rl.id = ANY(path)
223 FROM record_links rl, record_links_rec_${wanted} rlr
224 WHERE rlr.${wanted}_id = rl.${myself}_id AND rlr.${wanted}_table = rl.${myself}_table AND NOT cycle
226 SELECT DISTINCT ON (${wanted}_table, ${wanted}_id)
227 id, from_table, from_id, to_table, to_id, path, depth FROM record_links_rec_${wanted}
229 ORDER BY ${wanted}_table, ${wanted}_id, depth ASC;
231 my $links = selectall_hashref_query($::form, $::form->get_standard_dbh, $query, @ids, $self->meta->table);
234 return $params{by_id} ? {} : [];
237 my $link_objs = SL::DB::Manager::RecordLink->get_all(query => [ id => [ map { $_->{id} } @$links ] ]);
238 my $objects = $get_objects->($link_objs);
240 my %links_by_id = map { $_->{id} => $_ } @$links;
242 if ($params{save_path}) {
244 for my $record_link ('ARRAY' eq ref $_->{_record_link} ? @{ $_->{_record_link} } : $_->{_record_link}) {
245 my $link = $links_by_id{$record_link->id};
246 my $intermediate_links = SL::DB::Manager::RecordLink->get_all(query => [ id => $link->{path} ]);
247 $_->{_record_link_path} = $link->{path};
248 $_->{_record_link_obj_path} = $get_objects->($intermediate_links);
249 $_->{_record_link_depth} = $link->{depth};
254 if ($params{batch} && $params{by_id}) {
255 my %link_obj_by_id = map { $_->id => $_ } @$link_objs;
263 $links_by_id{$_->id}->{path}->[0]
264 }->$sub_myself_id == $id
265 } 'ARRAY' eq $_->{_record_link} ? @{ $_->{_record_link} } : $_->{_record_link}
268 } @{ $params{batch} }
281 croak "self has no id" unless $self->id;
282 croak "other has no id" unless $other->id;
284 my @directions = ([ 'from', 'to' ]);
285 push @directions, [ 'to', 'from' ] if $params{bidirectional};
288 foreach my $direction (@directions) {
289 my %data = ( $direction->[0] . "_table" => SL::DB::Helper::Mappings::get_table_for_package(ref($self)),
290 $direction->[0] . "_id" => $self->id,
291 $direction->[1] . "_table" => SL::DB::Helper::Mappings::get_table_for_package(ref($other)),
292 $direction->[1] . "_id" => $other->id,
295 my $link = SL::DB::Manager::RecordLink->find_by(and => [ %data ]);
296 push @links, $link ? $link : SL::DB::RecordLink->new(%data)->save;
299 return wantarray ? @links : $links[0];
302 sub sort_linked_records {
303 my ($self_or_class, $sort_by, $sort_dir, @records) = @_;
305 @records = @{ $records[0] } if (1 == scalar(@records)) && (ref($records[0]) eq 'ARRAY');
306 $sort_dir = $sort_dir * 1 ? 1 : -1;
308 my %numbers = ( 'SL::DB::SalesProcess' => sub { $_[0]->id },
309 'SL::DB::Order' => sub { $_[0]->quotation ? $_[0]->quonumber : $_[0]->ordnumber },
310 'SL::DB::DeliveryOrder' => sub { $_[0]->donumber },
311 'SL::DB::Invoice' => sub { $_[0]->invnumber },
312 'SL::DB::PurchaseInvoice' => sub { $_[0]->invnumber },
313 'SL::DB::RequirementSpec' => sub { $_[0]->id },
314 'SL::DB::Letter' => sub { $_[0]->letternumber },
315 'SL::DB::ShopOrder' => sub { $_[0]->shop_ordernumber },
316 UNKNOWN => '9999999999999999',
318 my $number_xtor = sub {
319 my $number = $numbers{ ref($_[0]) };
320 $number = $number->($_[0]) if ref($number) eq 'CODE';
321 return $number || $numbers{UNKNOWN};
323 my $number_comparator = sub {
324 my $number_a = $number_xtor->($a);
325 my $number_b = $number_xtor->($b);
327 ncmp($number_a, $number_b) * $sort_dir;
331 %scores = ( 'SL::DB::SalesProcess' => 10,
332 'SL::DB::RequirementSpec' => 15,
333 'SL::DB::Order' => sub { $scores{ $_[0]->type } },
334 sales_quotation => 20,
336 sales_delivery_order => 40,
337 'SL::DB::DeliveryOrder' => sub { $scores{ $_[0]->type } },
338 'SL::DB::Invoice' => 50,
339 request_quotation => 120,
340 purchase_order => 130,
341 purchase_delivery_order => 140,
342 'SL::DB::PurchaseInvoice' => 150,
343 'SL::DB::PurchaseInvoice' => 150,
344 'SL::DB::Letter' => 200,
345 'SL::DB::ShopOrder' => 250,
348 my $score_xtor = sub {
349 my $score = $scores{ ref($_[0]) };
350 $score = $score->($_[0]) if ref($score) eq 'CODE';
351 return $score || $scores{UNKNOWN};
353 my $type_comparator = sub {
354 my $score_a = $score_xtor->($a);
355 my $score_b = $score_xtor->($b);
357 $score_a == $score_b ? $number_comparator->() : ($score_a <=> $score_b) * $sort_dir;
360 my $today = DateTime->today_local;
361 my $date_xtor = sub {
362 $_[0]->can('transdate_as_date') ? $_[0]->transdate
363 : $_[0]->can('itime_as_date') ? $_[0]->itime->clone->truncate(to => 'day')
366 my $date_comparator = sub {
367 my $date_a = $date_xtor->($a);
368 my $date_b = $date_xtor->($b);
370 ($date_a <=> $date_b) * $sort_dir;
373 my $comparator = $sort_by eq 'number' ? $number_comparator
374 : $sort_by eq 'date' ? $date_comparator
377 return [ sort($comparator @records) ];
380 sub filter_linked_records {
381 my ($self_or_class, $filter, @records) = @_;
383 if ($filter eq 'accessible') {
384 my $employee = SL::DB::Manager::Employee->current;
385 @records = grep { !$_->can('may_be_accessed') || $_->may_be_accessed($employee) } @records;
387 croak "Unsupported filter parameter '${filter}'";
401 SL::DB::Helper::LinkedRecords - Mixin for retrieving linked records via the table C<record_links>
405 # In SL::DB::<Object>
406 use SL::DB::Helper::LinkedRecords;
408 # later in consumer code
409 # retrieve all links in both directions
410 my @linked_objects = $order->linked_records;
412 # only links to Invoices
413 my @linked_objects = $order->linked_records(
417 # more than one target
418 my @linked_objects = $order->linked_records(
419 to => [ 'Invoice', 'Order' ],
422 # more than one direction
423 my @linked_objects = $order->linked_records(
427 # more than one direction and different targets
428 my @linked_objects = $order->linked_records(
433 # via over known classes
434 my @linked_objects = $order->linked_records(
436 via => 'DeliveryOrder',
438 my @linked_objects = $order->linked_records(
440 via => [ 'Order', 'DeliveryOrder' ],
444 my @linked_objects = $order->linked_records(
449 # limit direction when further params contain additional keys
450 my %params = (to => 'Invoice', from => 'Order');
451 my @linked_objects = $order->linked_records(
457 $order->link_to_record($invoice);
458 $order->link_to_record($purchase_order, bidirectional => 1);
465 =item C<linked_records %params>
467 Retrieves records linked from or to C<$self> via the table C<record_links>.
469 The optional parameter C<direction> (either C<from>, C<to> or C<both>)
470 determines whether the function retrieves records that link to C<$self> (for
471 C<direction> = C<to>) or that are linked from C<$self> (for C<direction> =
472 C<from>). For C<direction = both> all records linked from or to C<$self> are
475 The optional parameter C<from> or C<to> (same as C<direction>) contains the
476 package names of Rose models for table limitation (the prefix C<SL::DB::> is
477 optional). It can be a single model name as a single scalar or multiple model
478 names in an array reference in which case all links matching any of the model
479 names will be returned.
481 If no parameter C<direction> is given, but any of C<to>, C<from> or C<both>,
482 then C<direction> is inferred accordingly. If neither are given, C<direction> is
485 The optional parameter C<via> can be used to retrieve all documents that may
486 have intermediate documents inbetween. It is an array reference of Rose package
487 names for the models that may be intermediate link targets. One example is
488 retrieving all invoices for a given quotation no matter whether or not orders
489 and delivery orders have been created. If C<via> is given then C<from> or C<to>
490 (depending on C<direction>) must be given as well, and it must then not be an
495 If you only need invoices created directly from an order C<$order> (no
496 delivery orders in between) then the call could look like this:
498 my $invoices = $order->linked_records(
503 Retrieving all invoices from a quotation no matter whether or not
504 orders or delivery orders were created:
506 my $invoices = $quotation->linked_records(
509 via => [ 'Order', 'DeliveryOrder' ],
512 The optional parameter C<query> can be used to limit the records
513 returned. The following call limits the earlier example to invoices
516 my $invoices = $order->linked_records(
519 query => [ transdate => DateTime->today_local ],
522 In case you don't know or care which or how many objects are visited the flag
523 C<recursive> can be used. It searches all reachable objects in the given direction:
525 my $records = $order->linked_records(
530 Only link chains of the same type will be considered. So even with direction
533 order 1 ---> invoice <--- order 2
535 started from order 1 will only find invoice. If an object is found both in each
536 direction, only one copy will be returned. The recursion is cycle protected,
537 and will not recurse infinitely. Cycles are defined by the same link being
538 visited twice, so this
541 order 1 ---> order 2 <--> delivery order
545 will find the path o1 -> o2 -> do -> o2 -> i without considering it a cycle.
547 The optional extra flag C<save_path> will give you extra information saved in
548 the returned objects:
550 my $records = $order->linked_records(
556 Every record will have two fields set:
560 =item C<_record_link_path>
562 An array with the ids of the visited links. The shortest paths will be
563 preferred, so in the previous example this would contain the ids of o1-o2 and
566 =item C<_record_link_depth>
568 Recursion depth when this object was found. Equal to the number of ids in
573 Since record_links is comparatively expensive to call, you will want to cache
574 the results for multiple objects if you know in advance you'll need them.
576 You can pass the optional argument C<batch> with an array ref of ids which will
577 be used instead of the id of the invocant. You still need to call it as a
578 method on a valid object, because table information is inferred from there.
580 C<batch> mode will currenty not work with C<via>.
582 The optional flag C<by_id> will return the objects sorted into a hash instead
583 of a plain array. Calling C<<recursive => 1, batch => [1,2], by_id => 1>> on
586 order 1 --> delivery order 1 --> invoice 1
587 order 2 --> delivery order 2 --> invoice 2
591 { 1 => [ delivery order 1, invoice 1 ],
592 2 => [ delivery order 2, invoice 1 ], }
594 you may then cache these as you see fit.
597 The optional parameters C<$params{sort_by}> and C<$params{sort_dir}>
598 can be used in order to sort the result. If C<$params{sort_by}> is
599 trueish then the result is sorted by calling L</sort_linked_records>.
601 The optional parameter C<$params{filter}> controls whether or not the
602 result is filtered. Supported values are:
608 Removes all objects for which the function C<may_be_accessed> from the
609 mixin L<SL::DB::Helper::MayBeAccessed> exists and returns falsish for
610 the current employee.
614 Returns an array reference. Each element returned is a Rose::DB
615 instance. Additionally several elements in the element returned are
616 set to special values:
620 =item C<_record_link_direction>
622 Either C<from> or C<to> indicating the direction. C<from> means that
623 this object is the source in the link.
625 =item C<_record_link>
627 The actual database link object (an instance of L<SL::DB::RecordLink>).
631 =item C<link_to_record $record, %params>
633 Will create an entry in the table C<record_links> with the C<from>
634 side being C<$self> and the C<to> side being C<$record>. Will only
635 insert a new entry if such a link does not already exist.
637 If C<$params{bidirectional}> is trueish then another link will be
638 created with the roles of C<from> and C<to> reversed. This link will
639 also only be created if it doesn't exist already.
641 In scalar context returns either the existing link or the newly
642 created one as an instance of C<SL::DB::RecordLink>. In array context
643 it returns an array of links (one entry if C<$params{bidirectional}>
644 is falsish and two entries if it is trueish).
646 =item C<sort_linked_records $sort_by, $sort_dir, @records>
648 Sorts linked records by C<$sort_by> in the direction given by
649 C<$sort_dir> (trueish = ascending, falsish = descending). C<@records>
650 can be either a single array reference or or normal array.
652 C<$sort_by> can be one of the following strings:
658 Sort by type first and by record number second. The type order
659 reflects the order in which records are usually processed by the
660 employees: sales processes, sales quotations, sales orders, sales
661 delivery orders, invoices; requests for quotation, purchase orders,
662 purchase delivery orders, purchase invoices.
666 Sort by the record's running number.
670 Sort by the transdate of the record was created or applies to.
672 Note: If the latter has a default setting it will always mask the creation time.
676 Returns an array reference.
678 Can only be called both as a class function since it is not exported.
684 This mixin exports the functions L</linked_records> and
693 * C<recursive> should take a query param depth and cut off there
694 * C<recursive> uses partial distinct which is known to be not terribly fast on
695 a million entry table. replace with a better statement if this ever becomes
700 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
701 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>