DBIx-EAV
view release on metacpan or search on metacpan
lib/DBIx/EAV/Schema.pm view on Meta::CPAN
package DBIx::EAV::Schema;
use Moo;
use Carp 'croak';
use Scalar::Util 'blessed';
use DBIx::EAV::Table;
use SQL::Translator;
use constant {
SQL_DEBUG => $ENV{DBIX_EAV_TRACE}
};
our $SCHEMA_VERSION = 1;
my %driver_to_producer = (
mysql => 'MySQL'
);
has 'dbh', is => 'ro', required => 1;
has 'database_cascade_delete', is => 'ro', default => 1;
has 'table_prefix', is => 'ro', default => 'eav_';
has 'tenant_id', is => 'ro';
has 'enable_multi_tenancy', is => 'ro', default => 0;
has 'data_types', is => 'ro', default => sub { [qw/ int decimal varchar text datetime bool /] };
has 'static_attributes', is => 'ro', default => sub { [] };
has 'id_type', is => 'ro', default => 'int';
has 'translator', is => 'ro', init_arg => undef, lazy => 1, builder => 1;
has '_tables', is => 'ro', init_arg => undef, default => sub { {} };
sub BUILD {
my $self = shift;
# enable sqlite fk for cascade delete to work
$self->dbh_do("PRAGMA foreign_keys = ON;")
if $self->db_driver_name eq 'SQLite';
}
sub _build_translator {
my $self = shift;
my $sqlt = SQL::Translator->new;
$self->_build_sqlt_schema($sqlt->schema);
$sqlt;
}
sub _build_sqlt_schema {
my ($self, $schema) = @_;
my $enable_multi_tenancy = $self->enable_multi_tenancy || $self->tenant_id;
my @schema = (
entity_types => {
columns => ['id', $enable_multi_tenancy ? 'tenant_id' : (), 'name:varchar:255', 'signature:char:32'],
index => [$enable_multi_tenancy ? 'tenant_id' : ()],
unique => {
name => [$enable_multi_tenancy ? 'tenant_id' : (),'name']
}
},
entities => {
columns => [qw/ id entity_type_id /, @{ $self->static_attributes } ],
fk => { entity_type_id => 'entity_types' }
},
attributes => {
columns => [qw/ id entity_type_id name:varchar:255 data_type:varchar:64 /],
fk => { entity_type_id => 'entity_types' }
},
relationships => {
columns => [qw/ id left_entity_type_id right_entity_type_id name:varchar:255 incoming_name:varchar:255 is_has_one:bool::0 is_has_many:bool::0 is_many_to_many:bool::0 /],
fk => { left_entity_type_id => 'entity_types', right_entity_type_id => 'entity_types' },
unique => {
name => ['left_entity_type_id','name']
}
},
entity_relationships => {
columns => [qw/ relationship_id left_entity_id right_entity_id /],
pk => [qw/ relationship_id left_entity_id right_entity_id /],
fk => {
relationship_id => 'relationships',
left_entity_id => { table => 'entities', cascade_delete => $self->database_cascade_delete },
right_entity_id => { table => 'entities', cascade_delete => $self->database_cascade_delete },
}
},
type_hierarchy => {
columns => [qw/ parent_type_id child_type_id /],
pk => [qw/ parent_type_id child_type_id /],
fk => {
parent_type_id => { table => 'entity_types', cascade_delete => $self->database_cascade_delete },
child_type_id => { table => 'entity_types', cascade_delete => $self->database_cascade_delete },
}
},
map {
("value_$_" => {
columns => [qw/ entity_id attribute_id /, 'value:'.$_],
fk => {
entity_id => { table => 'entities', cascade_delete => $self->database_cascade_delete },
attribute_id => 'attributes'
}
})
} @{ $self->data_types }
);
for (my $i = 0; $i < @schema; $i += 2) {
# add table
my $table_name = $schema[$i];
my $table_schema = $schema[$i+1];
my $table = $schema->add_table( name => $self->table_prefix . $table_name )
or die $schema->error;
# add columns
foreach my $col ( @{ $table_schema->{columns} }) {
my $field_params = ref $col ? $col : do {
my ($name, $type, $size, $default) = split ':', $col;
+{
name => $name,
data_type => $type,
size => $size,
default_value => $default
}
};
$field_params->{data_type} = $self->id_type
if $field_params->{name} =~ /(?:^id$|_id$)/;
$field_params->{is_auto_increment} = 1
if $field_params->{name} eq 'id';
$field_params->{is_nullable} //= 0;
$table->add_field(%$field_params)
or die $table->error;
}
# # primary key
my $pk = $table->get_field('id') ? 'id' : $table_schema->{pk};
$table->primary_key($pk) if $pk;
# # foreign keys
foreach my $fk_column (keys %{ $table_schema->{fk} || {} }) {
my $params = $table_schema->{fk}->{$fk_column};
$params = { table => $params } unless ref $params;
$table->add_constraint(
name => join('_', 'fk', $table_name, $fk_column, $params->{table}),
type => 'foreign_key',
fields => $fk_column,
reference_fields => 'id',
reference_table => $self->table_prefix . $params->{table},
on_delete => $params->{cascade_delete} ? 'CASCADE' : 'NO ACTION'
);
}
# # unique constraints
foreach my $name (keys %{ $table_schema->{unique} || {} }) {
$table->add_index(
name => join('_', 'unique', $table_name, $name),
type => 'unique',
fields => $table_schema->{unique}{$name},
);
}
# # index
foreach my $colname (@{ $table_schema->{index} || [] }) {
$table->add_index(
name => join('_', 'idx', $table_name, $colname),
type => 'normal',
fields => $colname,
);
}
}
return 1;
}
sub version { $SCHEMA_VERSION }
sub get_ddl {
my ($self, $producer) = @_;
unless ($producer) {
my $driver = $self->dbh->{Driver}{Name};
$producer = $driver_to_producer{$driver} || $driver;
}
$self->translator->producer($producer);
$self->translator->translate;
}
sub version_table {
my $self = shift;
DBIx::EAV::Table->new(
dbh => $self->dbh,
name => $self->table_prefix . 'schema_versions',
columns => [qw/ id version ddl /]
);
}
sub version_table_is_installed {
my $self = shift;
my $success = 0;
eval {
$self->dbh_do(sprintf 'SELECT COUNT(*) FROM %s', $self->table_prefix . 'schema_versions');
$success = 1;
};
$success;
}
sub install_version_table {
my $self = shift;
my $sqlt = SQL::Translator->new;
my $table = $sqlt->schema->add_table( name => $self->version_table->name );
$table->add_field(
name => 'id',
data_type => 'INTEGER',
is_auto_increment => 1
);
$table->add_field(
name => 'version',
data_type => 'INTEGER'
);
$table->add_field(
name => 'ddl',
data_type => 'TEXT'
);
$table->primary_key('id');
# execute ddl
my $driver = $self->dbh->{Driver}{Name};
$sqlt->producer($driver_to_producer{$driver} || $driver);
$self->dbh_do($_)
for grep { /\w/ } split ';', $sqlt->translate;
}
sub installed_version {
my $self = shift;
my $table = $self->version_table;
my $row;
eval {
my ($rv, $sth) = $self->dbh_do(sprintf 'SELECT * FROM %s ORDER BY id DESC', $table->name);
$row = $sth->fetchrow_hashref;
};
return unless $row;
$row->{version};
}
sub deploy {
my $self = shift;
my %options = ( @_, no_comments => 1 );
$self->translator->$_($options{$_})
for keys %options;
# deploy version table
$self->install_version_table
unless $self->version_table_is_installed;
# check we already installed this version
my $version_table = $self->version_table;
return if $version_table->select_one({ version => $self->version }) && !$options{add_drop_table};
# deploy ddl
my $ddl = $self->get_ddl;
print STDERR $ddl if SQL_DEBUG;
$self->dbh_do($_)
for grep { /\w/ } split ';', $ddl;
# create version record
$version_table->insert({
version => $self->version,
ddl => 'DDL'
});
}
sub dbh_do {
my ($self, $stmt, $bind) = @_;
if (SQL_DEBUG) {
my $i = 0;
print STDERR "$stmt";
print STDERR $bind ? sprintf(": %s\n", join(' ', map { $i++.'='.$_ } @{ $bind || [] }))
: ";\n";
}
my $sth = $self->dbh->prepare($stmt);
my $rv = $sth->execute(ref $bind eq 'ARRAY' ? @$bind : ());
die $sth->errstr unless defined $rv;
return ($rv, $sth);
}
sub table {
my ($self, $name) = @_;
return $self->_tables->{$name}
if exists $self->_tables->{$name};
my $table_schema = $self->translator->schema->get_table($self->table_prefix . $name);
croak "Table '$name' does not exist."
unless $table_schema;
$self->_tables->{$name} = DBIx::EAV::Table->new(
dbh => $self->dbh,
tenant_id => $self->tenant_id,
name => $table_schema->name,
columns => [ $table_schema->field_names ]
);
}
sub has_data_type {
my ($self, $name) = @_;
foreach (@{$self->data_types}) {
return 1 if $_ eq $name;
}
0;
}
sub db_driver_name {
shift->dbh->{Driver}{Name};
}
1;
__END__
=encoding utf-8
=head1 NAME
DBIx::EAV::Schema - Describes the physical EAV database schema.
=head1 SYNOPSIS
my $schema = DBIx:EAV::Schema->new(
dbh => $dbh, # required
tables => \%tables # required
tenant_id => $tenant_id, # default undef
table_prefix => 'my_eav_', # default 'eav_'
);
=head1 DESCRIPTION
This class represents the physical eav database schema. Will never need to
instantiate an object of this class directly.
=head1 CONSTRUCTOR OPTIONS
=head2 data_types
=over
=item Default: C<[qw/ int decimal varchar text datetime bool /]>
=back
Arrayref of SQL data types that will be available to entity attributes. DBIx::EAV
uses one value table for each data type listed here. See L</values> and L</deploy>.
=head2 static_attributes
=over
=item Default: C<[]>
Arrayref of column definitions which will be available as static attributes for
all entities. A column definition is a string in the form of
C<"$col_name:$data_type:$data_size:$default_value"> or a hashref suitable for
L<SQL::Translator::Schema::Table/add_field>.
Example defining a C<slug VARCHAR(255)> and a C<is_deleted BOOL DEFAULT 0>
attributes. Note that in the definition of C<is_deleted> we wanted to specify
the C<$default_value> but not the C<$data_size> field.
static_attributes => [qw/ slug:varchar:255 is_deleted:bool::0 /]
=back
=head2 table_prefix
=over
=item Default: C<"eav_">
=back
Prefix added to our tables names to form the real database table name.
See L</TABLES>.
=head2 database_cascade_delete
=over
=item Default: C<1>
=back
When enabled, entities delete operations (via L<DBIx::EAV::Entity/delete> or
L<DBIx::EAV::ResultSet/delete>) are accomplished through a single C<DELETE> SQL command.
Also instructs L</deploy> to create the proper C<ON DELETE CASCADE> constraints.
See L</"CASCADE DELETE">.
=head2 tenant_id
=over
=item Default: C<undef>
=back
Setting this parameter enables the multi-tenancy feature.
=head2 id_type
=over
=item Default: C<"int">
=back
Data type used by L</deploy> for the C<PRIMARY KEY> ('id') and C<FOREIGN KEY> ('*_id') columns.
=head1 TABLES
This section describes the tables used by L<DBIx::EAV>.
=head2 entity_types
=over
=item Columns: id, tenant_id?, name:varchar:255
=item Primary Key: id
=item Index: tenant_id?
=item Unique: name
=back
=head2 attributes
=over
=item Columns: id, entity_type_id, name:varchar:255, data_type:varchar:64
=item Primary Key: id
=item Foreign Key: entity_type_id -> L</entity_types>
=back
=head2 relationships
=over
=item Columns: id, name:varchar:255, left_entity_type_id, right_entity_type_id, is_has_one:bool::0, is_has_many:bool::0, is_many_to_many:bool::0
=item Primary Key: id
=item Foreign Key: left_entity_type_id -> L</entity_types>
=item Foreign Key: right_entity_type_id -> L</entity_types>
=item Unique: left_entity_type_id, name
=back
Stores the relationships definition between L<entity types|/entity_types>.
See L<DBIx::EAV::Manual/RELATIONSHIPS>.
=head2 type_hierarchy
=over
=item Columns: parent_type_id, child_type_id
=item Primary Key: parent_type_id, child_type_id
=item Foreign Key: parent_type_id -> L</entity_types>
=item Foreign Key: child_type_id -> L</entity_types>
=back
Stores the type -> subtype relationship. See L</"TYPE INHERITANCE">.
=head2 entities
=over
=item Columns: id, entity_type_id, (L</static_attributes>)?
=item Primary Key: id
=item Foreign Key: entity_type_id -> L</entity_types>
=back
Stores the main entities rows, which by default contain only the C<id> and
C<entity_type_id> columns. Any defined L</static_attributes> are also added as
real columns of this table. This is a very "tall and skinny" table, tipical of EAV
systems.
=head2 entity_relationships
=over
=item Columns: relationship_id, left_entity_id, right_entity_id
=item Primary Key: id
=item Foreign Key: left_entity_id -> L</entitites> (ON DELETE CASCADE)
=item Foreign Key: right_entity_id -> L</entitites> (ON DELETE CASCADE)
=back
Stores the actual relationship links between L</entities>.
=head2 values
=over
=item Columns: entity_id, attribute_id, value
=item Primary Key: entity_id, attribute_id
=item Foreign Key: entity_id -> L</entitites> (ON DELETE CASCADE)
=item Foreign Key: attribute_id -> L</attributes>
=back
Stores the actual attributes values. One table named
C< $table_prefix . $data_type . "_value" > is created for each data type listed
in L</data_types>.
=head1 METHODS
=head2 table
my $table = $schema->table($name);
Returns a L<DBIx::EAV::Table> representing the table $name.
=head2 dbh_do
=head2 has_data_type
=head2 deploy
Create the eav database tables.
$eav->schema->deploy( add_drop_table => 1 );
=head2 get_ddl
Returns the eav schema DDL in any of the supported L<SQL::Translator> producers.
If no argument is passed a producer for the L<current driver|/db_driver_name> is
used.
my $mysql_ddl = $eav->schema->get_ddl('MySQL');
=head2 db_driver_name
Shortcut for C<< $self->dbh->{Driver}{Name} >>.
=head1 CASCADE DELETE
Since a single L<entity|DBIx::EAV::Entity>'s data is spread over several value
tables, we can't just delete the entity in a single SQL C<DELETE> command.
We must first send a C<DELETE> for each of those value tables, and one more for
the L</entity_relationships> table. If an entity has attributes of 4 data types,
and has any relationship defined, a total of 6 (six!!) C<DELETE> commands will
be needed to delete a single entity. Four to the L</values> tables, one to the
L</entity_relationships> and one for the actual L</entities> table).
Those extra C<DELETE> commands can be avoided by using database-level
C<ON DELETE CASCADE> for the references from the B<values> and
B<entity_relationships> tables to the B<entities> table.
The current DBIx::EAV implementation can handle both situations, but defaults
to database-level cascade delete. See L</database_cascade_delete> option.
I'll probably drop support for no database-level cascade delete in the future...
if no one points otherwise.
=head1 LICENSE
Copyright (C) Carlos Fernando Avila Gratz.
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Carlos Fernando Avila Gratz E<lt>cafe@kreato.com.brE<gt>
=cut
( run in 0.254 second using v1.01-cache-2.11-cpan-73692580452 )