Sack

Minimalistic Database Layer providing Models, Validation and Relationships

Presentation

This library provides a small persistence framework, including models (programmatic description of entities), validation rules and relationships between models.

Installation

Gemfile

gem 'sack'

Terminal

gem install -V sack

Usage

Sack may be used at different levels to achieve various levels of complexity. Let's explore the major uses.

Most basic

In its simplest form, Sack provides a direct CRUD to any database, as long as you provide it with schema information, a connector and a connection string.

# Define the Schema
SCHEMA = {

    # 'user' Table
    user: {

        # ID - Integer (:int) Primary-Key (:pk) Auto-Increment (:ai)
        id: [:int, :pk, :ai],

        # Name - String
        name: [:str]
    }
}

# Create a Database object using the SQLite3 Connector
db = Sack::Database.new Sack::Connectors::SQLite3Connector, 'example.db', SCHEMA

# Create
db.create :user, name: 'foobar'

# Fetch by Field
u = db.fetch_by(:user, :name, 'foobar').first

# Fetch by ID
u = db.fetch(:user, u[:id]).first

# Fetch All
users = db.fetch_all :user

# Find (fetch by ID - first result)
u = db.find :user, u[:id]

# Find by Field
u = db.find_by :user, :name, 'foobar'

# Count
user_count = db.count :user

# Update
db.update :user, u[:id], name: 'John Doe'

# Save (Combined Create / Update)
u[:name] = 'Jane Doe'
db.save :user, u

# Delete
db.delete :user, u[:id]

Model-based Schema

Defining a Sack Schema by hand can be painful. Also, the concept of relationships between entities is not available. Finally, all validation has to be performed by the application.

Another option is to use Sack's Model abstraction, providing schema generation, validation and relationships.

Each model is defined using a module which includes Sack::Database::Model, placed inside a root module.

The schema for the whole entity model can then be derived from this root module using Sack::Schema.from_module.

The module presented above can be re-written as such:

# Model Root
module DataModel

    # Users
    module User
        include Sack::Database::Model

        # Fields
        field id: [:int, :pk, :ai]
        field name: [:str]
    end
end

# Create a Database object using the SQLite3 Connector and a Schema derived from our DataModel
db = Sack::Database.new Sack::Connectors::SQLite3Connector, 'example.db', Sack::Database::Schema.from_module(DataModel)

Validation

The Model abstraction provided by Sack offers validation, which can be specified directly on the fields defined in each model. Let's add some validation on our user model:

# Users
module User
    include Sack::Database::Model

    # Fields
    field id: [:int, :pk, :ai]
    field name: [:str],
          required: true,
          unique: true,
          min_length: 3,
          max_length: 24,
          regex: /^[a-zA-Z][a-zA-Z0-9_-]+$/,
          validate: :no_prefix

    # Custom Validation Method - No Prefix
    # @param [Database] db Database instance
    # @param [Hash] data The entity being validated
    # @param [Symbol] name The field being validated
    # @param [Object] val The field's value
    # @param [Hash] rules The field's validation rules hash (possibly containing any custom validation params)
    # @param [Array] errors The list of errors for the entity being validated (if no error is added but the method returns false, a generic error message will be added by Sack)
    # @return [Object] true / false (true = valid)
    def self.no_prefix db, data, name, val, rules, errors
        valid = !(/^mr|ms|dr|mrs /i =~ val)
        errors << "Field [#{name}] should not include prefixes such as mr. ms. dr. etc..."
        valid
    end
end

Note: The unique validator can be either set to true to validate global unicity, or it can be set to an array of other fields to scope unicity.

Validity can then be checked by the application at any time using the is_valid? method injected into every model.

User.is_valid? db, name: 'foobar'
# => true

User.is_valid? db, name: nil
# => false

errors = []
User.is_valid?(db, { name: '000' }, errors)
# => false

errors.each { |e| puts e }
# Field [name] doesn't match allowed pattern (/^[a-zA-Z][a-zA-Z0-9_-]+$/)

Relationships

Using the Model abstraction, we can define relationships between our entities. Let's add an article model and define a one-to-many relationship from users to articles.

# User Model
module User
    include Sack::Database::Model

    # Fields
    field id: [:int, :pk, :ai]
    field name: [:str]

    # Relationships
    has_many articles: :article, fk: :author
end

# Article Model
module Article
    include Sack::Database::Model

    # Fields
    field id: [:int, :pk, :ai]
    field author: [:int]
    field title: [:str]
    field body: [:txt]

    # Relationships
    belongs_to author: :user
end

This then allows us to use the relationships to simplify our lives a little.

# Fetching association with direct parameters
articles = User.articles db, id: 0

# Fetching association with entity
u = User.find db, id
articles = User.articles db, u

a = articles.first
u = Article.author db, a

This is nice, but we can do better. Actually, although every entity handled by Sack is really just a Hash (to keep things simple), the models also inject a small router into any entity it loads. This allows to access an entity's associations directly, and through multiple levels.

u = User.find db, id

articles = u.articles(db)
u = articles.first.author(db)

# We can recurse as many levels as we want
articles = u.articles(db).first.author(db).articles(db).first.author(db).articles(db)

Belongs To

The belongs-to relationship injects a method in the model, with the name of the association. This method allows fetching the associated entity. The name of the association MUST MATCH the name of the field storing the key to the associated entity.

If given ONLY a name, belongs_to will auto-guess the name of the associated model (CamelCased version of the association's name). To alter this behavior (as in the example above - the association is called 'author' but the target model is actually 'user'), simply provide a model name:

module Foo
    include Sack::Database::Model
    field id: [:int, :pk, :ai]
end

module Bar
    include Sack::Database::Model
    field id: [:int, :pk, :ai]

    # A 'Bar' belongs to two 'Foo's
    field foo: [:int]
    field other_foo: [:int]

    # Target model is Foo - no need to specify it
    belongs_to :foo

    # Explicitly use Foo as the target model
    belongs_to other_foo: :foo
end

Has Many

The has-many relationship injects a method in the model, with the name of the association. This method allows fetching the associated entities. The name of the association can be anything. The target model NEEDS to be specified.

Unless explicitly specified, Sack will use the name of the current model to determine the foreign key (the field within the target model which holds the ID of the current model).

Delete actions can be defined on 'has_many' relationships:

  • detach (nullifies the foreign key)
  • delete (removes the related entities)
module Foo
    include Sack::Database::Model
    field id: [:int, :pk, :ai]

    # Foreign Key in Bar is 'foo'
    has_many bars: :bar, on_delete: :detach

    # Foreign Key in Bork is 'parent'
    has_many borks: :bork, fk: :parent, on_delete: :delete
end

module Bar
    include Sack::Database::Model
    field id: [:int, :pk, :ai]
    field foo: [:int]   
    belongs_to :foo
end

module Bork
    include Sack::Database::Model
    field id: [:int, :pk, :ai]
    field parent: [:int]
    belongs_to parent: :foo
end

Schema Migrations

As your application evolves, your database schema may change over time.

Unlike many popular frameworks, the concept of 'migrations' does not exist with Sack, at least not in the 'traditional' sense where one would create migration files describing changes to be applied in sequence.

Sack will simply always try to keep your database up to date with your schema. Upon initialization, Sack verifies the database against the schema defined by your application. The migration policy is simple: anything that is not present gets added.

New tables will be created, as well as new fields (columns) in already-existing tables.

HOWEVER it must be noted that things only get added. Tables and fields are NEVER removed. Existing fields are NEVER modified. This keeps the concept of migration VERY simple.

License

The gem is available as open source under the terms of the MIT License.