package Yancy::Backend::Dbic; our $VERSION = '1.081'; # ABSTRACT: A backend for DBIx::Class schemas #pod =head1 SYNOPSIS #pod #pod ### URL string #pod use Mojolicious::Lite; #pod plugin Yancy => { #pod backend => 'dbic://My::Schema/dbi:Pg:localhost', #pod read_schema => 1, #pod }; #pod #pod ### DBIx::Class::Schema object #pod use Mojolicious::Lite; #pod use My::Schema; #pod plugin Yancy => { #pod backend => { Dbic => My::Schema->connect( 'dbi:SQLite:myapp.db' ) }, #pod read_schema => 1, #pod }; #pod #pod ### Arrayref #pod use Mojolicious::Lite; #pod use My::Schema; #pod plugin Yancy => { #pod backend => { #pod Dbic => [ #pod 'My::Schema', #pod 'dbi:SQLite:mysql.db', #pod undef, undef, #pod { PrintError => 1 }, #pod ], #pod }, #pod read_schema => 1, #pod }; #pod #pod =head1 DESCRIPTION #pod #pod This Yancy backend allows you to connect to a L schema to #pod manage the data inside. #pod #pod =head1 METHODS #pod #pod See L for the methods this backend has and their return #pod values. #pod #pod =head2 read_schema #pod #pod While reading the various sources, this method will check each source's #pod C for the existence of a C method. If it exists, #pod that will be called, and must return the initial JSON schema for Yancy. #pod #pod A very useful possibility is for that JSON schema to just contain #pod C<<{ 'x-ignore' => 1 }>>. #pod #pod =head2 Backend URL #pod #pod The URL for this backend takes the form C<< dbic:/// >> #pod where C is the DBIx::Class schema module name and C is #pod the full L data source name (DSN) used to connect to the database. #pod #pod =head2 Schema Names #pod #pod The schema names for this backend are the names of the #pod L classes in your schema, just as DBIx::Class allows #pod in the C<< $schema->resultset >> method. #pod #pod So, if you have the following schema: #pod #pod package My::Schema; #pod use base 'DBIx::Class::Schema'; #pod __PACKAGE__->load_namespaces; #pod #pod package My::Schema::Result::People; #pod __PACKAGE__->table( 'people' ); #pod __PACKAGE__->add_columns( qw/ id name email / ); #pod #pod package My::Schema::Result::Business #pod __PACKAGE__->table( 'business' ); #pod __PACKAGE__->add_columns( qw/ id name email / ); #pod #pod You could map that to the following schema names: #pod #pod { #pod backend => 'dbic://My::Schema/dbi:SQLite:test.db', #pod schema => { #pod People => { #pod properties => { #pod id => { #pod type => 'integer', #pod readOnly => 1, #pod }, #pod name => { type => 'string' }, #pod email => { type => 'string' }, #pod }, #pod }, #pod Business => { #pod properties => { #pod id => { #pod type => 'integer', #pod readOnly => 1, #pod }, #pod name => { type => 'string' }, #pod email => { type => 'string' }, #pod }, #pod }, #pod }, #pod } #pod #pod =head1 SEE ALSO #pod #pod L, L, L #pod #pod =cut use Mojo::Base 'Yancy::Backend'; use Role::Tiny qw( with ); with 'Yancy::Backend::Role::Sync'; use Scalar::Util qw( looks_like_number blessed ); use Mojo::Loader qw( load_class ); use Mojo::JSON qw( true encode_json ); BEGIN { eval { require DBIx::Class; DBIx::Class->VERSION( 0.082842 ); 1 } or die "Could not load Dbic backend: DBIx::Class version 0.08242 or higher required\n"; } has driver =>; sub new { my ( $class, $backend, $schema ) = @_; if ( !ref $backend ) { my ( $dbic_class, $dsn, $optstr ) = $backend =~ m{^[^:]+://([^/]+)/([^?]+)(?:\?(.+))?$}; if ( my $e = load_class( $dbic_class ) ) { die ref $e ? "Could not load class $dbic_class: $e" : "Could not find class $dbic_class"; } $backend = $dbic_class->connect( $dsn, undef, undef, {}, { quote_names => 1 } ); } elsif ( !blessed $backend ) { my $dbic_class = shift @$backend; if ( my $e = load_class( $dbic_class ) ) { die ref $e ? "Could not load class $dbic_class: $e" : "Could not find class $dbic_class"; } if ( my $extra_attrs = $backend->[4] ||= {} ) { $extra_attrs->{ quote_names } = 1; } $backend = $dbic_class->connect( @$backend ); } return $class->SUPER::new( $backend, $schema ); } sub _rs { my ( $self, $schema_name, $params, $opt ) = @_; $params ||= {}; $opt ||= {}; my $schema = $self->schema->{ $schema_name }; my $real_schema = ( $schema->{'x-view'} || {} )->{schema} // $schema_name; my $rs = $self->driver->resultset( $real_schema )->search( $params, $opt ); $rs->result_class( 'DBIx::Class::ResultClass::HashRefInflator' ); return $rs; } sub _find { my ( $self, $schema_name, $id ) = @_; my $id_field = $self->schema->{ $schema_name }{ 'x-id-field' } || 'id'; my %id; if ( ref $id_field eq 'ARRAY' ) { %id = %$id; die "Missing composite ID parts" if @$id_field > keys %$id; } else { %id = ( $id_field => $id ); } return $self->driver->resultset( $schema_name )->find( \%id ); } sub create { my ( $self, $schema_name, $params ) = @_; $params = $self->normalize( $schema_name, $params ); die "No refs allowed in '$schema_name': " . encode_json $params if grep ref && ref ne 'SCALAR', values %$params; my $created = $self->driver->resultset( $schema_name )->create( $params ); my $id_field = $self->schema->{ $schema_name }{ 'x-id-field' } || 'id'; return ref $id_field eq 'ARRAY' ? { map { $_ => $created->$_ } @$id_field } : $created->$id_field ; } sub get { my ( $self, $schema_name, $id, %opt ) = @_; my $schema = $self->schema->{ $schema_name }; my $real_schema = ( $schema->{'x-view'} || {} )->{schema} // $schema_name; my $props = $schema->{properties} || $self->schema->{ $real_schema }{properties}; my $id_field = $schema->{ 'x-id-field' } || 'id'; my %id; if ( ref $id_field eq 'ARRAY' ) { %id = %$id; die "Missing composite ID parts" if @$id_field > keys %$id; } else { %id = ( $id_field => $id ); } # Prefetch the data so HashRefInflator does the right thing if ( $opt{join} ) { $opt{prefetch} = $opt{join}; } my $ret = $self->_rs( $real_schema, undef, { select => [ keys %$props ], %opt }, )->find( \%id ); return $self->normalize( $schema_name, $ret ); } sub list { my ( $self, $schema_name, $params, @opt ) = @_; my $opt = @opt % 2 == 0 ? {@opt} : $opt[0]; $params ||= {}; my $schema = $self->schema->{ $schema_name }; my $real_schema = ( $schema->{'x-view'} || {} )->{schema} // $schema_name; my $props = $schema->{properties} || $self->schema->{ $real_schema }{properties}; my %rs_opt = ( order_by => $opt->{order_by}, select => [ keys %$props ], ); # Prefetch the data so HashRefInflator does the right thing if ( $opt->{join} ) { $rs_opt{join} = $opt->{join}; $rs_opt{prefetch} = $opt->{join}; } my $count_rs = $self->_rs( $schema_name, $params, \%rs_opt ); if ( $opt->{limit} ) { die "Limit must be number" if !looks_like_number $opt->{limit}; $rs_opt{ rows } = $opt->{limit}; } if ( $opt->{offset} ) { die "Offset must be number" if !looks_like_number $opt->{offset}; $rs_opt{ offset } = $opt->{offset}; } my $rs = $self->_rs( $schema_name, $params, \%rs_opt ); return { items => [ map $self->normalize( $schema_name, $_ ), $rs->all ], total => $count_rs->count, }; } sub set { my ( $self, $schema_name, $id, $params ) = @_; $params = $self->normalize( $schema_name, $params ); die "No refs allowed in '$schema_name'($id): " . encode_json $params if grep ref && ref ne 'SCALAR', values %$params; if ( my $row = $self->_find( $schema_name, $id ) ) { $row->set_columns( $params ); if ( $row->is_changed ) { $row->update; return 1; } } return 0; } sub delete { my ( $self, $schema_name, $id ) = @_; # We assume that if we can find the row by ID, that the delete will # succeed if ( my $row = $self->_find( $schema_name, $id ) ) { $row->delete; return 1; } return 0; } my %fix_default = ( current_timestamp => "now", current_time => "now", current_date => "now", ); sub read_schema { my ( $self, @schema_names ) = @_; my %schema; my @schemas = @schema_names ? @schema_names : $self->driver->sources; my %classes; for my $schema_name ( @schemas ) { # ; say "Got schema $schema_name"; my $source = $self->driver->source( $schema_name ); my $result_class = $source->result_class; # ; say "Adding class: $result_class ($schema_name)"; $classes{ $result_class } = $source; $schema{ $schema_name } = $result_class->yancy if $result_class->can('yancy'); $schema{ $schema_name }{type} = 'object'; my @columns = $source->columns; for my $i ( 0..$#columns ) { my $column = $columns[ $i ]; my $c = $source->column_info( $column ); # ; use Data::Dumper; # ; say Dumper $c; my $is_auto = $c->{is_auto_increment}; my $default = ref $c->{default_value} eq 'SCALAR' ? ${ $c->{default_value} } : $c->{default_value }; $schema{ $schema_name }{ properties }{ $column } = { $self->_map_type( $c ), $is_auto ? ( readOnly => true ) : (), defined $default ? ( default => exists $fix_default{ $default } ? $fix_default{ $default } : $default ) : (), 'x-order' => $i + 1, }; if ( !$c->{is_nullable} && !$is_auto && !defined $c->{default_value} ) { push @{ $schema{ $schema_name }{ required } }, $column; } } my %is_pk = map {$_=>1} $source->primary_columns; my @unique_columns = grep !$is_pk{$_}, # we know about those already map @$_, grep scalar( @$_ ) == 1, map [ $source->unique_constraint_columns( $_ ) ], $source->unique_constraint_names; my ( $pk ) = keys %is_pk; if ( @unique_columns == 1 and $unique_columns[0] ne 'id' ) { # favour "natural" key over "surrogate" integer one, if exists $schema{ $schema_name }{ 'x-id-field' } = $unique_columns[0]; } elsif ( $pk && $pk ne 'id' ) { $schema{ $schema_name }{ 'x-id-field' } = $pk; } } # Link foreign keys for my $source ( values %classes ) { for my $rel_name ( $source->relationships ) { my $rel = $source->relationship_info( $rel_name ); next unless $rel->{attrs}{accessor} eq 'single'; # Only belongs_to # ; use Data::Dumper; # ; say Dumper $rel; my $self_schema = $source->source_name; my $foreign_class = $rel->{source}; # XXX Only very simple joins are possible here right now my @self_cols = map /^[^.]+\.(.+)$/, grep /^self[.]/, %{ $rel->{cond} }; my @foreign_cols = map /^[^.]+\.(.+)$/, grep /^foreign[.]/, %{ $rel->{cond} }; if ( @self_cols > 1 || @foreign_cols > 1 ) { warn sprintf 'Cannot do foreign key with multiple columns yet on table %s, relationship %s', $source->source_name, $rel_name, ; next; } # ; say "Looking for foreign class: $foreign_class"; next unless $classes{ $foreign_class }; my $foreign_schema = $classes{ $foreign_class }->source_name; my $foreign_id = $schema{ $foreign_schema }{'x-id-field'} // 'id'; if ( $foreign_cols[0] ne $foreign_id ) { warn sprintf 'Cannot do foreign key with columns that are not the primary ID (x-id-field) on table %s, relationship %s (foreign column: %s, foreign id: %s)', $source->name, $rel_name, $foreign_cols[0], $foreign_id, ; next; } $schema{ $self_schema }{ properties }{ $self_cols[0] }{ 'x-foreign-key' } = $foreign_schema; } } return @schema_names ? @schema{ @schema_names } : \%schema; } sub _map_type { my ( $self, $column ) = @_; my %conf; my $db_type = $column->{data_type} // 'varchar'; if ( $column->{extra}{list} ) { %conf = ( enum => $column->{extra}{list} ); } if ( $db_type =~ /^(?:text|varchar)/i ) { %conf = ( %conf, type => 'string' ); } elsif ( $db_type =~ /^(?:boolean)/i ) { %conf = ( %conf, type => 'boolean' ); } elsif ( $db_type =~ /^(?:int|integer|smallint|bigint|tinyint|rowid)/i ) { %conf = ( %conf, type => 'integer' ); } elsif ( $db_type =~ /^(?:double|float|money|numeric|real)/i ) { %conf = ( %conf, type => 'number' ); } elsif ( $db_type =~ /^(?:timestamp|datetime)/i ) { %conf = ( %conf, type => 'string', format => 'date-time' ); } elsif ( $db_type =~ /(?:blob|bytea)/i ) { %conf = ( %conf, type => 'string', format => 'binary' ); } else { # Default to string %conf = ( %conf, type => 'string' ); } if ( $column->{is_nullable} ) { $conf{ type } = [ $conf{ type }, 'null' ]; } #; use Data::Dumper; #; say "Field: " . Dumper $column; #; say "Conf: " . Dumper \%conf; return %conf; } sub supports { 0 } 1; __END__ =pod =head1 NAME Yancy::Backend::Dbic - A backend for DBIx::Class schemas =head1 VERSION version 1.081 =head1 SYNOPSIS ### URL string use Mojolicious::Lite; plugin Yancy => { backend => 'dbic://My::Schema/dbi:Pg:localhost', read_schema => 1, }; ### DBIx::Class::Schema object use Mojolicious::Lite; use My::Schema; plugin Yancy => { backend => { Dbic => My::Schema->connect( 'dbi:SQLite:myapp.db' ) }, read_schema => 1, }; ### Arrayref use Mojolicious::Lite; use My::Schema; plugin Yancy => { backend => { Dbic => [ 'My::Schema', 'dbi:SQLite:mysql.db', undef, undef, { PrintError => 1 }, ], }, read_schema => 1, }; =head1 DESCRIPTION This Yancy backend allows you to connect to a L schema to manage the data inside. =head1 METHODS See L for the methods this backend has and their return values. =head2 read_schema While reading the various sources, this method will check each source's C for the existence of a C method. If it exists, that will be called, and must return the initial JSON schema for Yancy. A very useful possibility is for that JSON schema to just contain C<<{ 'x-ignore' => 1 }>>. =head2 Backend URL The URL for this backend takes the form C<< dbic:/// >> where C is the DBIx::Class schema module name and C is the full L data source name (DSN) used to connect to the database. =head2 Schema Names The schema names for this backend are the names of the L classes in your schema, just as DBIx::Class allows in the C<< $schema->resultset >> method. So, if you have the following schema: package My::Schema; use base 'DBIx::Class::Schema'; __PACKAGE__->load_namespaces; package My::Schema::Result::People; __PACKAGE__->table( 'people' ); __PACKAGE__->add_columns( qw/ id name email / ); package My::Schema::Result::Business __PACKAGE__->table( 'business' ); __PACKAGE__->add_columns( qw/ id name email / ); You could map that to the following schema names: { backend => 'dbic://My::Schema/dbi:SQLite:test.db', schema => { People => { properties => { id => { type => 'integer', readOnly => 1, }, name => { type => 'string' }, email => { type => 'string' }, }, }, Business => { properties => { id => { type => 'integer', readOnly => 1, }, name => { type => 'string' }, email => { type => 'string' }, }, }, }, } =head1 SEE ALSO L, L, L =head1 AUTHOR Doug Bell =head1 COPYRIGHT AND LICENSE This software is copyright (c) 2021 by Doug Bell. This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself. =cut