File Coverage

blib/lib/FlyBy.pm
Criterion Covered Total %
statement 157 161 97.5
branch 63 72 87.5
condition 28 48 58.3
subroutine 19 21 90.4
pod 4 5 80.0
total 271 307 88.2


line stmt bran cond sub pod time code
1             package FlyBy;
2              
3 2     2   112932 use strict;
  2         3  
  2         50  
4 2     2   6 use warnings;
  2         3  
  2         32  
5 2     2   33 use 5.010;
  2         7  
  2         59  
6             our $VERSION = '0.093';
7              
8 2     2   881 use Moo;
  2         19680  
  2         10  
9              
10 2     2   2674 use Carp qw(croak);
  2         2  
  2         68  
11 2     2   788 use Parse::Lex;
  2         26946  
  2         53  
12 2     2   17 use Scalar::Util qw(reftype);
  2         3  
  2         90  
13 2     2   1008 use Set::Scalar;
  2         14805  
  2         66  
14 2     2   1645 use Try::Tiny;
  2         1811  
  2         2176  
15              
16             has index_sets => (
17             is => 'ro',
18             init_arg => undef,
19             default => sub { {}; },
20             );
21              
22             has records => (
23             is => 'ro',
24             init_arg => undef,
25             default => sub { []; },
26             );
27              
28             has _full_set => (
29             is => 'ro',
30             init_arg => undef,
31             default => sub { Set::Scalar->new; },
32             );
33              
34             has query_lexer => (
35             is => 'ro',
36             init_arg => undef,
37             lazy => 1,
38             builder => '_build_query_lexer',
39             );
40              
41             my $negation = '!';
42              
43             sub _build_query_lexer {
44 2     2   785 my $self = shift;
45              
46             my @tokens = (
47             "NOTEQUAL" => "(is not|IS NOT)",
48             "EQUAL" => "is|IS",
49             "AND" => "and|AND",
50             "OR" => "or|OR",
51             "REDUCE" => "->",
52             "COMMA" => ",",
53             "QUOTED_STRING" => qq~(?:\'(?:[^\\\']*(?:\\.[^\\\']*)*)\'|\"(?:[^\\\"]*(?:\\.[^\\\"]*)*)\")~, # From Text::Balanced
54             "ERROR" => ".*",
55 2     1   17 sub { die qq!cannot analyze: "$_[1]"!; });
  1         6155  
56              
57 2         28 return Parse::Lex->new(@tokens);
58             }
59              
60             sub add_records {
61 6     6 1 6740 my ($self, @new_records) = @_;
62              
63 6         24 my $index_sets = $self->index_sets;
64 6         10 my $records = $self->records;
65              
66 6         9 foreach my $record (@new_records) {
67 11   100     141 my $whatsit = reftype($record) // 'no reference';
68 11 100       52 croak 'Records must be hash references, got: ' . $whatsit unless ($whatsit eq 'HASH');
69              
70 9         10 my $rec_index = $#$records + 1; # Even if we accidentally made this sparse, we can insert here.
71 9         7 my $add_it = 0; # Do not add until we know there is at least one defined value;
72 9         30 while (my ($k, $v) = each %$record) {
73 27 100       28 if (defined $v) {
74 24         33 $self->_from_index($k, $v, 1)->insert($rec_index);
75 24   100     586 $add_it ||= 1;
76             } else {
77 3         8 delete $record->{$k}; # A missing key denotes an undefined value.
78             }
79             }
80 9 100       34 if ($add_it) {
81 8         11 $records->[$rec_index] = $record;
82 8         17 $self->_full_set->insert($rec_index);
83             }
84             }
85              
86 4         79 return 1;
87             }
88              
89             sub _from_index {
90 84     84   77 my ($self, $key, $value, $add_missing_key) = @_;
91 84         111 my $index_sets = $self->index_sets;
92              
93 84         51 my ($result, $negated);
94              
95 84 100       157 if (substr($value, 0, 1) eq $negation) {
96 12         11 $negated = 1;
97 12         14 $value = substr($value, 1);
98             }
99              
100 84 100 100     209 if (not $add_missing_key and not exists $index_sets->{$key}) {
101 12 100       49 $result = ($negated) ? $self->_full_set : $self->_full_set->empty_clone; # Avoiding auto-viv on request
102             } else {
103 72   66     300 $index_sets->{$key}{$value} //= $self->_full_set->empty_clone; # Sets which do not (yet) exist in the index are null.
104 72         1029 $result = $index_sets->{$key}{$value};
105 72 100       124 $result = $self->_full_set->difference($result) if ($negated);
106             }
107              
108 84         3062 return $result;
109             }
110              
111             sub query {
112 47     47 1 39101 my ($self, $query_clauses, $reduce_list) = @_;
113              
114 47 100       128 if (not reftype($query_clauses)) {
115 20         24 my $err; # To let us notice parsing errors;
116 20 100       59 croak 'String queries should have a single parameter' if (defined $reduce_list);
117 19         37 ($query_clauses, $reduce_list, $err) = $self->parse_query($query_clauses);
118 19 100       69 croak $err if $err;
119             } else {
120             # Trust the parser above, so we only verify on 'hand-made' queries.
121 27 100 50     208 croak 'Query clauses should be a non-empty hash reference.'
      33        
      66        
122             unless ($query_clauses and (reftype($query_clauses) // '') eq 'HASH' and keys %$query_clauses);
123 26 100 100     88 croak 'Reduce list should be a non-empty array reference.'
      100        
      66        
124             unless (not $reduce_list or ((reftype($reduce_list) // '') eq 'ARRAY' and scalar @$reduce_list));
125              
126             # Now convert the supplied hashref to an array reference we can use.
127 24         52 my %qhash = %$query_clauses;
128 24         34 $query_clauses = [map { [$_ => $qhash{$_}] } grep { defined $qhash{$_} } keys %qhash];
  30         62  
  31         61  
129             }
130              
131 41         80 my $match_set = $self->_full_set;
132 41         44 my @qc = @$query_clauses;
133              
134 41         65 while (my $addl_clause = shift @qc) {
135 52         2141 my ($key, $value) = @$addl_clause;
136 52         65 my $whatsit = reftype($value);
137 52         39 my $change_set;
138 52 100 66     100 if ($whatsit && $whatsit eq 'ARRAY') {
139             # OR syntax.
140 9         15 my @ors = @$value;
141 9         12 $value = shift @ors;
142             # Allow for negation, even though it seems unlikely.
143 9         18 $change_set = $self->_from_index($key, $value, 0);
144 9         23 foreach my $or (@ors) {
145 8         11 $change_set = $change_set->union($self->_from_index($key, $or, 0));
146             }
147             } else {
148 43         68 $change_set = $self->_from_index($key, $value, 0);
149             }
150              
151 52         1654 $match_set = $match_set->intersection($change_set);
152             }
153              
154 41         7046 my $records = $self->records;
155             # Sort may only be important for testing. Reconsider if large slow sets appear.
156 41         78 my @indices = sort { $a <=> $b } ($match_set->elements);
  73         297  
157 41         100 my @results;
158              
159 41 100       55 if ($reduce_list) {
160 13         15 my @keys = @$reduce_list;
161 13         12 my $key_count = scalar @keys;
162 13         9 my %seen;
163 13         18 foreach my $idx (@indices) {
164 30   50     24 my @reduced_element = map { ($records->[$idx]->{$_} // '') } @keys;
  36         78  
165 30         25 my $seen_key = join('->', @reduced_element);
166 30 100       42 if (not $seen{$seen_key}) {
167 22 100       27 push @results, ($key_count > 1) ? \@reduced_element : @reduced_element;
168 22         37 $seen{$seen_key}++;
169             }
170             }
171             } else {
172 52         176 @results = map {
173 28         25 { %{$records->[$_]} }
  52         32  
174             } @indices;
175             }
176              
177 41         111 return @results;
178             }
179              
180             sub parse_query {
181 19     19 0 18 my ($self, $query) = @_;
182              
183 19         14 my (%values, $err);
184 19         385 my $lexer = $self->query_lexer;
185 19     0   1185 my $parse_err = sub { return 'Improper query at: ' . shift; };
  0         0  
186              
187             try {
188 19 100   19   706 croak 'Empty query' unless $query;
189 18         19 my @clause = ();
190 18         58 my @tokens = $lexer->analyze($query);
191 17         10585 my ($in_reduce, $negated, $in_or) = (0, 0);
192 17         37 $values{query} = [];
193             TOKEN:
194 17         37 while (my $name = shift @tokens) {
195 112         77 my $text = shift @tokens;
196 112 100       133 if ($name eq 'EOI') {
197             # We must be done.
198 17 100 66     57 if (@clause and $in_reduce) {
    50          
199 6         11 $values{reduce} = [@clause];
200             } elsif (@clause) {
201 11         5 push @{$values{query}}, [@clause];
  11         19  
202             }
203              
204 17         41 last TOKEN;
205             }
206 95 100       109 next TOKEN if ($name eq 'COMMA'); # They can put commas anywhere, we don't care.
207 93         60 my $expected_length = 2;
208 93 100       146 if ($name eq 'QUOTED_STRING') {
    100          
    100          
    100          
    100          
    50          
209 56 100       87 my $value = ($negated) ? $negation . substr($text, 1, -1) : substr($text, 1, -1);
210 56 100       48 if ($in_or) {
211 4 50       15 $clause[-1] = [$clause[-1]] unless (reftype($clause[-1]));
212 4         3 push @{$clause[-1]}, $value;
  4         7  
213             } else {
214 52         52 push @clause, $value;
215             }
216 56         96 ($in_or, $negated) = 0;
217             } elsif ($name eq 'AND') {
218 5 50 33     18 croak $parse_err->($text) if ($in_reduce or scalar @clause != $expected_length);
219 5         3 push @{$values{query}}, [@clause];
  5         23  
220 5         13 @clause = (); # Starting a new clause.
221             } elsif ($name eq 'OR') {
222 4 50 33     14 croak $parse_err->($text) if ($in_reduce or scalar @clause != $expected_length);
223 4         9 $in_or = 1;
224             } elsif ($name eq 'EQUAL') {
225 17 50 33     84 croak $parse_err->($text) if ($in_reduce or scalar @clause != $expected_length - 1);
226             } elsif ($name eq 'NOTEQUAL') {
227 5 50 33     20 croak $parse_err->($text) if ($in_reduce or scalar @clause != $expected_length - 1);
228 5         8 $negated = 1;
229             } elsif ($name eq 'REDUCE') {
230 6 50       8 croak $parse_err->($text) if ($in_reduce);
231 6         5 $in_reduce = 1;
232 6 50       10 push @{$values{query}}, [@clause] if (@clause);
  6         11  
233 6         13 @clause = ();
234             }
235             }
236             }
237             catch {
238 2     2   483 $err = $_;
239 19         123 };
240              
241 19         433 return $values{query}, $values{reduce}, $err;
242             }
243              
244             sub _check_clause {
245 0     0   0 my ($self, $thing) = @_;
246              
247 0         0 my $whatsit = reftype $thing;
248 0   0     0 return ($whatsit and $whatsit eq 'ARRAY' and scalar @$thing == 2);
249             }
250              
251             sub all_keys {
252 1     1 1 982 my $self = shift;
253 1         1 return (sort { $a cmp $b } keys %{$self->index_sets});
  5         10  
  1         8  
254             }
255              
256             sub values_for_key {
257 1     1 1 4 my ($self, $key) = @_;
258 1         2 return (sort { $a cmp $b } keys %{$self->index_sets->{$key}});
  3         11  
  1         9  
259             }
260              
261             1;
262             __END__