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)