The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
package Data::Model::Schema;
use strict;
use warnings;

use Carp ();
$Carp::Internal{(__PACKAGE__)}++;
use Encode ();

use Data::Model::Row;
use Data::Model::Schema::Properties;

my  $SUGAR_MAP    = +{};
our $COLUMN_SUGAR = +{};

sub import {
    my($class, %args) = @_;
    my $caller = caller;
    $SUGAR_MAP->{$caller} = $args{sugar} || 'default';
    $COLUMN_SUGAR->{$SUGAR_MAP->{$caller}} ||= +{};

    if ($caller eq 'Data::Model::Schema::Properties') {
        $args{skip_import}++;
    }

    unless ($args{skip_import}) {
        no strict 'refs';
        for my $name (qw/ base_driver driver install_model schema column columns key index unique schema_options column_sugar
                          utf8_column utf8_columns alias_column add_method /) {
            *{"$caller\::$name"} = \&$name;
        }
    }

    my $__properties = +{
        base_driver  => undef,
        schema       => +{},
        __process_tmp => +{
            class => $caller,
        },
    };

    no strict 'refs';
    no warnings 'redefine';
    *{"$caller\::__properties"} = sub { $__properties };
}

my $CALLER = undef;
sub install_model ($$;%) {
    my($name, $schema_code, %args) = @_;
    my $caller = caller;

    my $pkg = "$caller\::$name";

    my $schema = $caller->__properties->{schema}->{$name} = Data::Model::Schema::Properties->new(
        driver                  => $caller->__properties->{base_driver},
        schema_class            => $caller,
        model                   => $name,
        class                   => $pkg,
        column                  => {},
        columns                 => [],
        index                   => {},
        unique                  => {},
        key                     => [],
        foreign                 => [],
        triggers                => {},
        options                 => {},
        utf8_columns            => {},
        inflate_columns         => [],
        deflate_columns         => [],
        has_inflate             => 0,
        has_deflate             => 0,
        alias_column            => {},
        aluas_column_revers_map => {},
        _build_tmp              => {},
    );

    $caller->__properties->{__process_tmp}->{name} = $name;
    $CALLER = $caller;
    $schema_code->();
    $schema->setup_inflate;
    unless ($schema->options->{bare_row}) {
        no strict 'refs';
        @{"$pkg\::ISA"} = ( 'Data::Model::Row' );
        _install_columns_to_class($schema);
        _install_alias_columns_to_class($schema);
    }
    $CALLER = undef;
    delete $caller->__properties->{__process_tmp};

    if ($schema->driver) {
        $schema->driver->attach_model($name, $schema);
    }
}
sub schema (&) { shift }

sub _install_columns_to_class {
    my $schema = shift;
    no strict 'refs';
    while (my($column, $args) = each %{ $schema->column }) {
        my $alias_list = $schema->aluas_column_revers_map->{$column};

        if ($alias_list) {
            *{ $schema->class . "::$column" } = sub {
                my $obj = shift;
                # getter
                return $obj->{column_values}->{$column} unless @_;
                # setter
                my($val, $flags) = @_;
                my $old_val = $obj->{column_values}->{$column};
                $obj->{column_values}->{$column} = $val;
                unless ($flags && ref($flags) eq 'HASH' && $flags->{no_changed_flag}) {
                    $obj->{changed_cols}->{$column} = $old_val;
                }
                for my $alias (@{ $alias_list }) {
                    delete $obj->{alias_values}->{$alias};
                }
                return $obj->{column_values}->{$column};
            };
        } else {
            *{ $schema->class . "::$column" } = sub {
                my $obj = shift;
                # getter
                return $obj->{column_values}->{$column} unless @_;
                # setter
                my($val, $flags) = @_;
                my $old_val = $obj->{column_values}->{$column};
                $obj->{column_values}->{$column} = $val;
                unless ($flags && ref($flags) eq 'HASH' && $flags->{no_changed_flag}) {
                    $obj->{changed_cols}->{$column} = $old_val;
                }
                return $obj->{column_values}->{$column};
            };
        }
    }
}

