NAME

Punk::Model - the storage-agnostic model tier

SYNOPSIS

package MyApp::Model::Book;
use Punk::Model;

table 'books';
field id      => { type => 'integer', primary => 1 };
field title   => { type => 'string', required => 1, minLength => 1 };
field author  => { type => 'string' };
field created => { type => 'string' };

1;

# in the app
database dsn => 'dbi:SQLite:dbname=myapp.db';
model    'Book';

# in a controller
my $book = $c->model('Book')->get(id => $c->param('id'));
my $page = $c->model('Book')->search({ author => 'Gibson' },
                                     { limit => 20 });

DESCRIPTION

A model class uses Punk::Model, names its table and its fields, and inherits a fixed six-method contract that delegates to a storage backend - Punk::Model::DBI by default. Rows are plain hashrefs: fast, and directly JSON-encodable by a controller.

The class is registered with model 'Book' in the app; the instance is built once per worker on first $c->model('Book') and cached (fork-safe). Backends swap with database backend => 'Class' - any class honouring the contract works.

DECLARING A MODEL

table $name

The backing table (or collection) name. Required.

field $name => \%spec

One field. The spec is JSON-Schema-flavoured; primary => 1 marks the primary key (used for ordering and keyset pagination), required marks it required for create, and the schema keywords type, format, pattern, enum, minLength/maxLength, minimum/maximum, multipleOf, minItems/maxItems flow into the validator. With no field marked primary an id field is assumed.

validate $bool

Force create/update validation on or off. The default is on when any field carries a constraint (required or a schema keyword), off otherwise.

database $name

The configured database this model lives in - one of the names given to the app's database keyword. Defaults to the unnamed default database. Every model on the same database shares one connection per worker.

THE CONTRACT

get(%key)                  -> row hashref | undef
search(\%filter, \%opts)   -> { rows => [...], has_more_data => 0|1,
                                next => $token | undef }
all()                      -> search({}, {})
create(\%data)             -> created row hashref
update(\%key_and_changes)  -> updated row hashref
delete(%key)               -> count

create validates \%data against the field schema (required included); update validates the changes (the primary key excluded, required relaxed). A validation failure croaks. search options are the backend's; Punk::Model::DBI takes limit and an opaque after pagination token.

METHODS

get(%key)

The row named by %key (usually the primary key) as a hashref, or undef.

search(\%filter, \%opts)

The { rows, has_more_data, next } page for the equality %filter and backend %opts (Punk::Model::DBI takes limit and after).

all

search({}, {}) - every row, first page.

create(\%data)

Validate (when the model has constraints) and insert; the stored row.

update(\%key_and_changes)

Validate the changes and update the row named by the primary key; the stored row.

delete(%key)

Delete the row(s) named by %key; the affected count.

backend

The backend instance the contract delegates to.

meta

The compiled class metadata (table, fields, primary key).

CUSTOM METHODS

A model class is an ordinary package - add your own methods. The instance $c->model('Book') hands back is blessed into the model class, so a method receives it as its invocant and can call the contract (and the backend) directly:

package MyApp::Model::Book;
use Punk::Model;

table 'books';
field id     => { type => 'integer', primary => 1 };
field title  => { type => 'string', required => 1 };
field author => { type => 'string' };

sub by_author {
    my ($self, $who) = @_;
    return $self->search({ author => $who }, { limit => 50 })->{rows};
}

sub latest {
    my ($self) = @_;
    return $self->search({}, { limit => 1 })->{rows}[0];
}

# in a controller
my $books = $c->model('Book')->by_author('Gibson');

Your methods sit alongside the six contract methods; keep query logic here rather than in controllers. Everything the contract exposes - search, get, create, $self->backend, $self->meta - is available to them.

WRITING A BACKEND

Punk::Model::DBI is the default backend, not the only one. A backend is any class implementing the six-method contract; point a database at it with database $name => { backend => 'Class', ... } and a model reaches it by selecting that database ("database"). This is how a model tier over something other than SQL - a search index, a document store, an HTTP service - plugs in without touching the framework.

The class must provide a constructor and the six methods:

package Punk::Model::ElasticSearch;

# Built once per worker by Punk::Model. %args carries:
#   database => \%conn   the database options minus `backend`
#                        (your dsn / nodes / auth / ...)
#   table    => $name    the model's table keyword (here: the index)
#   primary  => $field   the primary-key field name
#   columns  => \@names  the declared field names, in order
sub new {
    my ($class, %args) = @_;
    bless { ... }, $class;
}

sub get    { my ($self, %key) = @_;  ... }   # row hashref | undef
sub search { my ($self, $filter, $opts) = @_;
             ...
             return { rows => \@rows, has_more_data => 0|1,
                      next => $token|undef };
}
sub all    { $_[0]->search({}, {}) }
sub create { my ($self, $data) = @_;  ...; return \%row }
sub update { my ($self, $data) = @_;  ...; return \%row }
sub delete { my ($self, %key) = @_;   ...; return $count }

Contract notes: rows are plain hashrefs; search returns the { rows, has_more_data, next } page (next an opaque token your own search understands via $opts->{after}, or undef); create and update return the stored row; delete returns a count. Field validation happens in Punk::Model before create/update are called, so a backend never re-validates. Nothing else is required - no base class, no use Punk::Model.

SEE ALSO

Punk::Model::DBI, Punk, Punk::Context, JSON::Schema::Fast.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)