pika-clear

A companion shard that bridges Pika and Clear, a PostgreSQL ORM for Crystal

pika-clear

CI

Clear ORM integration bridge for Pika.

Pika's core is ORM-agnostic. This shard adds first-class Clear support: it auto-derives request validation rules, entity field exposure, and OpenAPI schemas directly from your Clear::Model column definitions, eliminating the need to keep params blocks and entity classes in sync with your database schema.


What it provides

Feature Description
Pika::Clear::Model Mixin that generates a PIKA_COLUMNS compile-time schema constant from Clear column annotations
expose_clear_model Entity macro that exposes fields derived from PIKA_COLUMNS
params_from ModelClass Derives a params block from a model's column schema
paginate Wraps a Clear query with LIMIT/OFFSET and returns a standard JSON envelope
Pika::ValidationError.from_clear_model Converts Clear model validation errors to Pika 422 responses
Pika::Clear.map_db_error Maps database exceptions (unique violation, FK error, etc.) to Pika error classes

Installation

Add both shards to your shard.yml:

dependencies:
  pika:
    github: tekanic/pika
    version: "~> 0.1"
  pika-clear:
    github: tekanic/pika-clear
    version: "~> 0.1"

Then run shards install.

Require pika-clear after pika in your application:

require "pika"
require "pika-clear"

Model setup

Include Pika::Clear::Model alongside Clear::Model in each model you want to use with pika-clear. Order matters — include Clear::Model first so its column annotations are in place before Pika::Clear::Model's macro finished runs.

class User
  include Clear::Model
  include Pika::Clear::Model

  self.table = "users"

  column id    : Int64,  primary: true
  column email : String
  column name  : String
  column age   : Int32?   # nilable → becomes optional in params_from
  column role  : String
  timestamps
end

Pika::Clear::Model introspects @[Clear::Column]-annotated instance variables at compile time and generates a constant:

User::PIKA_COLUMNS
# => [
#   {name: "email", type_str: "String", nilable: false, oa_kind: "string"},
#   {name: "name",  type_str: "String", nilable: false, oa_kind: "string"},
#   {name: "age",   type_str: "Int32?", nilable: true,  oa_kind: "integer"},
#   {name: "role",  type_str: "String", nilable: false, oa_kind: "string"},
# ]

This constant is what params_from, expose_clear_model, and paginate all read. It is generated at compile time — there is no runtime reflection.


Entities

Use expose_clear_model inside a Pika::Entity(T) subclass to derive field exposure from PIKA_COLUMNS. Fields not in PIKA_COLUMNS (internal fields, associations) are never exposed unless you add them explicitly.

class UserEntity < Pika::Entity(User)
  expose_clear_model User, except: [:role]
end

expose_clear_model generates both represent(obj) and represent(collection) methods, so the entity works for single objects and arrays without extra configuration.

Use in a handler with Pika's present:

get do
  user = User.query.find!(declared_params.id)
  present user, using: UserEntity
end

Request validation with params_from

params_from reads PIKA_COLUMNS and synthesises a params block at compile time. Non-nilable columns become requires, nilable columns become optional. Use only: or except: to limit the fields included.

resource :users do
  desc "Create a user"
  params_from User, except: [:id, :created_at, :updated_at]
  post do
    user = User.new
    user.email = declared_params.email
    user.name  = declared_params.name
    user.age   = declared_params.age    # Int32? — nil if not provided
    user.save!
    present user, using: UserEntity
  end
end

params_from and a hand-written params block can coexist in the same route — place them consecutively and Pika merges the fields.


Pagination

paginate applies page and per_page to a Clear query scope and returns a standard JSON envelope with data and meta keys.

resource :users do
  params do
    optional page     : Int32 = 1
    optional per_page : Int32 = 25
  end
  get do
    paginate(User.query, using: UserEntity,
             page: declared_params.page,
             per_page: declared_params.per_page)
  end
end

Response:

{
  "data": [
    { "id": 1, "email": "alice@example.com", "name": "Alice" },
    { "id": 2, "email": "bob@example.com",   "name": "Bob"   }
  ],
  "meta": {
    "total": 120,
    "page": 1,
    "per_page": 25,
    "pages": 5
  }
}

per_page is clamped to a maximum of 100 regardless of the value provided.


Error mapping

Clear model validation errors → 422

post do
  user = User.build(declared_params)
  raise Pika::ValidationError.from_clear_model(user) unless user.valid?
  user.save!
  present user, using: UserEntity
end

from_clear_model reads user.errors (Clear's validation error list) and converts each entry to Pika's {field:, message:} format, returning a Pika::ValidationError that Pika renders as a 422 with a structured errors array.

Database exceptions → Pika errors

rescue e : Exception
  raise Pika::Clear.map_db_error(e)
end
DB exception message Pika error Status
duplicate key value violates unique constraint Pika::ConflictError 409
violates foreign key constraint Pika::ValidationError 422
anything else Pika::Error 500

Full example

require "pika"
require "pika-clear"

class User
  include Clear::Model
  include Pika::Clear::Model

  self.table = "users"

  column id    : Int64,  primary: true
  column email : String
  column name  : String
  column age   : Int32?
  column role  : String
  timestamps
end

class UserEntity < Pika::Entity(User)
  expose_clear_model User, except: [:role]
end

class UsersAPI < Pika::API
  version "v1"
  info title: "Users API", version: "1.0.0"
  docs at: "/docs"

  resource :users do
    params do
      optional page     : Int32 = 1
      optional per_page : Int32 = 25
    end
    get do
      paginate(User.query, using: UserEntity,
               page: declared_params.page,
               per_page: declared_params.per_page)
    end

    desc "Create a user"
    params_from User, except: [:id, :created_at, :updated_at]
    post do
      user = User.new
      user.email = declared_params.email
      user.name  = declared_params.name
      user.age   = declared_params.age
      raise Pika::ValidationError.from_clear_model(user) unless user.valid?
      user.save!
      present user, using: UserEntity
    rescue e : Exception
      raise Pika::Clear.map_db_error(e)
    end

    route_param :id do
      get do
        user = User.query.find!(declared_params.id)
        present user, using: UserEntity
      rescue Clear::Model::RecordNotFoundError
        raise Pika::NotFoundError.new("User not found")
      end
    end
  end
end

UsersAPI.run

Development

Specs run without a live database or a real Clear install. The spec suite stubs out Clear::Model and tests all pika-clear features against those stubs.

shards install
crystal spec

License

MIT

Repository

pika-clear

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 2
  • about 4 hours ago
  • May 5, 2026
License

Links
Synced at

Tue, 05 May 2026 11:19:00 GMT

Languages