sub _install_alias_columns_to_class {
    my $schema = shift;
    no strict 'refs';
    while (my($column, $args) = each %{ $schema->alias_column }) {
        my $base          = $args->{base};
        my $deflate_code  = $args->{deflate};
        my $is_utf8       = $args->{is_utf8};
        my $charset       = $args->{charset} || 'utf8';
        my $inflate2alias = $args->{inflate2alias};

        if ($is_utf8 && $deflate_code) {
            *{ $schema->class . "::$column" } = sub {
                my $obj = shift;
                # getter
                return $obj->{alias_values}->{$column} ||= $inflate2alias->($obj) unless @_;
                # setter
                $obj->{alias_values}->{$column} = $_[0];
                $obj->$base( Encode::encode($charset, $deflate_code->( $_[0] ) ) );
                return $_[0];
            };
        } elsif ($is_utf8) {
            *{ $schema->class . "::$column" } = sub {
                my $obj = shift;
                # getter
                return $obj->{alias_values}->{$column} ||= $inflate2alias->($obj) unless @_;
                # setter
                $obj->{alias_values}->{$column} = $_[0];
                $obj->$base( Encode::encode($charset, $_[0]) );
                return $_[0];
            };
        } elsif ($deflate_code) {
            *{ $schema->class . "::$column" } = sub {
                my $obj = shift;
                # getter
                return $obj->{alias_values}->{$column} ||= $inflate2alias->($obj) unless @_;
                # setter
                $obj->{alias_values}->{$column} = $_[0];
                $obj->$base( $deflate_code->($_[0]) );
                return $_[0];
            };
        } else {
            *{ $schema->class . "::$column" } = sub {
                my $obj = shift;
                # getter
                return $obj->{alias_values}->{$column} ||= $inflate2alias->($obj) unless @_;
                # setter
                $obj->{alias_values}->{$column} = $_[0];
                $obj->$base( $_[0] );
                return $_[0];
            };
        }
    }
}

sub _get_model_schema {
    if ($CALLER) {
        my $caller = caller(1);
        my $name = $caller->__properties->{__process_tmp}->{name};
        return ($name, $caller->__properties->{schema}->{$name});
    }

    my $method = (caller(1))[3];
    $method =~ s/.+:://;
    Carp::croak "'$method' method is target internal only";
}

sub base_driver ($) {
    my $caller = caller;
    return unless $caller->can('__properties');
    $caller->__properties->{base_driver} = shift;
}

sub driver ($;%) {
    my($name, $schema) = _get_model_schema;
    my($driver, %args) = @_;
    $schema->driver($driver);
}

sub column ($;$;$) {
    my($name, $schema) = _get_model_schema;
    $schema->add_column(@_);
}
sub columns (@) {
    my($name, $schema) = _get_model_schema;
    my @columns = @_;
    for my $column (@columns) {
        $schema->add_column($column);
    }
}
sub utf8_column ($;$;$) {
    my($name, $schema) = _get_model_schema;
    $schema->add_utf8_column(@_);
}
sub utf8_columns (@) {
    my($name, $schema) = _get_model_schema;
    my @columns = @_;
    for my $column (@columns) {
        $schema->add_utf8_column($column);
    }
}

sub alias_column {
    my($name, $schema) = _get_model_schema;
    $schema->add_alias_column(@_);
}

sub key ($;%) {
    my($name, $schema) = _get_model_schema;
    $schema->add_keys(@_);
}

sub index ($;$;%) {
    my($name, $schema) = _get_model_schema;
    $schema->add_index(@_);
}

sub unique ($;$;%) {
    my($name, $schema) = _get_model_schema;
    $schema->add_unique(@_);
}

sub schema_options (@) {
    my($name, $schema) = _get_model_schema;
    $schema->add_options(@_);
}

