File Coverage

blib/lib/Genealogy/Relationship.pm
Criterion Covered Total %
statement 63 67 94.0
branch 22 26 84.6
condition n/a
subroutine 12 12 100.0
pod 6 6 100.0
total 103 111 92.7


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 should be 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             =head2 Caching
77              
78             Calculating relationship names isn't at all different. But there can be a lot
79             of (simple and repetitive) work involved. This is particularly true if your
80             objects are based on database tables (as I found to my expense).
81              
82             If you're calculating a lot of relationships, then you should probably
83             consider putting a caching layer in front of C.
84              
85             =cut
86              
87             package Genealogy::Relationship;
88              
89 3     3   213462 use Moo;
  3         33562  
  3         13  
90 3     3   5936 use Types::Standard qw[Str HashRef];
  3         229033  
  3         35  
91 3     3   2900 use List::Util qw[first];
  3         9  
  3         326  
92 3     3   1637 use List::MoreUtils qw[firstidx];
  3         30223  
  3         24  
93 3     3   4637 use Lingua::EN::Numbers qw[num2en num2en_ordinal];
  3         6163  
  3         3076  
94              
95             our $VERSION = '0.1.2';
96              
97             has parent_field_name => (
98             is => 'ro',
99             isa => Str,
100             default => 'parent',
101             );
102              
103             has identifier_field_name => (
104             is => 'ro',
105             isa => Str,
106             default => 'id',
107             );
108              
109             has gender_field_name => (
110             is => 'ro',
111             isa => Str,
112             default => 'gender',
113             );
114              
115             has relationship_table => (
116             is => 'ro',
117             isa => HashRef,
118             builder => '_build_relationship_table',
119             );
120              
121             sub _build_relationship_table {
122             return {
123 3     3   19829 m => [
124             [ undef, 'Father', 'Grandfather', 'Great grandfather', 'Great, great grandfather', 'Great, great, great grandfather' ],
125             ['Son', 'Brother', 'Uncle', 'Great uncle', 'Great, great uncle', 'Great, great, great uncle' ],
126             ['Grandson', 'Nephew', 'First cousin', 'First cousin once removed', 'First cousin twice removed', 'First cousin three times removed' ],
127             ['Great grandson', 'Great nephew', 'First cousin once removed', 'Second cousin', 'Second cousin once removed', 'Second cousin twice removed' ],
128             ['Great, great grandson', 'Great, great nephew', 'First cousin twice removed', 'Second cousin once removed', 'Third cousin', 'Third cousin once removed' ],
129             ['Great, great, great grandson', 'Great, great, great nephew', 'First cousin three times removed', 'Second cousin twice removed', 'Third cousin once removed', 'Fourth cousin' ],
130             ],
131             f => [
132             [ undef, 'Mother', 'Grandmother', 'Great grandmother', 'Great, great grandmother', 'Great, great great grandmother' ],
133             ['Daughter', 'Sister', 'Aunt', 'Great aunt', 'Great, great aunt', 'Great, great, great aunt' ],
134             ['Granddaughter', 'Niece', 'First cousin', 'First cousin once removed', 'First cousin twice removed', 'First cousin three times removed' ],
135             ['Great granddaughter', 'Great niece', 'First cousin once removed', 'Second cousin', 'Second cousin once removed', 'Second cousin twice removed' ],
136             ['Great, great granddaughter', 'Great, great niece', 'First cousin twice removed', 'Second cousin once removed', 'Third cousin', 'Third cousin once removed' ],
137             ['Great, great, great granddaughter', 'Great, great, great niece', 'First cousin three times removed', 'Second cousin twice removed', 'Third cousin once removed', 'Fourth cousin' ],
138             ],
139             };
140             }
141              
142             =head1 Methods
143              
144             The following methods are defined.
145              
146             =head2 most_recent_common_ancestor
147              
148             Given two person objects, returns the person who is the most recent common
149             ancestor for the given people.
150              
151             =cut
152              
153             sub most_recent_common_ancestor {
154 5     5 1 568 my $self = shift;
155 5         11 my ($person1, $person2) = @_;
156              
157             # Are they the same person?
158 5 100       24 return $person1 if $person1->id == $person2->id;
159              
160 4         10 my @ancestors1 = ($person1, $self->get_ancestors($person1));
161 4         7 my @ancestors2 = ($person2, $self->get_ancestors($person2));
162              
163 4         10 for my $anc1 (@ancestors1) {
164 9         25 for my $anc2 (@ancestors2) {
165 17 100       49 return $anc1 if $anc1->id == $anc2->id;
166             }
167             }
168              
169 1         9 die "Can't find a common ancestor.\n";
170             }
171              
172             =head2 get_ancestors
173              
174             Given a person object, returns a list of person objects, one for each
175             ancestor of the given person.
176              
177             The first person in the list will be the person's parent and the last person
178             will be their most distant ancestor.
179              
180             =cut
181              
182             sub get_ancestors {
183 81     81 1 1520 my $self = shift;
184 81         123 my ($person) = @_;
185              
186 81         126 my @ancestors = ();
187              
188 81         189 while (defined ($person = $person->parent)) {
189 279         551 push @ancestors, $person;
190             }
191              
192 81         193 return @ancestors;
193             }
194              
195             =head2 get_relationship
196              
197             Given two person objects, returns a string containing a description of the
198             relationship between those two people.
199              
200             =cut
201              
202             sub get_relationship {
203 30     30 1 5643 my $self = shift;
204 30         58 my ($person1, $person2) = @_;
205              
206 30         75 my ($x, $y) = $self->get_relationship_coords($person1, $person2);
207              
208 30 100       134 if (defined $self->relationship_table->{$person1->gender}[$x][$y]) {
209 17         86 return $self->relationship_table->{$person1->gender}[$x][$y];
210             } else {
211 13         37 return $self->relationship_table->{$person1->gender}[$x][$y] =
212             ucfirst $self->make_rel($person1->gender, $x, $y);
213             }
214             }
215              
216             =head2 make_rel
217              
218             Given relationship co-ords and a gender, this will synthesise a relationship
219             description. This only works because we've hard-coded an initial relationship
220             table that covers all of the trickier situations.
221              
222             =cut
223              
224             sub make_rel {
225 13     13 1 20 my $self = shift;
226 13         25 my ($gender, $x, $y) = @_;
227              
228 13         68 my %terms = (
229             m => {
230             child => 'son',
231             parent => 'father',
232             parent_sibling => 'uncle',
233             parent_sibling_child => 'nephew',
234             },
235             f => {
236             child => 'daughter',
237             parent => 'mother',
238             parent_sibling => 'aunt',
239             parent_sibling_child => 'niece',
240             },
241             );
242              
243 13 100       34 if ($x == $y) {
244 5         14 return num2en_ordinal($x - 1) . ' cousin';
245             }
246              
247 8 50       16 if ($x == 0) {
248 0         0 return join(', ', ('great') x ($y - 2)) . ' grand' . $terms{$gender}{parent};
249             }
250              
251 8 100       18 if ($x == 1) {
252 5         57 return join(', ', ('great') x ($y - 2)) . ' ' . $terms{$gender}{parent_sibling};
253             }
254              
255 3 50       12 if ($y == 0) {
256 0         0 return join(', ', ('great') x ($x - 2)) . ' grand' . $terms{$gender}{child};
257             }
258              
259 3 50       7 if ($y == 1) {
260 0         0 return join(', ', ('great') x ($x - 2)) . ' ' . $terms{$gender}{parent_sibling_child};
261             }
262              
263 3 100       7 if ($x > $y) {
264 1         5 return num2en_ordinal($y - 1) . ' cousin ' . times_str($x - $y) . ' removed';
265             } else {
266 2         9 return num2en_ordinal($x - 1) . ' cousin ' . times_str($y - $x) . ' removed';
267             }
268              
269 0         0 return 'working on it';
270             }
271              
272             =head2 times_str
273              
274             Given an integer, this method returns a string version for use in a
275             "removed" cousin relationship, i.e. "once", "twice", "three times", etc.
276              
277             =cut
278              
279             sub times_str {
280 3     3 1 121 my ($num) = @_;
281              
282 3 50       8 return 'once' if $num == 1;
283 3 100       21 return 'twice' if $num == 2;
284              
285 2         6 return num2en($num) . ' times';
286             }
287              
288             =head2 get_relationship_coords
289              
290             Given two person objects, returns the "co-ordinates" of the relationship
291             between them.
292              
293             The relationship co-ordinates are a pair of integers. The first integer is
294             the number of generations between the first person and their most recent
295             common ancestor. The second integer is the number of generations between
296             the second person and their most recent common ancestor.
297              
298             =cut
299              
300             sub get_relationship_coords {
301 36     36 1 90 my $self = shift;
302 36         67 my ($person1, $person2) = @_;
303              
304             # If the two people are the same person, then return (0, 0).
305 36 100       135 return (0, 0) if $person1->id == $person2->id;
306              
307 35         78 my @ancestors1 = ($person1, $self->get_ancestors($person1));
308 35         111 my @ancestors2 = ($person2, $self->get_ancestors($person2));
309              
310 35         85 for my $i (0 .. $#ancestors1) {
311 143         227 for my $j (0 .. $#ancestors2) {
312 979 100       2068 return ($i, $j) if $ancestors1[$i]->id == $ancestors2[$j]->id;
313             }
314             }
315              
316 1         14 die "Can't work out the relationship.\n";
317             }
318              
319             =head1 AUTHOR
320              
321             Dave Cross
322              
323             =head1 SEE ALSO
324              
325             perl(1)
326              
327             =head1 COPYRIGHT AND LICENSE
328              
329             Copyright (C) 2018-2023, Magnum Solutions Ltd. All Rights Reserved.
330              
331             This script is free software; you can redistribute it and/or modify it
332             under the same terms as Perl itself.
333              
334             =cut
335              
336             1;