Installation

# Add this to your shard.yml
dependencies:
  active-model:
    github: spider-gazelle/active-model
    version: ~> 4.5.0

Then run:

shards install

shard.yml

Crystal
>= 1.0.0
License
MIT
Authors
Stephen von Takach <steve@place.tech>, Caspian Baska <caspian@place.tech>, Duke Nguyen <duke@place.tech>

Dependencies

Runtime Dependencies

  • db*github: crystal-lang/crystal-db
  • sanitize*github: straight-shoota/sanitize, branch: master
  • http-params-serializable~> 0.5github: place-labs/http-params-serializable

Development Dependencies

  • ameba*github: crystal-ameba/amebadev

README

# Spider-Gazelle ActiveModel

[![CI](https://github.com/spider-gazelle/active-model/actions/workflows/CI.yml/badge.svg)](https://github.com/spider-gazelle/active-model/actions/workflows/CI.yml)
[![Crystal Version](https://img.shields.io/badge/crystal%20-1.0.0-brightgreen.svg)](https://crystal-lang.org/api/1.0.0/)

Active Model provides a known set of interfaces for usage in model classes. Active Model also helps with building custom ORMs.

## Usage

Please also checkout the [detailed guide](https://spider-gazelle.net/#/models/basics).

### Active Model

ActiveModel::Model should be used as the base class for your ORM

```crystal
require "active-model"

class Person < ActiveModel::Model
  attribute name : String = "default value"
  attribute age : Int32
end

p = Person.from_json("\"name\": \"Bob Jane\"")
p.name # => "Bob Jane"
p.to_json # => "\"name\":\"Bob Jane\""
p.attributes # => {:name => "Bob Jane", :age => nil}

p.age = 32
p.attributes # => {:name => "Bob Jane", :age => 32}
```

The `attribute` macro takes two parameters. The field name with type and an optional default value.

You can also define enum attributes!<br>
The default serialisation for enums is to a downcased string. Use [`Enum::ValueConverter(T)`](https://crystal-lang.org/api/latest/Enum/ValueConverter.html) if you want to serialise to the value backing members of the enum.

```crystal
require "active-model"

class Order < ActiveModel::Model
  enum Product
   Fries
   Burger
  end

  enum Size
    Medium
    ExtraMedium
  end

  attribute product : Product = Product::Fries
  attribute size : Size = Size::ExtraMedium, converter: Enum::ValueConverter(Size)
end
```

#### Validations

ActiveModel::Validators is a mix-in that you include in your class. Similar to those supported by Rails: <http://guides.rubyonrails.org/active_record_validations.html>

```crystal
require "active-model"

class Person < ActiveModel::Model
  include ActiveModel::Validation

  attribute name : String
  attribute age : Int32

  validates :name, presence: true, length: { minimum: 3 }
  validates :age, presence: true, numericality: {greater_than: 5}
end
```

The `validate` macro takes three parameters. The symbol of the field and the message that will display when the validation fails. The third is a `Proc` that is provided an instance of `self` and returns either true or false.

To check to see if your instance is valid, call `valid?`. Each Proc will be called and if any of them fails, an `errors` Array with the messages is returned.

If no Symbol is provided as a first parameter, the errors will be added to the `:base` field.

```crystal
person = Person.new(name: "JD")
person.valid?.should eq false
person.errors[0].to_s.should eq "Name is too short"
```

#### Dirty Checking

Changes to attributes are tracked throughout the lifetime of the model. Similar to Rails: <http://api.rubyonrails.org/classes/ActiveModel/Dirty.html>

```crystal
person = Person.new(name: "JD")
person.changed? # => true
person.changed_attributes # => {:name => "JD"}
person.name_changed? # => true
person.name_change # => {nil, "JD"}
person.name_was # => nil

person.clear_changes_information
person.changed? # => false
```

#### Callbacks

Register before/after callbacks for `create`, `update`, `delete`, `save` methods. You must define the method you wish to register callbacks for.<br>
Registered callbacks are invoked through wrapping crud logic with the `run_create_callbacks`, `run_update_callbacks`, etc. functions

```crystal
require "active-model"

class Person < ActiveModel::Model
  include ActiveModel::Callbacks

  attribute name : String
  attribute age : Int32

  before_save :capitalize

  def capitalize
    @name = @name.capitalize
  end

  def save
    run_save_callbacks do
      # save to database
      @foo.save(attributes)
    end
  end
end
```

#### Serialization

The `serialization_group` argument to `attribute` accepts an `Array(Symbol)` or `Symbol`.
This will include the attribute in a generated serializer, `#to_<group>_json`.

The `define_to_json` macro allows for defining subset serializations via `only` and `except` arguments. The `methods` argument allows for inclusion of instance methods in the serializer.

```crystal
require "active-model"

class SerializationGroups < ActiveModel::Model
  attribute everywhere : String = "hi", serialization_group: [:admin, :user, :public]
  attribute joined : Int64 = 0, serialization_group: [:admin, :user]
  attribute mates : Int64 = 0, serialization_group: :user
  attribute another : String = "ok"

  define_to_json :some, only: [:joined, :another]
  define_to_json :most, except: :everywhere
  define_to_json :method, only: :joined, methods: :foo

  getter foo = "foo"
end

m = SerializationGroups.new
m.to_public_json # {"everywhere":"hi"}
m.to_admin_json  # {"everywhere":"hi","joined":0}
m.to_user_json   # {"everywhere":"hi","joined":0,"mates":1}
m.to_some_json   # {"joined":0,"another":"ok"}
m.to_most_json   # {"joined":0,"mates":0,"another":"ok"}
m.to_method_json # {"joined":0,"foo":"foo"}
```

#### Sanitization

Use the `sanitize:` option on `attribute` to automatically strip or clean HTML content from string fields and from arbitrarily-nested containers of strings.
Sanitization is applied on every write path — constructors, setters, JSON/YAML deserialization, and HTTP params.

Supported policies:

| Policy | Behaviour |
|--------|-----------|
| `:text` | Strips **all** HTML tags, returning plain text |
| `:basic` | Allows basic formatting (`<b>`, `<i>`, etc.) |
| `:inline` | Allows inline elements, strips block-level elements |
| `:common` | Allows common safe HTML (`<p>`, `<b>`, `<i>`, etc.) |

Supported field types (each may also be wrapped in `?` to make it nilable):

| Type | Behaviour |
|------|-----------|
| `String` | Sanitize the value |
| `JSON::Any` | Walk the tree; sanitize string leaves only. Non-string scalars (numbers, booleans, nulls) and object keys pass through |
| `Array(T)` | Sanitize each element; length and order are preserved |
| `Set(T)` | Sanitize each element; the set may shrink if two values collapse to the same sanitized string. Use `Array(T)` if order/length matter |
| `Hash(K, V)` | Sanitize each value; keys are left untouched (`K` can be any type) |
| `Union` (e.g. `String \| Int32`) | At least one arm must be sanitizable; non-sanitizable arms pass through at runtime |

Element types inside containers can themselves be any sanitizable type, so `Array(Hash(String, Array(String)))` is supported.

```/dev/null/example.cr#L1-18
require "active-model"

class Article < ActiveModel::Model
  attribute title : String, sanitize: :text
  attribute body : String?, sanitize: :common
  attribute tags : Array(String), sanitize: :text
  attribute keywords : Set(String), sanitize: :text
  attribute metadata : Hash(String, String), sanitize: :common
end

article = Article.new(title: "<b>Hello</b> World", tags: ["<b>one</b>", "<i>two</i>"])
article.title # => "Hello World"
article.tags  # => ["one", "two"]

article.body = "<p>Safe</p><script>alert('xss')</script>"
article.body # => "<p>Safe</p>"
```

The `sanitize:` option is only valid for the field types listed above. Attempting to use it on a type with no sanitizable leaves (e.g. `Int32`, `Array(Int32)`, `Hash(String, Int32)`) produces a compile-time error.

##### Custom and rare types

For types not in the built-in list — your own classes, or stdlib types like `Tuple`, `NamedTuple`, `Deque`, `Range` — opt in by defining a wrapper that includes `ActiveModel::Sanitizable` and implements `sanitize(policy : Symbol) : self`. The macro walker accepts any type that includes the module, and the runtime delegates to the type's own `sanitize` method:

```/dev/null/sanitizable.cr#L1-14
class Address
  include ActiveModel::Sanitizable
  property street : String
  property city : String

  def initialize(@street : String, @city : String); end

  def sanitize(policy : Symbol) : self
    @street = ActiveModel::Sanitizer.sanitize(@street, policy)
    @city = ActiveModel::Sanitizer.sanitize(@city, policy)
    self
  end
end

class Order < ActiveModel::Model
  attribute address : Address, sanitize: :text
  attribute addresses : Array(Address), sanitize: :text # nesting works
end
```

For stdlib generic types (`Tuple`, `Range`, etc.), prefer wrapping in a `Sanitizable` struct rather than reopening the stdlib type — reopening makes every instance globally satisfy `is_a?(Sanitizable)` and demand the abstract method.

`JSON::Any` is also a universal fallback: any unusual JSON-shaped payload can be modelled as `JSON::Any` and sanitized.