sub add_method {
    my($name, $schema) = _get_model_schema;
    my($method, $code) = @_;
    no strict 'refs';
    *{$schema->class."::$method"} = $code;
}


sub column_sugar (@) {
    my($column, $type, $options) = @_;
    Carp::croak "usage: add_column_sugar 'table_name.column_name' => type => { args };"
        unless $column =~ /^[^\.+]+\.[^\.+]+$/;

    my $caller = caller;
    $COLUMN_SUGAR->{$SUGAR_MAP->{$caller}} ||= +{};
    $COLUMN_SUGAR->{$SUGAR_MAP->{$caller}}->{$column} = +{
        type    => $type    || 'char',
        options => $options || +{},
    };
}

sub get_column_sugar {
    my($class, $schema) = @_;
    $COLUMN_SUGAR->{$SUGAR_MAP->{$schema->{schema_class}}};
}

1;

__END__

=head1 NAME

Data::Model::Schema - Schema DSL for Data::Model

=head1 SYNOPSIS

  package Your::Model;
  use base 'Data::Model';
  use Data::Model::Schema;
  use Data::Model::Driver::DBI;
  
  my $dbfile = '/foo/bar.db';
  my $driver = Data::Model::Driver::DBI->new(
      dsn => "dbi:SQLite:dbname=$dbfile",
  );
  base_driver( $driver ); # set the storage driver for Your::Model


  install_model tweet => schema { # CREATE TABLE tweet (
    key 'id'; # primary key
    index index_name [qw/ user_id at /]; # index index_name(user_id, at);

    column id
        => int => {
            auto_increment => 1,
            required       => 1,
            unsigned       => 1,
        }; # id   UNSIGNED INT NOT NULL AUTO_INCREMENT,

    column user_id
        => int => {
            required       => 1,
            unsigned       => 1,
        }; # user_id   UNSIGNED INT NOT NULL,

    column at
        => int => {
            required       => 1,
            default        => sub { time() },
            unsigned       => 1,
        }; # at   UNSIGNED INT NOT NULL, # If it is empty at the time of insert   time() is used.

    utf8_column body # append to auto utf8 inflating
        => varchar => {
            required       => 1,
            size           => 140,
            default        => '-',
        }; # body   VARCHAR(140) NOT NULL DEFAULT'-',


    column field_name
        => char => {
            default    => 'aaa', # default value
            auto_increment => 1, # auto_increment
            inflate => sub { unpack("H*", $_[0]) }, # inflating by original function
            deflate => sub { pack("H*", $_[0]) },   # deflating by original function
        };

    column field_name_2
        => char => {
            inflate => 'URI', # use URI inflate see L<Data::Model::Schema::Inflate>
            deflate => 'URI', # use URI deflate see L<Data::Model::Schema::Inflate>
        };

    columns qw/ foo bar /; # create columns uses default config
};

=head1 GLOBAL DSL

=head2 install_model, schema

  model name and it schema is set up.

  install_model model_name schema {
  };

=head2 base_driver

set driver ( Data::Model::Driver::* ) for current package's default.


=head2 column_sugar

column_sugar promotes reuse of a schema definition.

see head1 COLUMN SUGAR

=head1 SCHEMA DSL

=head2 driver

driver used only in install_model of current.

  install_model local_driver => schema {
      my $driver = Data::Mode::Driver::DBI->new( dsn => 'DBI:SQLite:' );
      driver($driver);
   }

=head2 column

It is a column definition.

  column column_name => column_type => \%options;

column_name puts in the column name of SQL schema.

column_type puts in the column type of SQL schema. ( INT CHAR BLOB ... )

=head2 columns

some columns are set up. However, options cannot be set.

=head2 utf8_column

column with utf8 inflated.

=head2 utf8_columns

columns with utf8 inflated.

=head2 alias_column

alias is attached to a specific column.

