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]
# 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. 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.(db)
# We can recurse as many levels as we want
articles = u.articles(db).first.(db).articles(db).first.(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).
module Foo
include Sack::Database::Model
field id: [:int, :pk, :ai]
# Foreign Key in Bar is 'foo'
has_many bars: :bar
# Foreign Key in Bork is 'parent'
has_many borks: :bork, fk: :parent
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.