File Coverage

blib/lib/FlyBy.pm
Criterion Covered Total %
statement 160 164 97.5
branch 65 74 87.8
condition 28 48 58.3
subroutine 19 21 90.4
pod 4 5 80.0
total 276 312 88.4


line stmt bran cond sub pod time code
1             package FlyBy;
2              
3 2     2   169659 use strict;
  2         3  
  2         42  
4 2     2   6 use warnings;
  2         3  
  2         32  
5 2     2   34 use 5.010;
  2         8  
6             our $VERSION = '0.094';
7              
8 2     2   897 use Moo;
  2         16586  
  2         11  
9              
10 2     2   2029 use Carp qw(croak);
  2         2  
  2         68  
11 2     2   853 use Parse::Lex;
  2         27371  
  2         54  
12 2     2   12 use Scalar::Util qw(reftype);
  2         2  
  2         76  
13 2     2   884 use Set::Scalar;
  2         14752  
  2         68  
14 2     2   815 use Try::Tiny;
  2         1888  
  2         2362  
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   917 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         6237  
56              
57 2         34 return Parse::Lex->new(@tokens);
58             }
59              
60             sub add_records {
61 8     8 1 8163 my ($self, @new_records) = @_;
62              
63 8         24 my $index_sets = $self->index_sets;
64 8         15 my $records = $self->records;
65              
66 8         15 foreach my $record (@new_records) {
67 13   100     172 my $whatsit = reftype($record) // 'no reference';
68 13 100       62 croak 'Records must be hash references, got: ' . $whatsit unless ($whatsit eq 'HASH');
69              
70 11         16 my $rec_index = $#$records + 1; # Even if we accidentally made this sparse, we can insert here.
71 11         12 my $add_it = 0; # Do not add until we know there is at least one defined value;
72 11         35 while (my ($k, $v) = each %$record) {
73 31 100       37 if (defined $v) {
74 28         38 $self->_from_index($k, $v, 1)->insert($rec_index);
75 28   100     775 $add_it ||= 1;
76             } else {
77 3         8 delete $record->{$k}; # A missing key denotes an undefined value.
78             }
79             }
80 11 100       36 if ($add_it) {
81 10         16 $records->[$rec_index] = $record;
82 10         25 $self->_full_set->insert($rec_index);
83             }
84             }
85              
86 6         145 return 1;
87             }
88              
89             sub _from_index {
90 89     89   100 my ($self, $key, $value, $add_missing_key) = @_;
91 89         103 my $index_sets = $self->index_sets;
92              
93 89         55 my ($result, $negated);
94              
95 89 100       169 if (substr($value, 0, 1) eq $negation) {
96 12         9 $negated = 1;
97 12         12 $value = substr($value, 1);
98             }
99              
100 89 100 100     248 if (not $add_missing_key and not exists $index_sets->{$key}) {
101 12 100       59 $result = ($negated) ? $self->_full_set : $self->_full_set->empty_clone; # Avoiding auto-viv on request
102             } else {
103 77   66     238 $index_sets->{$key}{$value} //= $self->_full_set->empty_clone; # Sets which do not (yet) exist in the index are null.
104 77         1124 $result = $index_sets->{$key}{$value};
105 77 100       136 $result = $self->_full_set->difference($result) if ($negated);
106             }
107              
108 89         2517 return $result;
109             }
110              
111             sub query {
112 48     48 1 55032 my ($self, $query_clauses, $reduce_list) = @_;
113              
114 48 100       133 if (not reftype($query_clauses)) {
115 20         16 my $err; # To let us notice parsing errors;
116 20 100       54 croak 'String queries should have a single parameter' if (defined $reduce_list);
117 19         34 ($query_clauses, $reduce_list, $err) = $self->parse_query($query_clauses);
118 19 100       72 croak $err if $err;
119             } else {
120             # Trust the parser above, so we only verify on 'hand-made' queries.
121 28 100 50     226 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 27 100 100     94 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 25         60 my %qhash = %$query_clauses;
128 25         43 $query_clauses = [map { [$_ => $qhash{$_}] } grep { defined $qhash{$_} } keys %qhash];
  31         68  
  32         61  
129             }
130              
131 42         88 my $match_set = $self->_full_set;
132 42         58 my @qc = @$query_clauses;
133              
134 42         78 while (my $addl_clause = shift @qc) {
135 53         2233 my ($key, $value) = @$addl_clause;
136 53         75 my $whatsit = reftype($value);
137 53         38 my $change_set;
138 53 100 66     109 if ($whatsit && $whatsit eq 'ARRAY') {
139             # OR syntax.
140 9         15 my @ors = @$value;
141 9         10 $value = shift @ors;
142             # Allow for negation, even though it seems unlikely.
143 9         20 $change_set = $self->_from_index($key, $value, 0);
144 9         14 foreach my $or (@ors) {
145 8         11 $change_set = $change_set->union($self->_from_index($key, $or, 0));
146             }
147             } else {
148 44         73 $change_set = $self->_from_index($key, $value, 0);
149             }
150              
151 53         1816 $match_set = $match_set->intersection($change_set);
152             }
153              
154 42         7587 my $records = $self->records;
155             # Sort may only be important for testing. Reconsider if large slow sets appear.
156 42         78 my @indices = sort { $a <=> $b } ($match_set->elements);
  80         192  
157 42         101 my @results;
158              
159 42 100       59 if ($reduce_list) {
160 13         18 my @keys = @$reduce_list;
161 13         14 my $key_count = scalar @keys;
162 13         10 my %seen;
163 13         22 foreach my $idx (@indices) {
164 30   50     23 my @reduced_element = map { ($records->[$idx]->{$_} // '') } @keys;
  36         88  
165 30         31 my $seen_key = join('->', @reduced_element);
166 30 100       48 if (not $seen{$seen_key}) {
167 22 100       33 push @results, ($key_count > 1) ? \@reduced_element : @reduced_element;
168 22         43 $seen{$seen_key}++;
169             }
170             }
171             } else {
172             @results = map {
173 29         31 { %{$records->[$_]} }
  52         35  
  52         165  
174             } @indices;
175             }
176              
177 42         141 return @results;
178             }
179              
180             sub parse_query {
181 19     19 0 19 my ($self, $query) = @_;
182              
183 19         16 my (%values, $err);
184 19         391 my $lexer = $self->query_lexer;
185 19     0   1255 my $parse_err = sub { return 'Improper query at: ' . shift; };
  0         0  
186              
187             try {
188 19 100   19   660 croak 'Empty query' unless $query;
189 18         17 my @clause = ();
190 18         70 my @tokens = $lexer->analyze($query);
191 17         12963 my ($in_reduce, $negated, $in_or) = (0, 0);
192 17         32 $values{query} = [];
193             TOKEN:
194 17         44 while (my $name = shift @tokens) {
195 112         84 my $text = shift @tokens;
196 112 100       156 if ($name eq 'EOI') {
197             # We must be done.
198 17 100 66     69 if (@clause and $in_reduce) {
    50          
199 6         13 $values{reduce} = [@clause];
200             } elsif (@clause) {
201 11         6 push @{$values{query}}, [@clause];
  11         30  
202             }
203              
204 17         46 last TOKEN;
205             }
206 95 100       115 next TOKEN if ($name eq 'COMMA'); # They can put commas anywhere, we don't care.
207 93         63 my $expected_length = 2;
208 93 100       185 if ($name eq 'QUOTED_STRING') {
    100          
    100          
    100          
    100          
    50          
209 56 100       88 my $value = ($negated) ? $negation . substr($text, 1, -1) : substr($text, 1, -1);
210 56 100       51 if ($in_or) {
211 4 50       13 $clause[-1] = [$clause[-1]] unless (reftype($clause[-1]));
212 4         4 push @{$clause[-1]}, $value;
  4         12  
213             } else {
214 52         50 push @clause, $value;
215             }
216 56         110 ($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         5 push @{$values{query}}, [@clause];
  5         8  
220 5         10 @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         8 $in_or = 1;
224             } elsif ($name eq 'EQUAL') {
225 17 50 33     86 croak $parse_err->($text) if ($in_reduce or scalar @clause != $expected_length - 1);
226             } elsif ($name eq 'NOTEQUAL') {
227 5 50 33     24 croak $parse_err->($text) if ($in_reduce or scalar @clause != $expected_length - 1);
228 5         11 $negated = 1;
229             } elsif ($name eq 'REDUCE') {
230 6 50       7 croak $parse_err->($text) if ($in_reduce);
231 6         6 $in_reduce = 1;
232 6 50       9 push @{$values{query}}, [@clause] if (@clause);
  6         14  
233 6         13 @clause = ();
234             }
235             }
236             }
237             catch {
238 2     2   395 $err = $_;
239 19         115 };
240              
241 19         412 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 2206 my $self = shift;
253 1         2 return (sort { $a cmp $b } keys %{$self->index_sets});
  5         10  
  1         6  
254             }
255              
256             sub values_for_key {
257 3     3 1 4 my ($self, $key) = @_;
258              
259 3         9 my $set = $self->index_sets->{$key};
260 3 100       8 my @values = grep { my $v = $set->{$_}; $v && %{$v->{elements}} } keys %$set;
  8         15  
  8         14  
  7         61  
261              
262 3         11 return (sort { $a cmp $b } @values);
  4         14  
263             }
264              
265             1;
266             __END__