It is helpful. I can use, when leaving original data and inflateing.

    { package Name; use Moose; has 'name' => ( is => 'rw' ); }
    # in schema 
    columns qw( name nickname );
    alias_column name     => 'name_name';
    alias_column nickname => 'nickname_name'
        => {
            inflate => sub {
                my $value = shift;
                Name->new( name => $value );
            }

    # in your script
    is $row->nickname, $row->nickname_name->name;

=head2 key

set the primary key.
Unless it specifies key, it does not move by lookup and lookup_multi.

  key 'id';
  key [qw/ id sub_id /]; # multiple key

=head2 index

  index 'name'; # index name(name);
  index name => [qw/ name name2 /]; # index name(name, name2)

=head2 unique

  unique 'name'; # unique name(name);
  unique name => [qw/ name name2 /]; # unique name(name, name2)

=head2 add_method

A method is added to Row class which install_model created.

  add_method show_name => sub {
      my $row = shift;
      printf "Show %s\n", $row->name;
  };
  
  $row->name('yappo');
  $row->show_name; # print "Show yappo\n"

=head2 schema_options

some option to schema is added.

It is used when using InnoDB in MySQL.

  schema_options create_sql_attributes => {
      mysql => 'TYPE=InnoDB',
  };

=head1 COLUMN OPTIONS

The option which can be used in a column definition.

Pasted the definition of ParamsValidate. It writes later.

=head2 size

                size   => {
                    type     => SCALAR,
                    regex    => qr/\A[0-9]+\z/,
                    optional => 1,
                },

=head2 required

                required   => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 null

                null       => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 signed

                signed     => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 unsigned

                unsigned   => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 decimals

                decimals   => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 zerofill

                zerofill   => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 binary

                binary     => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 ascii

                ascii      => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 unicode

                unicode    => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 default

                default    => {
                    type     => SCALAR | CODEREF,
                    optional => 1,
                },

=head2 auto_increment

                auto_increment => {
                    type     => BOOLEAN,
                    optional => 1,
                },

=head2 inflate

                inflate => {
                    type     => SCALAR | CODEREF,
                    optional => 1,
                },

=head2 deflate

                deflate => {
                    type     => SCALAR | CODEREF,
                    optional => 1,
                },


=head1 COLUMN SUGAR

UNDOCUMENTED

  package Mock::ColumnSugar;
  use strict;
  use warnings;
  use base 'Data::Model';
  use Data::Model::Schema sugar => 'column_sugar';
  
  column_sugar 'author.id'
      => 'int' => +{
          unsigned => 1,
          required => 1, # we can used to require or required
      };
  column_sugar 'author.name'
      => 'varchar' => +{
          size    => 128,
          require => 1,
      };
  
  column_sugar 'book.id'
      => 'int' => +{
          unsigned => 1,
          require  => 1,
      };
  column_sugar 'book.title'
      => 'varchar' => +{
          size    => 255,
          require => 1,
      };
  column_sugar 'book.description'
      => 'text' => +{
          require => 1,
          default => 'not yet writing'
      };
  column_sugar 'book.recommend'
      => 'text';
  
  
  install_model author => schema {
      driver $main::DRIVER;
      key 'id';
  
      column 'author.id' => { auto_increment => 1 }; # column name is id
      column 'author.name'; # column name is name
  };
  
  install_model book => schema {
      driver $main::DRIVER;
      key 'id';
      index 'author_id';
  
      column 'book.id'   => { auto_increment => 1 }; # column name is id
      column 'author.id'; # column name is author_id
      column 'author.id' => 'sub_author_id' => { required => 0 }; # column name is sub_author_id
      column 'book.title'; # column name is title
      column 'book.description'; # column name is description
      column 'book.recommend'; # column name is recommend
  };

=head1 AUTHOR

Kazuhiro Osawa E<lt>yappo <at> shibuya <döt> plE<gt>

=head1 LICENSE

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut