File Coverage

blib/lib/Genealogy/Relationship.pm
Criterion Covered Total %
statement 85 89 95.5
branch 32 38 84.2
condition n/a
subroutine 14 14 100.0
pod 8 8 100.0
total 139 149 93.2


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             Genealogy::Relationship - calculate the relationship between two people
4              
5             =head1 SYNOPSIS
6              
7             use Genealogy::Relationship;
8             use Person; # Imaginary class modelling people
9              
10             my $rel = Genealogy::Relationship->new;
11              
12             my $grandfather = Person->new( ... );
13             my $father = Person->new( ... );
14             my $me = Person->new( ... );
15             my $aunt = Person->new( ... );
16             my $cousin = Person->new( ... );
17              
18             my $common_ancestor = $rel->get_most_recent_common_ancestor(
19             $me, $cousin,
20             );
21             say $common_ancestor->name; # Grandfather's name
22              
23             say $rel->get_relationship($me, $grandfather); # Grandson
24             say $rel->get_relationship($grandfather, $me); # Grandfather
25              
26             say $rel->get_relationship($father, $cousin); # Uncle
27             say $rel->get_relationship($cousin, $father); # Niece
28              
29             =head1 DESCRIPTION
30              
31             This module makes it easy to calculate the relationship between two people.
32              
33             If you have a set of objects modelling your family tree, then you will
34             be able to use this module to get a description of the relationship between
35             any two people on that tree.
36              
37             The objects that you use with this module need to implement three methods:
38              
39             =over 4
40              
41             =item * parent
42              
43             This method should return the object which is the parent of the current
44             person.
45              
46             =item * id
47              
48             This method should return a unique identifier for the current person.
49             The identifier can be a string or a number.
50              
51             =item * gender
52              
53             This method should return the gender of the current person. It should be
54             the character 'm' or 'f'.
55              
56             =back
57              
58             =head2 Limitations
59              
60             This module was born out of a need I had while creating
61             L. This leads to a limitation
62             that I hope to remove at a later date.
63              
64             =over 4
65              
66             =item *
67              
68             Each person in the tree is expected to have only one parent. This is, of
69             course, about half of the usual number. It's like that because for the line
70             of succession I'm tracing bloodlines and only one parent is ever going to
71             be significant.
72              
73             I realise that this is a significant limitation and I'll be thinking about
74             how to fix it as soon as possible.
75              
76             =back
77              
78             =head2 Constructor
79              
80             The constructor for this class takes one optional attribute called `abbr`.
81             The default value for this attribute is 2. When set, strings of repeated
82             "great"s in a relationship description will collapsed to "$n x great".
83              
84             For example, if the description you have is "Great, great, great
85             grandfather", then that will be abbreviated to to "3 x great grandfather".
86              
87             The value for `abbr` is the maximum number of repetitions that will be left
88             untouched. You can turn abbreviations off by setting `abbr` to zero.
89              
90             =head2 Caching
91              
92             Calculating relationship names isn't at all different. But there can be a lot
93             of (simple and repetitive) work involved. This is particularly true if your
94             objects are based on database tables (as I found to my expense).
95              
96             If you're calculating a lot of relationships, then you should probably
97             consider putting a caching layer in front of C.
98              
99             =cut
100              
101             package Genealogy::Relationship;
102              
103 3     3   210856 use Moo;
  3         34019  
  3         13  
104 3     3   5947 use Types::Standard qw[Int Str HashRef];
  3         227266  
  3         38  
105 3     3   3212 use List::Util qw[first];
  3         8  
  3         328  
106 3     3   1634 use List::MoreUtils qw[firstidx];
  3         29841  
  3         24  
107 3     3   4613 use Lingua::EN::Numbers qw[num2en num2en_ordinal];
  3         6103  
  3         4128  
108              
109             our $VERSION = '0.2.0';
110              
111             has parent_field_name => (
112             is => 'ro',
113             isa => Str,
114             default => 'parent',
115             );
116              
117             has identifier_field_name => (
118             is => 'ro',
119             isa => Str,
120             default => 'id',
121             );
122              
123             has gender_field_name => (
124             is => 'ro',
125             isa => Str,
126             default => 'gender',
127             );
128              
129             has relationship_table => (
130             is => 'ro',
131             isa => HashRef,
132             builder => '_build_relationship_table',
133             );
134              
135             sub _build_relationship_table {
136             return {
137 5     5   21749 m => [
138             [ undef, 'Father', 'Grandfather', 'Great grandfather', 'Great, great grandfather', 'Great, great, great grandfather' ],
139             ['Son', 'Brother', 'Uncle', 'Great uncle', 'Great, great uncle', 'Great, great, great uncle' ],
140             ['Grandson', 'Nephew', 'First cousin', 'First cousin once removed', 'First cousin twice removed', 'First cousin three times removed' ],
141             ['Great grandson', 'Great nephew', 'First cousin once removed', 'Second cousin', 'Second cousin once removed', 'Second cousin twice removed' ],
142             ['Great, great grandson', 'Great, great nephew', 'First cousin twice removed', 'Second cousin once removed', 'Third cousin', 'Third cousin once removed' ],
143             ['Great, great, great grandson', 'Great, great, great nephew', 'First cousin three times removed', 'Second cousin twice removed', 'Third cousin once removed', 'Fourth cousin' ],
144             ],
145             f => [
146             [ undef, 'Mother', 'Grandmother', 'Great grandmother', 'Great, great grandmother', 'Great, great great grandmother' ],
147             ['Daughter', 'Sister', 'Aunt', 'Great aunt', 'Great, great aunt', 'Great, great, great aunt' ],
148             ['Granddaughter', 'Niece', 'First cousin', 'First cousin once removed', 'First cousin twice removed', 'First cousin three times removed' ],
149             ['Great granddaughter', 'Great niece', 'First cousin once removed', 'Second cousin', 'Second cousin once removed', 'Second cousin twice removed' ],
150             ['Great, great granddaughter', 'Great, great niece', 'First cousin twice removed', 'Second cousin once removed', 'Third cousin', 'Third cousin once removed' ],
151             ['Great, great, great granddaughter', 'Great, great, great niece', 'First cousin three times removed', 'Second cousin twice removed', 'Third cousin once removed', 'Fourth cousin' ],
152             ],
153             };
154             }
155              
156             has abbr => (
157             is => 'ro',
158             isa => Int,
159             default => 3,
160             );
161              
162             =head1 Methods
163              
164             The following methods are defined.
165              
166             =head2 most_recent_common_ancestor
167              
168             Given two person objects, returns the person who is the most recent common
169             ancestor for the given people.
170              
171             =cut
172              
173             sub most_recent_common_ancestor {
174 7     7 1 2124 my $self = shift;
175 7         16 my ($person1, $person2) = @_;
176              
177             # Are they the same person?
178 7 100       37 return $person1 if $person1->id eq $person2->id;
179              
180 6         15 my @ancestors1 = ($person1, $self->get_ancestors($person1));
181 6         15 my @ancestors2 = ($person2, $self->get_ancestors($person2));
182              
183 6         23 for my $anc1 (@ancestors1) {
184 13         20 for my $anc2 (@ancestors2) {
185 29 100       84 return $anc1 if $anc1->id eq $anc2->id;
186             }
187             }
188              
189 1         9 die "Can't find a common ancestor.\n";
190             }
191              
192             =head2 get_ancestors
193              
194             Given a person object, returns a list of person objects, one for each
195             ancestor of the given person.
196              
197             The first person in the list will be the person's parent and the last person
198             will be their most distant ancestor.
199              
200             =cut
201              
202             sub get_ancestors {
203 97     97 1 1546 my $self = shift;
204 97         156 my ($person) = @_;
205              
206 97         143 my @ancestors = ();
207              
208 97         239 while (defined ($person = $person->parent)) {
209 316         617 push @ancestors, $person;
210             }
211              
212 97         242 return @ancestors;
213             }
214              
215             =head2 get_relationship
216              
217             Given two person objects, returns a string containing a description of the
218             relationship between those two people.
219              
220             =cut
221              
222             sub get_relationship {
223 35     35 1 5374 my $self = shift;
224 35         69 my ($person1, $person2) = @_;
225              
226 35         88 my ($x, $y) = $self->get_relationship_coords($person1, $person2);
227              
228 35         57 my $rel;
229              
230 35 100       129 if (defined $self->relationship_table->{$person1->gender}[$x][$y]) {
231 21         55 $rel = $self->relationship_table->{$person1->gender}[$x][$y];
232             } else {
233 14         38 $rel = $self->relationship_table->{$person1->gender}[$x][$y] =
234             ucfirst $self->make_rel($person1->gender, $x, $y);
235             }
236              
237 35 100       352 $rel = $self->abbr_rel($rel) if $self->abbr;
238              
239 35         148 return $rel;
240             }
241              
242             =head2 abbr_rel
243              
244             Optionally abbreviate a relationship description.
245              
246             =cut
247              
248             sub abbr_rel {
249 32     32 1 49 my $self = shift;
250 32         57 my ($rel) = @_;
251              
252 32 50       76 return $rel unless $self->abbr;
253              
254 32         136 my @greats = $rel =~ /(great)/gi;
255 32         54 my $count = @greats;
256              
257 32 100       110 return $rel if $count < $self->abbr;
258              
259 7         55 $rel =~ s/(great,\s+)+/$count x /i;
260              
261 7         21 return $rel;
262             }
263              
264             =head2 make_rel
265              
266             Given relationship co-ords and a gender, this will synthesise a relationship
267             description. This only works because we've hard-coded an initial relationship
268             table that covers all of the trickier situations.
269              
270             =cut
271              
272             sub make_rel {
273 14     14 1 22 my $self = shift;
274 14         30 my ($gender, $x, $y) = @_;
275              
276 14         107 my %terms = (
277             m => {
278             child => 'son',
279             parent => 'father',
280             parent_sibling => 'uncle',
281             parent_sibling_child => 'nephew',
282             },
283             f => {
284             child => 'daughter',
285             parent => 'mother',
286             parent_sibling => 'aunt',
287             parent_sibling_child => 'niece',
288             },
289             );
290              
291 14 100       37 if ($x == $y) {
292 5         20 return num2en_ordinal($x - 1) . ' cousin';
293             }
294              
295 9 50       21 if ($x == 0) {
296 0         0 return join(', ', ('great') x ($y - 2)) . ' grand' . $terms{$gender}{parent};
297             }
298              
299 9 100       20 if ($x == 1) {
300 6         54 return join(', ', ('great') x ($y - 2)) . ' ' . $terms{$gender}{parent_sibling};
301             }
302              
303 3 50       7 if ($y == 0) {
304 0         0 return join(', ', ('great') x ($x - 2)) . ' grand' . $terms{$gender}{child};
305             }
306              
307 3 50       7 if ($y == 1) {
308 0         0 return join(', ', ('great') x ($x - 2)) . ' ' . $terms{$gender}{parent_sibling_child};
309             }
310              
311 3 100       9 if ($x > $y) {
312 1         7 return num2en_ordinal($y - 1) . ' cousin ' . times_str($x - $y) . ' removed';
313             } else {
314 2         8 return num2en_ordinal($x - 1) . ' cousin ' . times_str($y - $x) . ' removed';
315             }
316              
317 0         0 return 'working on it';
318             }
319              
320             =head2 times_str
321              
322             Given an integer, this method returns a string version for use in a
323             "removed" cousin relationship, i.e. "once", "twice", "three times", etc.
324              
325             =cut
326              
327             sub times_str {
328 3     3 1 108 my ($num) = @_;
329              
330 3 50       8 return 'once' if $num == 1;
331 3 100       14 return 'twice' if $num == 2;
332              
333 2         7 return num2en($num) . ' times';
334             }
335              
336             =head2 get_relationship_coords
337              
338             Given two person objects, returns the "co-ordinates" of the relationship
339             between them.
340              
341             The relationship co-ordinates are a pair of integers. The first integer is
342             the number of generations between the first person and their most recent
343             common ancestor. The second integer is the number of generations between
344             the second person and their most recent common ancestor.
345              
346             =cut
347              
348             sub get_relationship_coords {
349 41     41 1 99 my $self = shift;
350 41         73 my ($person1, $person2) = @_;
351              
352             # If the two people are the same person, then return (0, 0).
353 41 100       170 return (0, 0) if $person1->id eq $person2->id;
354              
355 40         91 my @ancestors1 = ($person1, $self->get_ancestors($person1));
356 40         72 my @ancestors2 = ($person2, $self->get_ancestors($person2));
357              
358 40         102 for my $i (0 .. $#ancestors1) {
359 153         246 for my $j (0 .. $#ancestors2) {
360 1035 100       2205 return ($i, $j) if $ancestors1[$i]->id eq $ancestors2[$j]->id;
361             }
362             }
363              
364 1         8 die "Can't work out the relationship.\n";
365             }
366              
367             =head2 get_relationship_ancestors
368              
369             Given two people, returns lists of people linking those two people
370             to their most recent common ancestor.
371              
372             The return value is a reference to an array containing two array
373             references. The first references array contains the person1 and
374             all their ancestors up to an including the most recent common
375             ancestor. The second list does the same for person2.
376              
377             =cut
378              
379             sub get_relationship_ancestors {
380 1     1 1 2 my $self = shift;
381 1         3 my ($person1, $person2) = @_;
382              
383 1 50       4 my $mrca = $self->most_recent_common_ancestor($person1, $person2)
384             or die "There is no most recent common ancestor\n";
385              
386 1         3 my (@ancestors1, @ancestors2);
387              
388 1         4 for ($person1, $self->get_ancestors($person1)) {
389 2         5 push @ancestors1, $_;
390 2 100       7 last if $_->id eq $mrca->id;
391             }
392              
393 1         4 for ($person2, $self->get_ancestors($person2)) {
394 3         5 push @ancestors2, $_;
395 3 100       10 last if $_->id eq $mrca->id;
396             }
397              
398 1         4 return [ \@ancestors1, \@ancestors2 ];
399             }
400              
401             =head1 AUTHOR
402              
403             Dave Cross
404              
405             =head1 SEE ALSO
406              
407             perl(1)
408              
409             =head1 COPYRIGHT AND LICENSE
410              
411             Copyright (C) 2018-2023, Magnum Solutions Ltd. All Rights Reserved.
412              
413             This script is free software; you can redistribute it and/or modify it
414             under the same terms as Perl itself.
415              
416             =cut
417              
418             1;