1 package OpenILS::Application::Flattener;
3 # This package is not meant to be registered as a stand-alone OpenSRF
4 # application, but to be used by high level methods in other services.
6 use base qw/OpenILS::Application/;
11 use OpenSRF::EX qw/:try/;
12 use OpenSRF::Utils::Logger qw/:logger/;
13 use OpenILS::Utils::CStoreEditor q/:funcs/;
14 use OpenSRF::Utils::JSON;
18 $Data::Dumper::Indent = 0;
20 sub _fm_link_from_class {
21 my ($class, $field) = @_;
23 return OpenILS::Application->publish_fieldmapper->{$class}{links}{$field};
26 sub _flattened_search_single_flesh_wad {
27 my ($hint, $path) = @_;
29 $path = [ @$path ]; # clone for processing here
30 my $class = OpenSRF::Utils::JSON->lookup_class($hint);
33 my $flesh_fields = {};
35 pop @$path; # last part is just field
39 while ($piece = shift @$path) {
40 my $link = _fm_link_from_class($class, $piece);
42 $flesh_fields->{$hint} ||= [];
43 push @{ $flesh_fields->{$hint} }, $piece;
44 $hint = $link->{class};
45 $class = OpenSRF::Utils::JSON->lookup_class($hint);
48 throw OpenSRF::EX::ERROR("no link $piece on $class");
53 flesh => $flesh_depth,
54 flesh_fields => $flesh_fields
58 # returns a join clause AND a string representing the deepest join alias
60 sub _flattened_search_add_join_clause {
61 my ($column_name, $hint, $path, $core_join, $path_tracker) = @_;
63 my $class = OpenSRF::Utils::JSON->lookup_class($hint);
64 my $last_ident = $class->Identity;
66 $path = [ @$path ]; # clone for processing here
68 pop @$path; # last part is just field
72 my $alias; # yes, we need it out at this scope.
76 while ($piece = shift @$path) {
77 my $link = _fm_link_from_class($class, $piece);
79 $hint = $link->{class};
80 $class = OpenSRF::Utils::JSON->lookup_class($hint);
82 push (@path_key_parts, ${piece});
83 my $path_key = "__" . join('__', @path_key_parts);
85 if (!$path_tracker->{$hint}) {
86 # first time finding this IDL hint anywhere in the map,
88 $path_tracker->{$hint} = {$path_key => 1};
90 } elsif ($path_count = $path_tracker->{$hint}{$path_key}) {
91 # we already have this exact path for this hint,
94 # we found a new path to this class, increment and store
96 $path_count = keys %{$path_tracker->{$hint}}; # count the keys
98 $path_tracker->{$hint}{$path_key} = $path_count;
100 $alias = "__${hint}_${path_count}";
102 # if we have already joined this segment, climb the tree
103 if ($last_join and $last_join->{join}{$alias}) {
104 $last_join = $last_join->{join}{$alias};
106 } elsif ($core_join->{$alias}) {
107 $last_join = $core_join->{$alias};
111 my $reltype = $link->{reltype};
112 my $field = $link->{key};
114 # XXX having a non-blank value for map means we'll need
115 # an additional level of join. TODO.
116 throw OpenSRF::EX::ERROR(
117 "support not yet implemented for links like '$piece' with" .
118 " non-blank 'map' IDL attribute"
123 if ($reltype eq "has_a") {
130 } elsif ($reltype eq "has_many" or $reltype eq "might_have") {
138 throw OpenSRF::EX::ERROR("unexpected reltype for link $piece");
142 $last_join->{join}{$alias} = $new_join;
144 $core_join->{$alias} = $new_join;
147 $last_ident = $class->Identity;
148 $last_join = $new_join;
150 throw new OpenSRF::EX::ERROR("no link '$piece' on $class");
154 return ($core_join, $alias);
157 # When $value is a string (short form of a column definition), it is assumed to
158 # be a dot-delimited path. This will be normalized into a hash (long form)
159 # containing and path key, whose value will be made into an array, and true
160 # values for sort/filter/display.
162 # When $value is already a hash (long form), just make an array of the path key
163 # and explicity set any sort/filter/display values not present to 0.
165 sub _flattened_search_normalize_map_column {
168 if (ref $value eq "HASH") {
169 foreach (qw/sort filter display/) {
170 $value->{$_} = 0 unless exists $value->{$_};
172 $value->{path} = [split /\./, $value->{path}];
175 path => [split /\./, $value],
185 sub _flattened_search_merge_flesh_wad {
186 my ($old, $new) = @_;
189 $old->{flesh} = $old->{flesh} > $new->{flesh} ? $old->{flesh} : $new->{flesh};
191 $old->{flesh_fields} ||= {};
192 foreach my $key (keys %{$new->{flesh_fields}}) {
193 if ($old->{flesh_fields}{$key}) {
194 # For easy bonus points, somebody could take the following block
195 # and make it use Set::Scalar so it's more semantic, which would
196 # mean a new Evergreen dependency.
198 # The nonobvious point of the following code is to merge the
199 # arrays at $old->{flesh_fields}{$key} and
200 # $new->{flesh_fields}{$key}, treating the arrays as sets.
202 my %hash = map { $_ => 1 } (
203 @{ $old->{flesh_fields}{$key} },
204 @{ $new->{flesh_fields}{$key} }
206 $old->{flesh_fields}{$key} = [ keys(%hash) ];
208 $old->{flesh_fields}{$key} = $new->{flesh_fields}{$key};
213 sub _flattened_search_expand_filter_column {
214 my ($o, $key, $map) = @_;
217 my $table = $map->{$key}{last_join_alias};
218 my $column = $map->{$key}{path}[-1];
221 $table = "+" . $table;
224 $o->{$table}{$column} = $o->{$key};
227 return $o->{$table}{$column};
228 } else { # field must be on core class
229 if ($column ne $key) {
230 $o->{$column} = $o->{$key};
233 return $o->{$column};
240 sub _flattened_search_recursively_apply_map_to_filter {
241 my ($o, $map, $state) = @_;
245 if (ref $o eq "HASH") {
246 foreach my $key (keys %$o) {
247 # XXX this business about "in_expr" may prove inadequate, but it's
248 # intended to avoid trying to map things like "between" in
250 # {"somecolumn": {"between": [1,10]}}
251 # and to that extent, it works.
253 if (not $state->{in_expr} and $key =~ /^[a-z]/) {
254 $state->{in_expr} = 1;
256 _flattened_search_recursively_apply_map_to_filter(
257 _flattened_search_expand_filter_column($o, $key, $map),
261 $state->{in_expr} = 0;
263 _flattened_search_recursively_apply_map_to_filter(
264 $o->{$key}, $map, $state
268 } elsif (ref $o eq "ARRAY") {
269 _flattened_search_recursively_apply_map_to_filter(
272 } # else scalar, nothing to do?
275 # returns a normalized version of the map, and the jffolo (see below)
277 my ($hint, $map) = @_;
279 $map = { %$map }; # clone map, to work on new copy
281 my $jffolo = { # jffolo: join/flesh/flesh_fields/order_by/limit/offset
285 # Here's a hash where we'll keep track of whether we've already provided
286 # a join to cover a given hash. It seems that without this we build
288 my $join_coverage = {};
290 # we need to be able to reference specific joined tables, but building
291 # aliases directly from the paths can exceed Postgres alias length limits
292 # (generally 63 characters). Instead, we'll increment for each unique
293 # path to a given IDL class.
294 my $path_tracker = {};
296 foreach my $k (keys %$map) {
297 my $column = $map->{$k} =
298 _flattened_search_normalize_map_column($map->{$k});
300 # For display columns, we'll need fleshing.
301 if ($column->{display}) {
302 _flattened_search_merge_flesh_wad(
304 _flattened_search_single_flesh_wad($hint, $column->{path})
308 # For filter or sort columns, we'll need joining.
309 if ($column->{filter} or $column->{sort}) {
310 my @path = @{ $column->{path} };
311 pop @path; # discard last part (field)
312 my $joinkey = join(",", @path);
314 my ($clause, $last_join_alias);
316 # Skip joins that are already covered. We shouldn't need more than
317 # one join for the same path
318 if ($join_coverage->{$joinkey}) {
319 ($clause, $last_join_alias) = @{ $join_coverage->{$joinkey} };
321 ($clause, $last_join_alias) =
322 _flattened_search_add_join_clause(
323 $k, $hint, $column->{path}, $jffolo->{join}, $path_tracker
325 $join_coverage->{$joinkey} = [$clause, $last_join_alias];
328 $map->{$k}{last_join_alias} = $last_join_alias;
332 return ($map, $jffolo);
335 # return a filter clause for PCRUD or cstore, by processing the supplied
336 # simplifed $where clause using $map.
338 my ($map, $where) = @_;
340 my $filter = {%$where};
342 _flattened_search_recursively_apply_map_to_filter($filter, $map);
347 # Return a jffolo with sort/limit/offset from the simplified sort hash (slo)
348 # mixed in. limit and offset are copied as-is. sort is translated into
349 # an order_by that calls simplified column named by their real names by checking
352 my ($core_hint, $map, $jffolo, $slo) = @_;
354 $jffolo = { %$jffolo }; # clone
355 $slo = { %$slo }; # clone
357 $jffolo->{limit} = $slo->{limit} if exists $slo->{limit};
358 $jffolo->{offset} = $slo->{offset} if exists $slo->{offset};
360 return $jffolo unless $slo->{sort};
362 # The slo has a special format for 'sort' that gives callers what they
363 # need, but isn't as flexible as json_query's 'order_by'.
365 # "sort": [{"column1": "asc"}, {"column2": "desc"}]
367 # "sort": ["column1", {"column2": "desc"}]
369 # "sort": {"onlycolumn": "asc"}
371 # "sort": "onlycolumn"
373 $jffolo->{order_by} = [];
375 # coerce from optional simpler format (see comment blob above)
376 $slo->{sort} = [ $slo->{sort} ] unless ref $slo->{sort} eq "ARRAY";
378 foreach my $exp (@{ $slo->{sort} }) {
379 $exp = { $exp => "asc" } unless ref $exp;
381 # XXX By assuming that each sort expression is (at most) a single
382 # key/value pair, we preclude the ability to use transforms and the
385 my ($key) = keys(%$exp);
388 my $class = $map->{$key}{last_join_alias} || $core_hint;
390 push @{ $jffolo->{order_by} }, {
392 field => $map->{$key}{path}[-1],
393 direction => $exp->{$key}
397 # If the key wasn't defined in the map, we'll leave it out of our
404 # Given a map and a fieldmapper object, return a flat representation as
405 # specified by the map's display fields
407 my ($map, $fmobj) = @_;
409 if (not ref $fmobj) {
410 throw OpenSRF::EX::ERROR(
411 "process_result() was passed an inappropriate second argument ($fmobj)"
417 while (my ($key, $mapping) = each %$map) {
418 next unless $mapping->{display};
420 my @path = @{ $mapping->{path} };
421 my $field = pop @path;
424 while (my $step = shift @path) {
425 $objs = [ map { $_->$step } @$objs ];
426 last unless ref $$objs[0];
429 # We can get arrays of values be either:
430 # - ending on a $field within a has_many reltype
431 # - passing through a path that is a has_many reltype
432 if (@$objs > 1 or ref $$objs[0] eq 'ARRAY') {
433 $flatrow->{$key} = [];
435 push @{ $flatrow->{$key} }, extract_field_value( $o, $field );
438 $flatrow->{$key} = extract_field_value( $$objs[0], $field );
445 sub extract_field_value {
449 if (ref $obj eq 'ARRAY') {
450 # has_many links return arrays
451 return ( map {$_->$field} @$obj );
453 return ref $obj ? $obj->$field : undef;