File Coverage

blib/lib/DBIx/DataModel/Meta/Association.pm
Criterion Covered Total %
statement 113 115 98.2
branch 33 48 68.7
condition 11 30 36.6
subroutine 19 19 100.0
pod 1 1 100.0
total 177 213 83.1


line stmt bran cond sub pod time code
1             package DBIx::DataModel::Meta::Association;
2 14     14   6944 use strict;
  14         28  
  14         358  
3 14     14   64 use warnings;
  14         25  
  14         326  
4 14     14   65 use parent "DBIx::DataModel::Meta";
  14         22  
  14         56  
5 14     14   680 use DBIx::DataModel;
  14         28  
  14         53  
6 14     14   72 use DBIx::DataModel::Meta::Utils qw/define_method define_readonly_accessors/;
  14         20  
  14         753  
7              
8 14     14   78 use Carp::Clan qw[^(DBIx::DataModel::|SQL::Abstract)];
  14         39  
  14         81  
9 14     14   1171 use Params::Validate qw/validate_with SCALAR ARRAYREF HASHREF OBJECT UNDEF/;
  14         29  
  14         887  
10 14     14   81 use List::MoreUtils qw/pairwise/;
  14         24  
  14         83  
11 14     14   8078 use Scalar::Util qw/weaken dualvar looks_like_number/;
  14         34  
  14         813  
12 14     14   74 use Module::Load qw/load/;
  14         25  
  14         103  
13 14     14   4985 use POSIX qw/LONG_MAX/;
  14         63971  
  14         69  
14 14     14   12768 use namespace::clean;
  14         25  
  14         87  
