doc/development/fixed_items_model.md
Use ActiveRecord::FixedItemsModel to define static, read-only data in code
instead of database tables. Instances behave like ActiveRecord objects but are
stored in memory with deterministic, version-controlled IDs.
This pattern replaces database-backed lookup tables where:
Use FixedItemsModel when:
The dataset does not have to be small or statically defined. Use the
.fixed_items class method to compute items dynamically from other sources.
For example, WidgetDefinition generates its items by iterating all work
item types and their widget configurations. Use auto_generate_ids! when
the items don't need stable IDs for the persistence layer.
Do not use FixedItemsModel when:
has_many or has_many :through.Database tables with auto-incrementing sequences produce different IDs on
different Cells for the same logical entity. Cross-cell references break
because Cell A's plan_id = 4 might mean premium while Cell B's
plan_id = 4 might mean gold.
FixedItemsModel solves this by hard-coding IDs in application code. Every
Cell loads the same definitions and produces the same IDs. For more details,
see the static data section of the Cells development guidelines.
ITEMS constantThe simplest pattern defines items inline:
module Security
class StaticTrainingProvider
include ActiveRecord::FixedItemsModel::Model
ITEMS = [
{ id: 1, name: "Kontra", url: "https://application.security/api/webhook/gitlab/exercises/search" },
{ id: 2, name: "Secure Code Warrior", url: "https://integration-api.securecodewarrior.com/api/v1/trial" },
{ id: 3, name: "SecureFlag", url: "https://knowledge-base-api.secureflag.com/gitlab" }
].freeze
attribute :name, :string
attribute :url, :string
end
end
Each item must have an id key with a positive integer value. Declare each
non-ID attribute with attribute. The id attribute is declared automatically.
You must use either an ITEMS constant or a .fixed_items class method, not both.
.fixed_itemsUse a class method when items are derived from other sources:
module WorkItems
module TypesFramework
module SystemDefined
class Type
include ActiveRecord::FixedItemsModel::Model
attribute :name, :string
attribute :base_type, :string
attribute :icon_name, :string
class << self
def fixed_items
[
Definitions::Issue.configuration,
Definitions::Incident.configuration,
Definitions::Task.configuration,
Definitions::Ticket.configuration
]
end
end
end
end
end
end
Each definition class returns a hash with id, name, base_type, and
icon_name keys. This pattern keeps the data definition close to the
domain logic for each type.
Use auto_generate_ids! when items don't need stable, externally-referenced IDs.
IDs are assigned sequentially starting at 1 based on array order. This is useful
when you need the ActiveRecord-like query interface (find_by, where, all)
but the objects are internal and their IDs are never persisted or exposed through
an API.
WidgetDefinition is a good example. It dynamically generates its items from all
work item types and their widget configurations. The IDs are throwaway handles —
what matters is the combination of widget_type and work_item_type_id:
class WidgetDefinition
include ActiveRecord::FixedItemsModel::Model
include ActiveRecord::FixedItemsModel::HasOne
auto_generate_ids!
attribute :widget_type, :string
attribute :work_item_type_id, :integer
belongs_to_fixed_items :work_item_type,
fixed_items_class: WorkItems::TypesFramework::SystemDefined::Type
class << self
def fixed_items
Type.all.flat_map do |type|
type.configuration_class.widgets.map do |widget_type|
{ widget_type: widget_type.to_s, work_item_type_id: type.id }
end
end
end
end
end
[!note] With
auto_generate_ids!, changing the order of items changes their IDs. Do not use this when IDs are stored in the database or referenced externally.
When assigning explicit IDs, reserve ID ranges if multiple teams might add items independently. For example, work item types use IDs 1-9 for system-defined types and 1001+ for custom types stored in the database.
FixedItemsModel provides an ActiveRecord-like query interface:
# Find by ID (raises RecordNotFound if not found)
Security::StaticTrainingProvider.find(1)
# Find by attributes (returns nil if not found)
Security::StaticTrainingProvider.find_by(name: "Kontra")
# Filter by attributes (returns an array)
Security::StaticTrainingProvider.where(name: "Kontra")
# All items
Security::StaticTrainingProvider.all
# Iterate
Security::StaticTrainingProvider.find_each { |provider| puts provider.name }
The where method supports multiple conditions and array values:
WorkItems::TypesFramework::SystemDefined::Type.where(base_type: %w[issue incident])
WorkItems::TypesFramework::SystemDefined::Type.where(base_type: :issue, icon_name: 'issue-type-issue')
Chaining is not supported. The where method returns an Array, not a
relation. Pass all conditions to a single where call, or add class methods
on the model for sorting, ordering, or other query logic.
Items are loaded into an in-memory cache on first access and reused for
the lifetime of the process. Repeated calls to .find or .find_by with
the same ID return the same object instance (identity equality, not just
value equality):
a = WorkItems::TypesFramework::SystemDefined::Type.find(1)
b = WorkItems::TypesFramework::SystemDefined::Type.find(1)
a.equal?(b) # => true, same object in memory
Use ActiveRecord::FixedItemsModel::HasOne to create a belongs_to-style
association from an ActiveRecord model to a fixed items model.
class CurrentStatus < ApplicationRecord
include ActiveRecord::FixedItemsModel::HasOne
belongs_to_fixed_items :system_defined_status,
fixed_items_class: WorkItems::Statuses::SystemDefined::Status,
foreign_key: 'system_defined_status_identifier'
end
The association provides getter, setter, and query methods:
status = CurrentStatus.last
status.system_defined_status # Returns the fixed items model instance
status.system_defined_status = Status.find(2) # Sets via object
status.system_defined_status_identifier = 2 # Sets via column
status.system_defined_status? # Returns true if present
_identifier, not _idName the database column <association>_identifier rather than
<association>_id. In PostgreSQL, _id columns conventionally imply a
foreign key with database-level integrity constraints.
Fixed items models live in memory, so the database cannot enforce
referential integrity on these columns. Using _identifier makes this
distinction explicit.
Always pass foreign_key: to belongs_to_fixed_items to use the
_identifier column name:
class CustomType < ApplicationRecord
include ActiveRecord::FixedItemsModel::HasOne
belongs_to_fixed_items :converted_from_type,
fixed_items_class: WorkItems::TypesFramework::SystemDefined::Type,
foreign_key: 'converted_from_system_defined_type_identifier'
end
The association caches the resolved object and invalidates automatically when
the foreign key changes. The cache is also cleared when reset is called on
the ActiveRecord model.
To use a fixed items model with GraphQL or any other system that relies on
GlobalID, include GlobalID::Identification:
class Type
include ActiveRecord::FixedItemsModel::Model
include GlobalID::Identification
# Optional: Override if the GlobalID model name must differ from the class name
def to_global_id(_options = {})
::Gitlab::GlobalId.build(self, model_name: 'WorkItems::Type', id: id)
end
alias_method :to_gid, :to_global_id
end
Instances support as_json and to_json with :only, :except, and
:methods options:
provider = Security::StaticTrainingProvider.find(1)
provider.as_json(only: [:id, :name])
# => {"id"=>1, "name"=>"Kontra"}
provider.as_json(except: [:url])
# => {"id"=>1, "name"=>"Kontra", "description"=>"..."}
provider.as_json(methods: [:some_computed_method])
Fixed items model instances behave as persisted, read-only records:
| Method | Return value |
|---|---|
persisted? | true |
new_record? | false |
readonly? | true |
changed? | false |
destroyed? | false |
Two instances are equal if they are the same class and have the same id.
The module defines two custom error classes:
ActiveRecord::FixedItemsModel::RecordNotFound — raised by .find when no
item matches the given ID.ActiveRecord::FixedItemsModel::UnknownAttribute — raised by .find_by or
.where when a query references an attribute that is not declared.Handle RecordNotFound the same way you would handle
ActiveRecord::RecordNotFound in controllers or services.
Instances support ActiveModel::Validations. Add validations the same way as
with any ActiveModel class:
class WidgetDefinition
include ActiveRecord::FixedItemsModel::Model
attribute :widget_type, :string
attribute :work_item_type_id, :integer
validates :widget_type, presence: true
validates :work_item_type_id, presence: true
end
Items are validated at load time. An invalid item definition raises an error
during the first call to .all.
Use build, not create. Fixed items models live in memory, so the
create semantic (persist to database) does not apply. Factories use
skip_create and initialize_with to return the actual in-memory object
rather than constructing a detached instance:
FactoryBot.define do
factory :work_item_system_defined_type, class: 'WorkItems::TypesFramework::SystemDefined::Type' do
skip_create
issue
initialize_with do
WorkItems::TypesFramework::SystemDefined::Type.find(attributes[:id] || 1)
end
trait :issue do
id { 1 }
base_type { 'issue' }
end
trait :incident do
id { 2 }
base_type { 'incident' }
end
end
end
build(:work_item_system_defined_type, :issue) returns the same object as
Type.find(1). This means specs operate on the real in-memory instances,
not detached copies.
Test fixed items models the same way you test ActiveRecord models: verify
validations, class methods, instance methods, and GlobalID integration.
The difference is that you always work with a specific in-memory object
rather than an unpersisted subject:
let(:type) { build(:work_item_system_defined_type) }
it 'has name attribute' do
expect(type.name).to eq('Issue')
end
For models without a factory, call query methods directly:
let(:provider) { Security::StaticTrainingProvider.find(1) }
The FixedItemsModel implementation is part of the activerecord-gitlab gem.
For questions or changes, reach out to the Project Management group in the
Plan stage through #g_project-management
or #s_plan on Slack.
| File | Purpose |
|---|---|
gems/activerecord-gitlab/lib/active_record/fixed_items_model/model.rb | Core module: query interface, storage, validation, serialization |
gems/activerecord-gitlab/lib/active_record/fixed_items_model/has_one.rb | Association support: belongs_to_fixed_items, caching |
gems/activerecord-gitlab/spec/active_record/fixed_items_model/model_spec.rb | Specs for the core module |
gems/activerecord-gitlab/spec/active_record/fixed_items_model/has_one_spec.rb | Specs for association support |
The gem has its own dependency set. Install and run specs from the gem directory:
cd gems/activerecord-gitlab
bundle install
bundle exec rspec spec/active_record/fixed_items_model/
| Model | Domain | Pattern | Complexity |
|---|---|---|---|
Security::StaticTrainingProvider | Security training | ITEMS constant | Simple |
WorkItems::Statuses::SystemDefined::Status | Work item statuses | ITEMS constant, associations | Medium |
Ai::FoundationalChatAgent | AI agents | ITEMS constant, GlobalID, custom queries | Medium |
WorkItems::TypesFramework::SystemDefined::Type | Work item types | .fixed_items, GlobalID, dynamic predicates | Complex |
WorkItems::TypesFramework::SystemDefined::WidgetDefinition | Widget configs | auto_generate_ids!, .fixed_items, associations | Complex |