Remove commented code, spurious deps.
[dbsrgits/DBIx-Class-ResultSource-MultipleTableInheritance.git] / lib / DBIx / Class / ResultSource / MultipleTableInheritance.pm
CommitLineData
876f6525 1package DBIx::Class::ResultSource::MultipleTableInheritance;
2
3use strict;
4use warnings;
5use parent qw(DBIx::Class::ResultSource::View);
876f6525 6use Method::Signatures::Simple;
7use Carp::Clan qw/^DBIx::Class/;
ca79850d 8use aliased 'DBIx::Class::ResultSource::Table';
7abe3af2 9use aliased 'DBIx::Class::ResultClass::HashRefInflator';
05fd2477 10use String::TT qw(strip tt);
92ebfc06 11use Scalar::Util qw(blessed);
ca79850d 12use namespace::autoclean;
70d56286 13
146ec120 14our $VERSION = 0.01;
70d56286 15
803ffff2 16__PACKAGE__->mk_group_accessors(simple => qw(parent_source additional_parents));
876f6525 17
e7189506 18# how this works:
19#
20# On construction, we hook $self->result_class->result_source_instance
21# if present to get the superclass' source object
5fa55fff 22#
e7189506 23# When attached to a schema, we need to add sources to that schema with
24# appropriate relationships for the foreign keys so the concrete tables
25# get generated
26#
27# We also generate our own view definition using this class' concrete table
28# and the view for the superclass, and stored procedures for the insert,
29# update and delete operations on this view.
30#
31# deploying the postgres rules through SQLT may be a pain though.
32
876f6525 33method new ($class: @args) {
34 my $new = $class->next::method(@args);
35 my $rc = $new->result_class;
36 if (my $meth = $rc->can('result_source_instance')) {
7abe3af2 37 my $source = $rc->$meth;
38 if ($source->result_class ne $new->result_class
39 && $new->result_class->isa($source->result_class)) {
40 $new->parent_source($source);
41 }
876f6525 42 }
43 return $new;
44}
45
4e4f71e3 46method add_additional_parents (@classes) {
47 foreach my $class (@classes) {
48 Class::C3::Componentised->ensure_class_loaded($class);
49 $self->add_additional_parent(
50 $class->result_source_instance
51 );
52 }
53}
54
803ffff2 55method add_additional_parent ($source) {
56 my ($our_pk, $their_pk) = map {
57 join('|',sort $_->primary_columns)
58 } ($self, $source);
59
60 confess "Can't attach additional parent ${\$source->name} - it has different PKs ($their_pk versus our $our_pk)"
61 unless $their_pk eq $our_pk;
62 $self->additional_parents([
63 @{$self->additional_parents||[]}, $source
64 ]);
65 $self->add_columns(
66 map {
67 $_ => # put the extra key first to default it
68 { originally_defined_in => $source->name, %{$source->column_info($_)}, },
69 } grep !$self->has_column($_), $source->columns
70 );
71 foreach my $rel ($source->relationships) {
72 my $rel_info = $source->relationship_info($rel);
73 $self->add_relationship(
74 $rel, $rel_info->{source}, $rel_info->{cond},
75 # extra key first to default it
76 {originally_defined_in => $source->name, %{$rel_info->{attrs}}},
77 );
78 }
a010ebf9 79 { no strict 'refs';
80 push(@{$self->result_class.'::ISA'}, $source->result_class);
81 }
803ffff2 82}
83
8b229aa6 84method _source_by_name ($name) {
85 my $schema = $self->schema;
5fa55fff 86 my ($source) =
8b229aa6 87 grep { $_->name eq $name }
88 map $schema->source($_), $schema->sources;
89 confess "Couldn't find attached source for parent $name - did you use load_classes? This module is only compatible with load_namespaces"
90 unless $source;
91 return $source;
92}
93
7abe3af2 94method schema (@args) {
95 my $ret = $self->next::method(@args);
96 if (@args) {
c73d582b 97 if ($self->parent_source) {
c73d582b 98 my $parent_name = $self->parent_source->name;
8b229aa6 99 $self->parent_source($self->_source_by_name($parent_name));
c73d582b 100 }
8b229aa6 101 $self->additional_parents([
102 map { $self->_source_by_name($_->name) }
103 @{$self->additional_parents||[]}
104 ]);
7abe3af2 105 }
106 return $ret;
107}
108
c73d582b 109method attach_additional_sources () {
4d88a8d7 110 my $raw_name = $self->raw_source_name;
ca79850d 111 my $schema = $self->schema;
112
113 # if the raw source is already present we can assume we're done
114 return if grep { $_ eq $raw_name } $schema->sources;
4d88a8d7 115
ca79850d 116 # our parent should've been registered already actually due to DBIC
117 # attaching subclass sources later in load_namespaces
4d88a8d7 118
ca79850d 119 my $parent;
120 if ($self->parent_source) {
121 my $parent_name = $self->parent_source->name;
5fa55fff 122 ($parent) =
ca79850d 123 grep { $_->name eq $parent_name }
124 map $schema->source($_), $schema->sources;
125 confess "Couldn't find attached source for parent $parent_name - did you use load_classes? This module is only compatible with load_namespaces"
126 unless $parent;
05fd2477 127 $self->parent_source($parent); # so our parent is the one in this schema
ca79850d 128 }
4d88a8d7 129
130 # create the raw table source
131
132 my $table = Table->new({ name => $self->raw_table_name });
133
ca79850d 134 # we don't need to add the PK cols explicitly if we're the root table
4d88a8d7 135 # since they'll get added below
136
803ffff2 137 my %pk_join;
138
ca79850d 139 if ($parent) {
ca79850d 140 foreach my $pri ($self->primary_columns) {
141 my %info = %{$self->column_info($pri)};
142 delete @info{qw(is_auto_increment sequence auto_nextval)};
7abe3af2 143 $table->add_column($pri => \%info);
803ffff2 144 $pk_join{"foreign.${pri}"} = "self.${pri}";
ca79850d 145 }
4d88a8d7 146 # have to use source name lookups rather than result class here
147 # because we don't actually have a result class on the raw sources
803ffff2 148 $table->add_relationship('parent', $parent->raw_source_name, \%pk_join);
c8e085ba 149 $self->deploy_depends_on->{$parent->result_class} = 1;
803ffff2 150 }
151
152 foreach my $add (@{$self->additional_parents||[]}) {
153 $table->add_relationship(
154 'parent_'.$add->name, $add->source_name, \%pk_join
155 );
c965b761 156 $self->deploy_depends_on->{$add->result_class} = 1 if $add->isa('DBIx::Class::ResultSource::View');
ca79850d 157 }
4d88a8d7 158 $table->add_columns(
159 map { ($_ => { %{$self->column_info($_)} }) }
160 grep { $self->column_info($_)->{originally_defined_in} eq $self->name }
161 $self->columns
162 );
ca79850d 163 $table->set_primary_key($self->primary_columns);
5fa55fff 164
490d5481 165 # we need to copy our rels to the raw object as well
166 # note that ->add_relationship on a source object doesn't create an
167 # accessor so we can leave that part in the attributes
168
169 # if the other side is a table then we need to copy any rels it has
170 # back to us, as well, so that they point at the raw table. if the
171 # other side is an MTI view then we need to create the rels to it to
172 # point at -its- raw table; we don't need to worry about backrels because
173 # it's going to run this method too (and its raw source might not exist
174 # yet so we can't, anyway)
175
176 foreach my $rel ($self->relationships) {
177 my $rel_info = $self->relationship_info($rel);
178
803ffff2 179 # if we got this from the superclass, -its- raw table will nail this.
180 # if we got it from an additional parent, it's its problem.
181 next unless $rel_info->{attrs}{originally_defined_in} eq $self->name;
182
490d5481 183 my $f_source = $schema->source($rel_info->{source});
184
185 # __PACKAGE__ is correct here because subclasses should be caught
186
187 my $one_of_us = $f_source->isa(__PACKAGE__);
188
189 my $f_source_name = $f_source->${\
190 ($one_of_us ? 'raw_source_name' : 'source_name')
191 };
5fa55fff 192
490d5481 193 $table->add_relationship(
194 '_'.$rel, $f_source_name, @{$rel_info}{qw(cond attrs)}
195 );
196
197 unless ($one_of_us) {
198 my $reverse = do {
199 # we haven't been registered yet, so reverse_ cries
200 # XXX this is evil and will probably break eventually
201 local @{$schema->source_registrations}
202 {map $self->$_, qw(source_name result_class)}
203 = ($self, $self);
204 $self->reverse_relationship_info($rel);
205 };
206 foreach my $rev_rel (keys %$reverse) {
207 $f_source->add_relationship(
208 '_raw_'.$rev_rel, $raw_name, @{$reverse->{$rev_rel}}{qw(cond attrs)}
209 );
210 }
211 }
212 }
213
ca79850d 214 $schema->register_source($raw_name => $table);
215}
216
217method set_primary_key (@args) {
218 if ($self->parent_source) {
219 confess "Can't set primary key on a subclass";
220 }
221 return $self->next::method(@args);
876f6525 222}
223
e96b2eeb 224method set_sequence ($table_name, @pks) {
225 return $table_name . '_' . join('_',@pks) . '_' . 'seq';
226}
227
4d88a8d7 228method raw_source_name () {
876f6525 229 my $base = $self->source_name;
05fd2477 230 confess "Can't generate raw source name for ${\$self->name} when we don't have a source_name"
876f6525 231 unless $base;
232 return 'Raw::'.$base;
233}
70d56286 234
4d88a8d7 235method raw_table_name () {
236 return '_'.$self->name;
237}
238
876f6525 239method add_columns (@args) {
240 my $ret = $self->next::method(@args);
241 $_->{originally_defined_in} ||= $self->name for values %{$self->_columns};
242 return $ret;
70d56286 243}
244
803ffff2 245method add_relationship ($name, $f_source, $cond, $attrs) {
246 $self->next::method(
247 $name, $f_source, $cond,
248 { originally_defined_in => $self->name, %{$attrs||{}}, }
249 );
250}
251
487f4489 252BEGIN {
253
254 # helper routines, constructed as anon subs so autoclean nukes them
255
256 use signatures;
257
258 *argify = sub (@names) {
259 map '_'.$_, @names;
260 };
261
262 *qualify_with = sub ($source, @names) {
92ebfc06 263 my $name = blessed($source) ? $source->name : $source;
264 map join('.', $name, $_), @names;
487f4489 265 };
266
267 *body_cols = sub ($source) {
268 my %pk; @pk{$source->primary_columns} = ();
269 map +{ %{$source->column_info($_)}, name => $_ },
270 grep !exists $pk{$_}, $source->columns;
271 };
272
273 *pk_cols = sub ($source) {
274 map +{ %{$source->column_info($_)}, name => $_ },
275 $source->primary_columns;
276 };
277
92ebfc06 278 *names_of = sub (@cols) { map $_->{name}, @cols };
487f4489 279
c8e085ba 280 *function_body = sub {
281 my ($name,$args,$body_parts) = @_;
05fd2477 282 my $arglist = join(
283 ', ',
388d83fc 284 map "_${\$_->{name}} ${\uc($_->{data_type})}",
05fd2477 285 @$args
286 );
287 my $body = join("\n", '', map " $_;", @$body_parts);
288 return strip tt q{
289 CREATE OR REPLACE FUNCTION [% name %]
290 ([% arglist %])
291 RETURNS VOID AS $function$
292 BEGIN
293 [%- body %]
294 END;
295 $function$ LANGUAGE plpgsql;
296 };
487f4489 297 };
487f4489 298}
299
05fd2477 300BEGIN {
301
302 use signatures;
303
304 *arg_hash = sub ($source) {
305 map +($_ => \(argify $_)), names_of body_cols $source;
306 };
92ebfc06 307
308 *rule_body = sub ($on, $to, $oldlist, $newlist) {
309 my $arglist = join(', ',
310 (qualify_with 'OLD', names_of @$oldlist),
311 (qualify_with 'NEW', names_of @$newlist),
312 );
313 $to = $to->name if blessed($to);
314 return strip tt q{
315 CREATE RULE _[% to %]_[% on %]_rule AS
316 ON [% on | upper %] TO [% to %]
317 DO INSTEAD (
3c259cfb 318 SELECT [% to %]_[% on %]([% arglist %])
92ebfc06 319 );
320 };
321 };
05fd2477 322}
323
324method root_table () {
325 $self->parent_source
326 ? $self->parent_source->root_table
327 : $self->schema->source($self->raw_source_name)
328}
329
487f4489 330method view_definition () {
331 my $schema = $self->schema;
332 confess "Can't generate view without connected schema, sorry"
333 unless $schema && $schema->storage;
334 my $sqla = $schema->storage->sql_maker;
2816c8ed 335 my $table = $self->schema->source($self->raw_source_name);
487f4489 336 my $super_view = $self->parent_source;
2816c8ed 337 my @all_parents = my @other_parents = @{$self->additional_parents||[]};
338 push(@all_parents, $super_view) if defined($super_view);
339 my @sources = ($table, @all_parents);
487f4489 340 my @body_cols = map body_cols($_), @sources;
d8c2caa7 341
342 # Order body_cols to match the columns order.
343 # Must match or you get typecast errors.
344 my %body_cols = map { $_->{name} => $_ } @body_cols;
345 @body_cols =
346 map { $body_cols{$_} }
347 grep { defined $body_cols{$_} }
348 $self->columns;
487f4489 349 my @pk_cols = pk_cols $self;
92ebfc06 350
d8c2caa7 351 # Grab sequence from root table. Only works with one PK named id...
f49b3ff1 352 # TBD: Fix this so it's more flexible.
d8c2caa7 353 for my $pk_col (@pk_cols) {
354 $self->columns_info->{ $pk_col->{name} }->{sequence} =
355 $self->root_table->name . '_id_seq';
356 }
357
92ebfc06 358 # SELECT statement
359
2816c8ed 360 my $am_root = !($super_view || @other_parents);
361
487f4489 362 my $select = $sqla->select(
2816c8ed 363 ($am_root
364 ? ($table->name)
365 : ([ # FROM _tbl _tbl
487f4489 366 { $table->name => $table->name },
2816c8ed 367 map {
368 my $parent = $_;
369 [ # JOIN view view
370 { $parent->name => $parent->name },
371 # ON _tbl.id = view.id
372 { map +(qualify_with($parent, $_), qualify_with($table, $_)),
373 names_of @pk_cols }
374 ]
375 } @all_parents
487f4489 376 ])
2816c8ed 377 ),
487f4489 378 [ (qualify_with $table, names_of @pk_cols), names_of @body_cols ],
05fd2477 379 ).';';
92ebfc06 380
2816c8ed 381 my ($now, @next) = grep defined, $super_view, $table, @other_parents;
92ebfc06 382
383 # INSERT function
384
05fd2477 385 # NOTE: this assumes a single PK col called id with a sequence somewhere
386 # but nothing else -should- so fixing this should make everything work
387 my $insert_func =
c8e085ba 388 function_body
05fd2477 389 $self->name.'_insert',
390 \@body_cols,
391 [
2816c8ed 392 $sqla->insert( # INSERT INTO tbl/super_view (foo, ...) VALUES (_foo, ...)
05fd2477 393 $now->name,
394 { arg_hash $now },
395 ),
2816c8ed 396 (map {
397 $sqla->insert( # INSERT INTO parent (id, ...)
398 # VALUES (currval('_root_tbl_id_seq'), ...)
399 $_->name,
400 {
401 (arg_hash $_),
402 id => \"currval('${\$self->root_table->name}_id_seq')",
403 }
404 )
405 } @next)
05fd2477 406 ];
92ebfc06 407
05fd2477 408 # note - similar to arg_hash but not quite enough to share code sanely
409 my $pk_where = { # id = _id AND id2 = _id2 ...
410 map +($_ => \"= ${\argify $_}"), names_of @pk_cols
411 };
92ebfc06 412
413 # UPDATE function
414
05fd2477 415 my $update_func =
c8e085ba 416 function_body
05fd2477 417 $self->name.'_update',
418 [ @pk_cols, @body_cols ],
419 [ map $sqla->update(
420 $_->name, # UPDATE foo
421 { arg_hash $_ }, # SET a = _a
422 $pk_where,
423 ), @sources
424 ];
92ebfc06 425
426 # DELETE function
427
05fd2477 428 my $delete_func =
c8e085ba 429 function_body
05fd2477 430 $self->name.'_delete',
431 [ @pk_cols ],
432 [ map $sqla->delete($_->name, $pk_where), @sources ];
92ebfc06 433
434 my @rules = (
435 (rule_body insert => $self, [], \@body_cols),
436 (rule_body update => $self, \@pk_cols, \@body_cols),
437 (rule_body delete => $self, \@pk_cols, []),
438 );
439 return join("\n\n", $select, $insert_func, $update_func, $delete_func, @rules);
487f4489 440}
441
70d56286 4421;
146ec120 443
444__END__
f5c54951 445
146ec120 446=head1 NAME
447
f5c54951 448DBIx::Class::ResultSource::MultipleTableInheritance
5fa55fff 449Use multiple tables to define your classes
f5c54951 450
451=head1 NOTICE
452
f49b3ff1 453This only works with PostgreSQL at the moment. It has been tested with
454PostgreSQL 9.0 and 9.1 beta.
455
456There is one additional caveat: the "parent" result classes that you
457defined with this resultsource must have one primary column and it must
458be named "id."
146ec120 459
460=head1 SYNOPSIS
461
146ec120 462 {
f8864134 463 package Cafe::Result::Coffee;
146ec120 464
f8864134 465 use strict;
466 use warnings;
467 use parent 'DBIx::Class::Core';
468 use aliased 'DBIx::Class::ResultSource::MultipleTableInheritance'
469 => 'MTI';
470
471 __PACKAGE__->table_class(MTI);
146ec120 472 __PACKAGE__->table('coffee');
473 __PACKAGE__->add_columns(
f8864134 474 "id", { data_type => "integer" },
475 "flavor", {
476 data_type => "text",
477 default_value => "good" },
146ec120 478 );
479
480 __PACKAGE__->set_primary_key("id");
481
482 1;
483 }
484
485 {
f8864134 486 package Cafe::Result::Sumatra;
146ec120 487
f8864134 488 use parent 'Cafe::Result::Coffee';
146ec120 489
490 __PACKAGE__->table('sumatra');
491
f8864134 492 __PACKAGE__->add_columns( "aroma",
493 { data_type => "text" }
146ec120 494 );
495
496 1;
497 }
5fa55fff 498
146ec120 499 ...
500
f8864134 501 my $schema = Cafe->connect($dsn,$user,$pass);
146ec120 502
f8864134 503 my $cup = $schema->resultset('Sumatra');
146ec120 504
f8864134 505 print STDERR Dwarn $cup->result_source->columns;
146ec120 506
f8864134 507 "id"
508 "flavor"
509 "aroma"
510 ..
146ec120 511
f5c54951 512Inherit from this package and you can make a resultset class from a view, but
513that's more than a little bit misleading: the result is B<transparently
514writable>.
146ec120 515
f5c54951 516This is accomplished through the use of stored procedures that map changes
517written to the view to changes to the underlying concrete tables.
146ec120 518
519=head1 WHY?
520
f5c54951 521In many applications, many classes are subclasses of others. Let's say you
522have this schema:
146ec120 523
524 # Conceptual domain model
5fa55fff 525
146ec120 526 class User {
f5c54951 527 has id,
528 has name,
529 has password
146ec120 530 }
531
532 class Investor {
533 has id,
534 has name,
535 has password,
536 has dollars
537 }
538
539That's redundant. Hold on a sec...
540
541 class User {
f5c54951 542 has id,
543 has name,
544 has password
146ec120 545 }
546
e7189506 547 class Investor extends User {
146ec120 548 has dollars
549 }
550
551Good idea, but how to put this into code?
552
f5c54951 553One far-too common and absolutely horrendous solution is to have a "checkbox"
554in your database: a nullable "investor" column, which entails a nullable
555"dollars" column, in the user table.
146ec120 556
557 create table "user" (
558 "id" integer not null primary key autoincrement,
559 "name" text not null,
560 "password" text not null,
561 "investor" tinyint(1),
562 "dollars" integer
563 );
564
565Let's not discuss that further.
566
f5c54951 567A second, better, solution is to break out the two tables into user and
568investor:
146ec120 569
570 create table "user" (
571 "id" integer not null primary key autoincrement,
572 "name" text not null,
573 "password" text not null
574 );
5fa55fff 575
146ec120 576 create table "investor" (
577 "id" integer not null references user("id"),
578 "dollars" integer
579 );
580
f5c54951 581So that investor's PK is just an FK to the user. We can clearly see the class
582hierarchy here, in which investor is a subclass of user. In DBIx::Class
583applications, this second strategy looks like:
5fa55fff 584
146ec120 585 my $user_rs = $schema->resultset('User');
586 my $new_user = $user_rs->create(
587 name => $args->{name},
588 password => $args->{password},
589 );
590
591 ...
592
593 my $new_investor = $schema->resultset('Investor')->create(
594 id => $new_user->id,
595 dollars => $args->{dollars},
596 );
597
f5c54951 598One can cope well with the second strategy, and it seems to be the most popular
599smart choice.
e7189506 600
146ec120 601=head1 HOW?
602
f5c54951 603There is a third strategy implemented here. Make the database do more of the
604work: hide the nasty bits so we don't have to handle them unless we really want
605to. It'll save us some typing and it'll make for more expressive code. What if
606we could do this:
146ec120 607
608 my $new_investor = $schema->resultset('Investor')->create(
609 name => $args->{name},
610 password => $args->{password},
611 dollars => $args->{dollars},
612 );
5fa55fff 613
e7189506 614And have it Just Work? The user...
615
616 {
617 name => $args->{name},
618 password => $args->{password},
619 }
620
f5c54951 621should be created behind the scenes, and the use of either user or investor
622in your code should require no special handling. Deleting and updating
623$new_investor should also delete or update the user row.
146ec120 624
f5c54951 625It does. User and investor are both views, their concrete tables abstracted
626away behind a set of rules and triggers. You would expect the above DBIC
627create statement to look like this in SQL:
146ec120 628
629 INSERT INTO investor ("name","password","dollars") VALUES (...);
630
631But using MTI, it is really this:
632
633 INSERT INTO _user_table ("username","password") VALUES (...);
634 INSERT INTO _investor_table ("id","dollars") VALUES (currval('_user_table_id_seq',...) );
635
f5c54951 636For deletes, the triggers fire in reverse, to preserve referential integrity
637(foreign key constraints). For instance:
146ec120 638
639 my $investor = $schema->resultset('Investor')->find({id => $args->{id}});
640 $investor->delete;
641
642Becomes:
643
644 DELETE FROM _investor_table WHERE ("id" = ?);
645 DELETE FROM _user_table WHERE ("id" = ?);
646
647
e7189506 648=head1 METHODS
649
650=over
651
652=item new
653
654
f5c54951 655MTI find the parents, if any, of your resultset class and adds them to the
656list of parent_sources for the table.
e7189506 657
658
659=item add_additional_parents
660
661
662Continuing with coffee:
663
664 __PACKAGE__->result_source_instance->add_additional_parents(
665 qw/
666 MyApp::Schema::Result::Beverage
667 MyApp::Schema::Result::Liquid
668 /
669 );
670
671This just lets you manually add additional parents beyond the ones MTI finds.
672
673=item add_additional_parent
674
675 __PACKAGE__->result_source_instance->add_additional_parent(
676 MyApp::Schema::Result::Beverage
677 );
678
679You can also add just one.
680
681=item attach_additional_sources
682
f5c54951 683MTI takes the parents' sources and relationships, creates a new
684DBIx::Class::Table object from them, and registers this as a new, raw, source
685in the schema, e.g.,
e7189506 686
687 use MyApp::Schema;
688
689 print STDERR map { "$_\n" } MyApp::Schema->sources;
690
5fa55fff 691 # Coffee
e7189506 692 # Beverage
693 # Liquid
694 # Sumatra
695 # Raw::Sumatra
146ec120 696
e7189506 697Raw::Sumatra will be used to generate the view.
146ec120 698
e7189506 699=item view_definition
146ec120 700
e7189506 701This takes the raw table and generates the view (and stored procedures) you will use.
146ec120 702
e7189506 703=back
146ec120 704
705=head1 AUTHOR
706
707Matt S. Trout, E<lt>mst@shadowcatsystems.co.ukE<gt>
708
709=head2 CONTRIBUTORS
710
140df08a 711Amiri Barksdale, E<lt>amiri@roosterpirates.comE<gt>
f5c54951 712
713=head1 COPYRIGHT
714
140df08a 715Copyright (c) 2011 the DBIx::Class::ResultSource::MultipleTableInheritance
f5c54951 716L</AUTHOR> and L</CONTRIBUTORS> as listed above.
146ec120 717
718=head1 LICENSE
719
720This library is free software; you can redistribute it and/or modify
721it under the same terms as Perl itself.
722
723=head1 SEE ALSO
724
725L<DBIx::Class>
726L<DBIx::Class::ResultSource>
727
728=cut