15              
16              
17             # specification for parameters to new()
18             my $association_spec = {
19             schema => {type => OBJECT, isa => "DBIx::DataModel::Meta::Schema"},
20             A => {type => HASHREF},
21             B => {type => HASHREF},
22             name => {type => SCALAR, optional => 1}, # computed if absent
23             kind => {type => SCALAR,
24             regex => qr/^(Association|Aggregation|Composition)$/},
25             };
26              
27             # specification for sub-parameters 'A' and 'B'
28             my $association_end_spec = {
29             table => {type => OBJECT,
30             isa => 'DBIx::DataModel::Meta::Source::Table'},
31             role => {type => SCALAR|UNDEF, optional => 1},
32             multiplicity => {type => SCALAR|ARRAYREF}, # if scalar : "$min..$max"
33             join_cols => {type => ARRAYREF, optional => 1},
34             };
35              
36             #----------------------------------------------------------------------
37             # PUBLIC METHODS
38             #----------------------------------------------------------------------
39              
40             sub new {
41 30     30 1 84 my $class = shift;
42              
43 30         606 my $self = validate_with(
44             params => \@_,
45             spec => $association_spec,
46             allow_extra => 0,
47             );
48              
49             # work on both association ends (A and B)
50 30         639 for my $letter (qw/A B/) {
51             # parse parameters for this association end
52 60         92 my @letter_params = %{$self->{$letter}};
  60         214  
53 60         1017 my $assoc_end = validate_with(
54             params => \@letter_params,
55             spec => $association_end_spec,
56             allow_extra => 0,
57             );
58              
59             croak "join_cols is present but empty"
60 60 50 66     293 if $assoc_end->{join_cols} && !@{$assoc_end->{join_cols}};
  12         51  
61              
62             # transform multiplicity scalar into a pair [$min, $max]
63 60         154 $class->_parse_multiplicity($assoc_end);
64              
65 60         150 $self->{$letter} = $assoc_end;
66             }
67              
68             # set default association name
69 30 100       60 my @names = map {$self->{$_}{role} || $self->{$_}{table}{name}} qw/A B/;
  60         221  
70 30   33     168 $self->{name} ||= join "_", @names;
71              
72             # if many-to-many, needs special treatment
73 30         44 my $install_method;
74 30 100 100     115 if ($self->{A}{multiplicity}[1] > 1 && $self->{B}{multiplicity}[1] > 1) {
75 2         6 $install_method = '_install_many_to_many';
76             }
77              
78             # otherwise, treat as a regular association
79             else {
80 28         44 $install_method = '_install_path';
81              
82             # handle implicit column names
83 28 100       80 if ($self->{A}{multiplicity}[1] > 1) { # n-to-1
    100          
84 2   33     6 $self->{B}{join_cols} ||= $self->{B}{table}{primary_key};
85 2   33     4 $self->{A}{join_cols} ||= $self->{B}{join_cols};
86             }
87             elsif ($self->{B}{multiplicity}[1] > 1) { # 1-to-n
88 24   33     113 $self->{A}{join_cols} ||= $self->{A}{table}{primary_key};
89 24   33     97 $self->{B}{join_cols} ||= $self->{A}{join_cols};
90             }
91              
92             # check if we have the same number of columns on both sides
93 28 50       35 @{$self->{A}{join_cols}} == @{$self->{B}{join_cols}}
  28         46  
  28         72  
94             or croak "Association: numbers of columns do not match";
95             }
96              
97             # instantiate
98 30         54 bless $self, $class;
99              
100             # special checks for compositions
101 30 100       194 $self->_check_composition if $self->{kind} eq 'Composition';
102              
103             # install methods from A to B and B to A, if role names are not empty
104             $self->{A}{role} || $self->{B}{role}
105 30 0 33     73 or croak "at least one side of the association must have a role name";
106 30 100       118 $self->$install_method(qw/A B/) if $self->{B}{role};
107 30 50       126 $self->$install_method(qw/B A/) if $self->{A}{role};
108              
109             # EXPERIMENTAL : no longer need association ends; all info is stored in Paths
110 30         45 delete@{$self}{qw/A B/};
  30         115  
111              
112             # avoid circular reference
113 30         92 weaken $self->{schema};
114              
115 30         81 return $self;
116             }
117              
118              
119             # accessor methods
120             define_readonly_accessors(__PACKAGE__, qw/schema name kind path_AB path_BA/);
121              
122              
123             #----------------------------------------------------------------------
124             # PRIVATE UTILITY METHODS
125             #----------------------------------------------------------------------
126              
127             sub _parse_multiplicity {
128 60     60   111 my ($class, $assoc_end) = @_;
129              
130             # nothing to do if already an arrayref
131 60 50       113 return if ref $assoc_end->{multiplicity};
132              
133             # otherwise, parse the scalar
134 60 50       288 $assoc_end->{multiplicity} =~ /^(?: # optional part
135             (\d+) # minimum
136             \s*\.\.\s* # followed by ".."
137             )? # end of optional part
138             (\d+|\*|n) # maximum
139             $/x
140             or croak "illegal multiplicity : $assoc_end->{multiplicity}";
141              
142             # multiplicity '*' is a shortcut for '0..*', and
143             # multiplicity '1' is a shortcut for '1..1'.
144 60         222 my $max_is_star = !looks_like_number($2);
145 60 100       177 my $min = defined $1 ? $1 : ($max_is_star ? 0 : $2);
    100          
146 60 100       152 my $max = $max_is_star ? dualvar(POSIX::LONG_MAX, '*') : $2;
147 60         171 $assoc_end->{multiplicity} = [$min, $max];
148             }
149              
150              
151             sub _install_many_to_many {
152 4     4   10 my ($self, $from, $to) = @_;
153              
154             # path must contain exactly 2 items (intermediate table + remote table)
155 4         10 my $role = $self->{$to}{role};
156 4         7 my @path = @{$self->{$to}{join_cols}};
  4         10  
157 4 50       11 @path == 2
158             or croak "many-to-many : should have exactly 2 roles";
159              
160             # define the method
161 4         17 $self->{$from}{table}->define_navigation_method($role, @path);
162             }
163              
164              
165             sub _install_path {
166 54     54   101 my ($self, $from, $to) = @_;
167              
168             # build the "ON" condition for SQL::Abstract::More
169 54         96 my $from_cols = $self->{$from}{join_cols};
170 54         71 my $to_cols = $self->{$to} {join_cols};
171 54     54   456 my %condition = pairwise {$a => $b} @$from_cols, @$to_cols;
  54         222  
172              
173             # define path
174 54         209 my $path_metaclass = $self->{schema}{path_metaclass};
175 54         179 load $path_metaclass;
176 54         2254 my $path_name = $self->{$to}{role};
177             $self->{"path_$from$to"} = $path_metaclass->new(
178             name => $path_name,
179             from => $self->{$from}{table},
180             to => $self->{$to}{table},
181             on => \%condition,
182             multiplicity => $self->{$to}{multiplicity},
183 54         224 association => $self,
184             direction => "$from$to",
185             );
186              
187             # if 1-to-many, define insertion method
188 54 100       183 if ($self->{$to}{multiplicity}[1] > 1) {
189              
190             # build method parts
191 26         114 my $method_name = "insert_into_$path_name";
192 26         50 my $to_table_name = $self->{$to}{table}{name};
193             my $method_body = sub {
194 6     6   17031 my $source = shift; # remaining @_ contains refs to records for insert()
195 6 50       19 ref($source) or croak "$method_name cannot be called as class method";
196              
197             # add join information into records that will be inserted
198 6         13 foreach my $record (@_) {
199              
200             # if this is a scalar, it's no longer a record, but an arg to insert()
201 12 100       24 last if !ref $record; # since args are at the end, we exit the loop
202              
203             # check that we won't overwrite existing data
204 10 50       18 not (grep {exists $record->{$_}} @$to_cols) or
  10         31  
205             croak "args to $method_name should not contain values in @$to_cols";
206              
207             # shallow copy and insert values for the join
208 10         36 $record = {%$record};
209 10         15 @{$record}{@$to_cols} = @{$source}{@$from_cols};
  10         21  
  10         16  
210             }
211              
212 6         17 return $source->schema->table($to_table_name)->insert(@_);
213 26         108 };
214              
215             # define the method
216             define_method(
217             class => $self->{$from}{table}{class},
218 26         128 name => $method_name,
219             body => $method_body,
220             );
221             }
222             }
223              
224             sub _check_composition {
225 13     13   38 my $self = shift;
226              
227             # multiplicities must be 1-to-n
228 13 50       65 $self->{A}{multiplicity}[1] == 1
229             or croak "max multiplicity of first class in a composition must be 1";
230 13 50       48 $self->{B}{multiplicity}[1] > 1
231             or croak "max multiplicity of second class in a composition must be > 1";
232              
233             # check for conflicting compositions
234 13 50       23 while (my ($name, $path) = each %{$self->{B}{table}{path} || {}}) {
  13         102  
235 0 0 0     0 if ($path->association->kind eq 'Composition' && $path->direction eq 'BA'
      0        
236             && ($path->multiplicity)[0] > 0) {
237 0         0 croak "$self->{B}{table}{name} can't be a component "
238             . "of $self->{A}{table}{name} "
239             . "(already component of $path->{to}{name})";
240             }
241             }
242             }
243              
244              
245             1;
246              
247             __END__