Overview
Temporal data modeling addresses the challenge of representing data that changes over time while preserving historical context. Unlike traditional data models that store only current state, temporal models maintain a complete history of changes, enabling queries across different time periods and reconstruction of past states.
Applications requiring temporal data modeling include financial systems tracking transaction history, inventory systems monitoring stock levels over time, employee databases maintaining job history, and compliance systems requiring audit trails. The fundamental challenge lies in balancing query performance, storage efficiency, and data integrity while managing time-varying information.
Temporal models distinguish between two primary time dimensions: valid time (when data is true in the real world) and transaction time (when data is recorded in the system). A record might become valid on January 1st but not be recorded until January 15th, creating a distinction between these temporal perspectives.
# Simple temporal record structure
class EmployeeHistory
attr_accessor :employee_id, :salary, :valid_from, :valid_to, :recorded_at
def current?
valid_to.nil? && valid_from <= Date.today
end
def valid_during?(date)
valid_from <= date && (valid_to.nil? || valid_to > date)
end
end
The complexity of temporal modeling increases with requirements for point-in-time queries, temporal joins between multiple changing entities, and handling of retroactive corrections. Systems must decide whether to support uni-temporal (single time dimension) or bi-temporal (both valid and transaction time) tracking based on business requirements and regulatory constraints.
Key Principles
Temporal data modeling rests on several foundational concepts that define how time-varying information is captured and queried. Understanding these principles clarifies the design decisions and trade-offs in temporal systems.
Valid Time represents when data is true in the real world, independent of when the system learns about it. An employee's salary increase might have a valid time of January 1st even if the change is entered into the system on December 15th of the previous year. Valid time typically uses ranges with start and end timestamps, where a NULL end time indicates the current state.
Transaction Time captures when data is recorded in the database. This dimension provides an audit trail of database modifications and enables point-in-time recovery. Transaction time is immutable—once recorded, historical transaction times cannot be changed. This property distinguishes transaction time from valid time, which can be adjusted retroactively.
Bi-temporal modeling combines both dimensions, creating a two-dimensional temporal space. Each fact exists at a specific point in valid time and a specific point in transaction time. This approach supports both "what did we know then?" queries (transaction time) and "what was true then?" queries (valid time), critical for compliance and audit requirements.
# Bi-temporal record structure
class BiTemporalRecord
attr_accessor :data, :valid_from, :valid_to, :transaction_from, :transaction_to
def self.query_at_time(valid_date, transaction_date)
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)', valid_date, valid_date)
.where('transaction_from <= ? AND (transaction_to IS NULL OR transaction_to > ?)',
transaction_date, transaction_date)
end
def self.current_view
where(transaction_to: nil)
end
end
Slowly Changing Dimensions (SCD) provide patterns for handling temporal changes in data warehousing. Type 1 overwrites old values, Type 2 creates new rows with temporal markers, and Type 3 maintains limited history in additional columns. Each type represents different trade-offs between history preservation and query complexity.
Point-in-time queries reconstruct the database state as it existed at a specific moment. These queries filter records based on temporal ranges, selecting only those valid at the target time. The complexity increases with bi-temporal queries that must consider both valid and transaction time dimensions.
Temporal joins connect time-varying entities by matching temporal ranges. An order joined with customer history must match the customer record valid when the order was placed, not necessarily the current customer state. These joins introduce complexity in query planning and execution.
Temporal normalization extends traditional normalization principles to temporal models. A temporally normalized design avoids redundancy across time while maintaining referential integrity. Denormalization decisions balance query performance against storage overhead and update complexity.
Implementation Approaches
Different temporal modeling strategies address varying requirements for history tracking, query patterns, and data integrity constraints. Selecting an appropriate approach depends on specific application needs and operational characteristics.
Separate History Tables maintain current records in one table and historical records in another. The current table contains only active rows with simple indexes, while the history table accumulates all changes. This approach separates operational queries (which access current data) from analytical queries (which access historical data), optimizing each workload independently.
class Employee < ApplicationRecord
after_update :archive_changes
private
def archive_changes
EmployeeHistory.create(
employee_id: id,
salary: salary_was,
department: department_was,
valid_from: updated_at_was,
valid_to: updated_at
)
end
end
class EmployeeHistory < ApplicationRecord
belongs_to :employee
def self.at_date(date)
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)', date, date)
end
end
Inline History with Status Flags embeds temporal tracking within the primary table using validity markers. Each row includes valid_from, valid_to, and is_current flags. Queries filter on is_current for operational needs and on temporal ranges for historical queries. This approach simplifies referential integrity at the cost of table size and index overhead.
Event Sourcing stores every state change as an immutable event. The current state is derived by replaying events from the beginning or from a snapshot. Events capture the intent behind changes (SalaryIncreased, EmployeePromoted) rather than just end states. This approach provides complete audit trails and enables temporal queries by replaying events up to any point in time.
class EmployeeAggregate
attr_reader :id, :state, :events
def initialize(id)
@id = id
@state = {}
@events = []
end
def load_from_event_store
EventStore.events_for_aggregate(id).each do |event|
apply_event(event, from_store: true)
end
end
def change_salary(new_salary, effective_date)
event = SalaryChanged.new(
employee_id: id,
previous_salary: state[:salary],
new_salary: new_salary,
effective_date: effective_date,
occurred_at: Time.current
)
apply_event(event)
events << event
end
def state_at(target_date)
temp_state = {}
EventStore.events_for_aggregate(id)
.select { |e| e.occurred_at <= target_date }
.each { |e| apply_event_to_state(e, temp_state) }
temp_state
end
private
def apply_event(event, from_store: false)
case event
when SalaryChanged
state[:salary] = event.new_salary
when DepartmentTransfer
state[:department] = event.new_department
end
EventStore.append(event) unless from_store
end
end
Temporal Tables with Database Support use built-in temporal features provided by databases like PostgreSQL (with temporal_tables extension) or SQL Server (system-versioned temporal tables). The database automatically maintains history tables and handles temporal queries through SQL:2011 temporal syntax. This approach offloads complexity to the database layer but requires database-specific features.
Snapshot-based Versioning creates periodic snapshots of entire tables or datasets. Each snapshot represents the complete state at a specific time. This approach suits scenarios with infrequent changes or where query patterns primarily access complete snapshots rather than individual entity histories. Storage overhead can be mitigated through compression and delta encoding between snapshots.
Design Considerations
Choosing a temporal modeling strategy requires evaluating multiple factors including query patterns, storage constraints, compliance requirements, and system complexity tolerance. Different approaches excel in different contexts.
Query Pattern Analysis determines which temporal approach fits the application. Systems dominated by current-state queries benefit from separate history tables or status-flag approaches that keep current data compact. Applications requiring frequent temporal range queries or point-in-time reconstruction favor inline temporal columns with appropriate indexes. Event-sourced systems work well when business logic depends on understanding the sequence of changes rather than just end states.
Storage and Performance Trade-offs create tension between history completeness and system efficiency. Inline temporal tables increase storage requirements and slow down current-state queries unless carefully indexed. Separate history tables isolate performance impacts but complicate cross-temporal queries and referential integrity. Event sourcing provides complete audit trails at the cost of requiring event replay for state reconstruction.
Temporal Referential Integrity poses challenges when related entities change over time. An order referencing a customer must specify which customer version applies. Systems can store point-in-time snapshots of related entities, maintain temporal foreign keys that reference specific validity periods, or accept denormalization to avoid complex temporal joins.
# Temporal foreign key approach
class Order < ApplicationRecord
# References customer state valid when order was placed
def customer_at_order_time
Customer.valid_at(ordered_at).find(customer_id)
end
end
class Customer < ApplicationRecord
scope :valid_at, ->(time) {
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)', time, time)
}
end
# Snapshot approach
class Order < ApplicationRecord
# Denormalized customer snapshot at order time
serialize :customer_snapshot, Hash
before_create :capture_customer_snapshot
private
def capture_customer_snapshot
self.customer_snapshot = {
name: customer.name,
address: customer.address,
tax_id: customer.tax_id
}
end
end
Retroactive Changes and Corrections require deciding whether to support modifying historical data. Bi-temporal models handle corrections by adding new records with updated valid times while preserving transaction time history. Uni-temporal models must choose between allowing updates (losing audit trail) or treating corrections as new events (maintaining history but complicating queries).
Temporal Granularity affects both storage and query precision. Date-level granularity suffices for many business applications but fails for high-frequency trading systems requiring microsecond precision. Coarser granularity reduces storage and simplifies queries; finer granularity increases accuracy and supports more precise temporal operations.
Handling Temporal Gaps and Overlaps requires validation logic. Most temporal models assume non-overlapping validity periods for a given entity, but business rules might permit gaps (employee between jobs) or overlaps (temporary dual role assignment). Implementing constraints and triggers prevents invalid temporal states while accommodating legitimate scenarios.
Ruby Implementation
Ruby applications implement temporal data modeling through Active Record extensions, dedicated gems, and custom temporal query patterns. Several approaches exist depending on application requirements and complexity tolerance.
Temporal Rails provides a popular gem for temporal data management in Rails applications. It extends Active Record models with temporal tracking, automatically maintaining history tables and providing temporal query methods.
# Gemfile
gem 'temporal_rails'
# Migration
class AddTemporalToEmployees < ActiveRecord::Migration[7.0]
def change
add_column :employees, :valid_from, :datetime, null: false, default: -> { 'CURRENT_TIMESTAMP' }
add_column :employees, :valid_to, :datetime
add_column :employees, :is_current, :boolean, default: true
add_index :employees, [:id, :valid_from, :valid_to]
add_index :employees, :is_current
end
end
# Model with temporal_rails
class Employee < ApplicationRecord
include TemporalRails::TemporalModel
temporal_track :salary, :department, :title
# Automatic temporal methods:
# Employee.current -> returns only current records
# Employee.at(date) -> returns records valid at date
# Employee.between(start_date, end_date) -> returns records valid in range
# employee.history -> returns all versions of this employee
end
# Usage
employee = Employee.create(name: 'Alice', salary: 80000)
employee.update(salary: 85000)
employee.update(salary: 90000)
# Query temporal history
employee.history.map(&:salary) # => [80000, 85000, 90000]
employee.at(2.months.ago).salary # => 80000
# Point-in-time queries
Employee.at(3.months.ago).where(department: 'Engineering')
Manual Temporal Implementation provides more control for specific requirements. This approach explicitly manages temporal columns and implements query scopes for temporal operations.
class Employee < ApplicationRecord
scope :current, -> { where(valid_to: nil) }
scope :valid_at, ->(date) {
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)', date, date)
}
scope :valid_between, ->(start_date, end_date) {
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)', end_date, start_date)
}
def self.close_period(employee_id, end_time)
transaction do
current_record = current.find_by(id: employee_id)
current_record.update!(valid_to: end_time)
new_record = current_record.dup
new_record.valid_from = end_time
new_record.valid_to = nil
new_record.save!
new_record
end
end
def history
self.class.where(id: id).order(:valid_from)
end
def snapshot_at(date)
history.valid_at(date).first
end
end
# Usage with explicit period management
employee = Employee.create(
name: 'Bob',
salary: 75000,
valid_from: Date.new(2024, 1, 1),
valid_to: nil
)
# Record salary change with explicit period closure
new_record = Employee.close_period(employee.id, Date.new(2024, 7, 1))
new_record.update!(salary: 80000)
# Query historical state
Employee.valid_at(Date.new(2024, 3, 15)).find_by(name: 'Bob').salary # => 75000
Employee.valid_at(Date.new(2024, 9, 15)).find_by(name: 'Bob').salary # => 80000
Event Store Implementation captures all changes as immutable events. Ruby implementations typically use gems like rails_event_store or implement custom event persistence.
# Using rails_event_store gem
class EmployeeEventHandler
def call(event)
case event
when EmployeeHired
Employee.create!(
id: event.data[:employee_id],
name: event.data[:name],
hired_at: event.metadata[:timestamp]
)
when SalaryChanged
employee = Employee.find(event.data[:employee_id])
EmployeeSalaryHistory.create!(
employee: employee,
previous_salary: employee.salary,
new_salary: event.data[:new_salary],
effective_date: event.data[:effective_date],
changed_at: event.metadata[:timestamp]
)
employee.update!(salary: event.data[:new_salary])
end
end
end
# Publishing events
event_store = RailsEventStore::Client.new
event_store.publish(
SalaryChanged.new(data: {
employee_id: 123,
new_salary: 90000,
effective_date: Date.today
}),
stream_name: "Employee$123"
)
# Rebuilding state from events
def rebuild_employee_state(employee_id, as_of_time)
events = event_store
.read
.stream("Employee$#{employee_id}")
.to_a
.select { |e| e.metadata[:timestamp] <= as_of_time }
state = {}
events.each do |event|
case event
when EmployeeHired
state[:name] = event.data[:name]
state[:hired_at] = event.data[:hired_at]
when SalaryChanged
state[:salary] = event.data[:new_salary]
when DepartmentTransferred
state[:department] = event.data[:new_department]
end
end
state
end
Bi-temporal Implementation tracks both valid time and transaction time for complete temporal history. This requires careful management of both dimensions and complex query logic.
class BiTemporalEmployee < ApplicationRecord
scope :current_perspective, -> {
where(transaction_to: nil)
}
scope :as_of_transaction_time, ->(time) {
where('transaction_from <= ? AND (transaction_to IS NULL OR transaction_to > ?)',
time, time)
}
scope :valid_during, ->(time) {
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)', time, time)
}
scope :at_both_times, ->(valid_time, transaction_time) {
valid_during(valid_time).as_of_transaction_time(transaction_time)
}
def self.record_change(employee_id, new_attributes, effective_date)
transaction do
# Close current transaction time period
current = current_perspective
.where(id: employee_id)
.valid_during(Time.current)
.first
if current
current.update!(transaction_to: Time.current)
end
# Create new record with updated attributes
create!(
id: employee_id,
**new_attributes,
valid_from: effective_date,
valid_to: nil,
transaction_from: Time.current,
transaction_to: nil
)
end
end
# Answer "What did we think Bob's salary was on June 1st,
# based on what we knew on July 15th?"
def self.historical_perspective(name, valid_date, transaction_date)
at_both_times(valid_date, transaction_date)
.find_by(name: name)
end
end
Common Patterns
Temporal data modeling employs recurring patterns that address specific temporal tracking requirements. These patterns represent tested solutions to common temporal challenges.
Slowly Changing Dimension Type 2 maintains full history by creating new rows for each change while preserving historical records. Each row includes temporal markers indicating its validity period. This pattern dominates data warehousing implementations due to its simplicity and query flexibility.
class ProductDimensionSCD2 < ApplicationRecord
# Table: product_dimension_scd2
# Columns: surrogate_key, natural_key, name, price, valid_from, valid_to, is_current
scope :current, -> { where(is_current: true) }
scope :at_date, ->(date) {
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)', date, date)
}
def self.update_dimension(natural_key, new_attributes)
transaction do
# Expire current record
current_record = current.find_by(natural_key: natural_key)
if current_record
current_record.update!(
valid_to: Time.current,
is_current: false
)
end
# Create new current record
create!(
natural_key: natural_key,
**new_attributes,
valid_from: Time.current,
valid_to: nil,
is_current: true
)
end
end
# Temporal join for fact tables
def self.join_to_facts_at_time(fact_table, date)
fact_table
.joins(<<-SQL)
INNER JOIN product_dimension_scd2
ON #{fact_table.table_name}.product_natural_key = product_dimension_scd2.natural_key
AND product_dimension_scd2.valid_from <= '#{date}'
AND (product_dimension_scd2.valid_to IS NULL OR product_dimension_scd2.valid_to > '#{date}')
SQL
end
end
Temporal Snapshot Pattern creates periodic full copies of data state. Instead of tracking individual changes, the system captures complete snapshots at defined intervals. This pattern suits scenarios where analyzing states at specific intervals matters more than tracking every individual change.
class InventorySnapshot < ApplicationRecord
# Captures complete inventory state at regular intervals
def self.create_snapshot
snapshot_time = Time.current
transaction do
Inventory.current.find_each do |item|
create!(
snapshot_time: snapshot_time,
item_id: item.id,
quantity: item.quantity,
location: item.location,
status: item.status
)
end
end
end
def self.compare_snapshots(time1, time2)
snap1 = where(snapshot_time: time1).index_by(&:item_id)
snap2 = where(snapshot_time: time2).index_by(&:item_id)
changes = []
snap2.each do |item_id, current|
previous = snap1[item_id]
if previous && previous.quantity != current.quantity
changes << {
item_id: item_id,
quantity_change: current.quantity - previous.quantity,
from: time1,
to: time2
}
end
end
changes
end
end
Temporal State Machine Pattern tracks entity lifecycle stages with temporal transitions. Each state change creates a new temporal record capturing when the entity entered and exited specific states.
class OrderState < ApplicationRecord
belongs_to :order
enum state: {
pending: 0,
confirmed: 1,
processing: 2,
shipped: 3,
delivered: 4,
cancelled: 5
}
scope :current, -> { where(exited_at: nil) }
scope :in_state_at, ->(state, time) {
where(state: state)
.where('entered_at <= ? AND (exited_at IS NULL OR exited_at > ?)', time, time)
}
def self.transition(order_id, new_state)
transaction do
# Exit current state
current = current.find_by(order_id: order_id)
current.update!(exited_at: Time.current) if current
# Enter new state
create!(
order_id: order_id,
state: new_state,
entered_at: Time.current,
exited_at: nil
)
end
end
def self.time_in_state(order_id, state)
where(order_id: order_id, state: state)
.sum { |record|
exit_time = record.exited_at || Time.current
exit_time - record.entered_at
}
end
end
Effective Dating Pattern separates when a change is known from when it becomes effective. This pattern handles scenarios like scheduled price changes or employment contracts with future start dates.
class PriceSchedule < ApplicationRecord
# effective_date: when price takes effect
# created_at: when price was scheduled
scope :effective_on, ->(date) {
where('effective_date <= ?', date)
.order(effective_date: :desc)
.limit(1)
}
scope :upcoming, -> {
where('effective_date > ?', Date.current)
.order(:effective_date)
}
def self.current_price(product_id)
effective_on(Date.current)
.find_by(product_id: product_id)
&.price
end
def self.schedule_price_change(product_id, new_price, effective_date)
create!(
product_id: product_id,
price: new_price,
effective_date: effective_date
)
end
def self.price_on_date(product_id, date)
where(product_id: product_id)
.where('effective_date <= ?', date)
.order(effective_date: :desc)
.first
&.price
end
end
Performance Considerations
Temporal queries introduce performance challenges through increased data volume, complex temporal predicates, and multi-dimensional temporal joins. Optimization strategies address these challenges through indexing, query design, and architectural patterns.
Temporal Indexes enable efficient temporal range queries. Composite indexes on (entity_id, valid_from, valid_to) support both current-state lookups and historical range scans. B-tree indexes handle temporal ranges effectively, but specialized temporal index structures can improve performance for complex temporal queries.
# Migration for temporal indexes
class AddTemporalIndexes < ActiveRecord::Migration[7.0]
def change
# Composite index for temporal queries
add_index :employees, [:id, :valid_from, :valid_to],
name: 'idx_employees_temporal'
# Covering index for current records
add_index :employees, [:id, :is_current],
where: 'is_current = true',
name: 'idx_employees_current'
# Index for point-in-time queries
add_index :employees, [:valid_from, :valid_to],
name: 'idx_employees_validity_period'
end
end
# Query optimization using indexes
class Employee < ApplicationRecord
# Efficient current lookup (uses partial index)
def self.find_current(id)
where(id: id, is_current: true).first
end
# Efficient temporal range query (uses composite index)
def self.active_during(start_date, end_date)
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)',
end_date, start_date)
end
end
Partitioning Strategies improve query performance by physically segregating temporal data. Range partitioning on temporal columns isolates historical data from current data, allowing queries to skip irrelevant partitions. PostgreSQL table partitioning supports this approach.
# PostgreSQL partitioning for temporal tables
class CreatePartitionedEmployees < ActiveRecord::Migration[7.0]
def up
execute <<-SQL
CREATE TABLE employees_partitioned (
id BIGINT NOT NULL,
name VARCHAR(255),
salary DECIMAL(10,2),
valid_from TIMESTAMP NOT NULL,
valid_to TIMESTAMP,
PRIMARY KEY (id, valid_from)
) PARTITION BY RANGE (valid_from);
-- Current year partition
CREATE TABLE employees_2024 PARTITION OF employees_partitioned
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
-- Historical partitions
CREATE TABLE employees_2023 PARTITION OF employees_partitioned
FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');
SQL
end
end
# Partition-aware queries automatically prune irrelevant partitions
Employee.where(valid_from: Date.new(2024, 6, 1)..Date.new(2024, 12, 31))
# Only scans employees_2024 partition
Materialized Views precompute expensive temporal queries. Complex temporal aggregations or multi-table temporal joins benefit from materialization, trading freshness for query speed.
class CreateTemporalMaterializedViews < ActiveRecord::Migration[7.0]
def up
execute <<-SQL
CREATE MATERIALIZED VIEW employee_salary_history AS
SELECT
id,
name,
salary,
valid_from,
valid_to,
COALESCE(valid_to, CURRENT_TIMESTAMP) - valid_from as duration,
LAG(salary) OVER (PARTITION BY id ORDER BY valid_from) as previous_salary
FROM employees
ORDER BY id, valid_from;
CREATE INDEX ON employee_salary_history (id, valid_from);
SQL
end
def down
execute 'DROP MATERIALIZED VIEW employee_salary_history'
end
end
# Refresh strategy
class EmployeeSalaryHistory < ApplicationRecord
self.table_name = 'employee_salary_history'
def self.refresh
connection.execute('REFRESH MATERIALIZED VIEW CONCURRENTLY employee_salary_history')
end
# Schedule periodic refresh
# In config/initializers/temporal_views.rb:
# Rails.application.config.after_initialize do
# EmployeeSalaryHistory.refresh if Rails.env.production?
# end
end
Query Optimization Techniques minimize temporal query overhead. Avoiding correlated subqueries in temporal joins, using window functions for temporal aggregations, and database-specific temporal features improve performance.
# Inefficient: Correlated subquery for each order
def orders_with_customer_name_slow
Order.select('orders.*, (
SELECT name FROM customers
WHERE customers.id = orders.customer_id
AND customers.valid_from <= orders.created_at
AND (customers.valid_to IS NULL OR customers.valid_to > orders.created_at)
LIMIT 1
) as customer_name')
end
# Efficient: Single temporal join
def orders_with_customer_name_fast
Order
.joins(<<-SQL)
INNER JOIN customers ON customers.id = orders.customer_id
AND customers.valid_from <= orders.created_at
AND (customers.valid_to IS NULL OR customers.valid_to > orders.created_at)
SQL
.select('orders.*, customers.name as customer_name')
end
# Window function for temporal aggregations
def salary_changes_with_percentage
connection.select_all(<<-SQL)
SELECT
id,
valid_from,
salary,
LAG(salary) OVER (PARTITION BY id ORDER BY valid_from) as previous_salary,
CASE
WHEN LAG(salary) OVER (PARTITION BY id ORDER BY valid_from) IS NOT NULL
THEN ((salary - LAG(salary) OVER (PARTITION BY id ORDER BY valid_from))
/ LAG(salary) OVER (PARTITION BY id ORDER BY valid_from) * 100)
ELSE NULL
END as percentage_change
FROM employees
ORDER BY id, valid_from
SQL
end
Archival Strategies balance performance with historical data retention requirements. Moving ancient historical data to separate archive tables or external storage reduces active table size while preserving queryable history.
Practical Examples
Real-world temporal scenarios demonstrate how different temporal patterns solve specific business problems. These examples show complete implementations including edge cases and integration concerns.
Employee Management System tracks employment history, salary changes, and organizational structure over time. The system must answer questions like "Who reported to Manager X on date Y?" and "What was Employee Z's salary progression?"
class Employee < ApplicationRecord
scope :employed_on, ->(date) {
where('hire_date <= ? AND (termination_date IS NULL OR termination_date > ?)',
date, date)
}
scope :in_department_on, ->(department, date) {
joins(:department_histories)
.where(department_histories: { department: department })
.where('department_histories.valid_from <= ? AND
(department_histories.valid_to IS NULL OR department_histories.valid_to > ?)',
date, date)
}
end
class DepartmentHistory < ApplicationRecord
belongs_to :employee
def self.organizational_structure_at(date)
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)', date, date)
.includes(:employee)
.group_by(&:department)
.transform_values { |histories| histories.map(&:employee) }
end
def self.transfer_employee(employee, new_dept, effective_date)
transaction do
# Close current assignment
current = where(employee: employee, valid_to: nil).first
current.update!(valid_to: effective_date) if current
# Create new assignment
create!(
employee: employee,
department: new_dept,
valid_from: effective_date,
valid_to: nil
)
end
end
end
class SalaryHistory < ApplicationRecord
belongs_to :employee
def self.compensation_on(employee_id, date)
where(employee_id: employee_id)
.where('effective_date <= ?', date)
.order(effective_date: :desc)
.first
end
def self.salary_progression(employee_id)
where(employee_id: employee_id)
.order(:effective_date)
.select(:effective_date, :salary, :currency)
end
def self.average_raise_percentage(employee_id)
changes = salary_progression(employee_id).each_cons(2).map do |prev, curr|
((curr.salary - prev.salary).to_f / prev.salary * 100).round(2)
end
changes.sum / changes.size if changes.any?
end
end
# Usage example
employee = Employee.create(name: 'Alice', hire_date: Date.new(2020, 1, 15))
SalaryHistory.create(employee: employee, salary: 80000, effective_date: Date.new(2020, 1, 15))
SalaryHistory.create(employee: employee, salary: 85000, effective_date: Date.new(2021, 1, 15))
DepartmentHistory.transfer_employee(employee, 'Engineering', Date.new(2020, 1, 15))
# Temporal queries
employee.salary_history.compensation_on(Date.new(2020, 6, 1)).salary # => 80000
Employee.in_department_on('Engineering', Date.new(2020, 6, 1)) # => [#<Employee id: 1, name: "Alice">]
Product Pricing with Effective Dating manages scheduled price changes, promotional pricing, and historical price tracking for e-commerce systems. The system must handle overlapping promotions and maintain audit trails.
class ProductPrice < ApplicationRecord
belongs_to :product
enum price_type: { regular: 0, promotional: 1, clearance: 2 }
scope :effective_on, ->(date) {
where('effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)', date, date)
}
def self.current_price(product_id)
where(product_id: product_id)
.effective_on(Date.current)
.order(price_type: :desc) # Promotional takes precedence
.first
end
def self.schedule_promotion(product_id, promo_price, start_date, end_date)
create!(
product_id: product_id,
amount: promo_price,
price_type: :promotional,
effective_from: start_date,
effective_to: end_date
)
end
def self.price_history(product_id, start_date, end_date)
where(product_id: product_id)
.where('effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)',
end_date, start_date)
.order(:effective_from)
end
end
class Order < ApplicationRecord
has_many :order_items
# Snapshot price at order time
def finalize_prices
order_items.each do |item|
price = ProductPrice.current_price(item.product_id)
item.update!(
unit_price: price.amount,
price_type: price.price_type
)
end
end
end
# Usage
product = Product.create(name: 'Widget')
ProductPrice.create(product: product, amount: 99.99,
price_type: :regular, effective_from: Date.new(2024, 1, 1))
ProductPrice.schedule_promotion(product.id, 79.99,
Date.new(2024, 6, 1), Date.new(2024, 6, 30))
# Price lookups
ProductPrice.current_price(product.id).amount # => depends on current date
ProductPrice.effective_on(Date.new(2024, 6, 15))
.find_by(product_id: product.id).amount # => 79.99 (promotional)
Insurance Policy Management requires bi-temporal tracking to handle policy changes, retroactive corrections, and regulatory requirements for maintaining both what was recorded when and what was actually true when.
class PolicyVersion < ApplicationRecord
belongs_to :policy
scope :current_knowledge, -> { where(recorded_to: nil) }
scope :known_at, ->(time) {
where('recorded_from <= ? AND (recorded_to IS NULL OR recorded_to > ?)', time, time)
}
scope :valid_at, ->(time) {
where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)', time, time)
}
def self.record_policy_change(policy_id, changes, valid_from)
transaction do
# Close current recorded version
current = current_knowledge
.where(policy_id: policy_id)
.valid_at(Time.current)
.first
current.update!(recorded_to: Time.current) if current
# Create new version
create!(
policy_id: policy_id,
**changes,
valid_from: valid_from,
valid_to: nil,
recorded_from: Time.current,
recorded_to: nil
)
end
end
def self.retroactive_correction(policy_id, corrections, valid_from, valid_to)
transaction do
# Close current recorded version
current = current_knowledge
.where(policy_id: policy_id)
.where('valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)',
valid_from, valid_from)
.first
if current
current.update!(recorded_to: Time.current)
end
# Insert corrected version
create!(
policy_id: policy_id,
**corrections,
valid_from: valid_from,
valid_to: valid_to,
recorded_from: Time.current,
recorded_to: nil,
correction: true
)
end
end
# Answer: "What did we think the coverage amount was on June 1st,
# based on what we knew on July 15th?"
def self.historical_perspective(policy_id, valid_date, recorded_date)
where(policy_id: policy_id)
.valid_at(valid_date)
.known_at(recorded_date)
.first
end
end
# Usage
policy = Policy.create(policy_number: 'POL-001')
PolicyVersion.record_policy_change(policy.id,
{ coverage_amount: 100000, premium: 1200 },
Date.new(2024, 1, 1))
# Retroactive correction: coverage was actually 150000
PolicyVersion.retroactive_correction(policy.id,
{ coverage_amount: 150000, premium: 1200 },
Date.new(2024, 1, 1),
Date.new(2024, 6, 1))
# Queries
PolicyVersion.historical_perspective(policy.id,
Date.new(2024, 3, 1),
Date.new(2024, 5, 1)) # => coverage_amount: 100000 (before correction)
PolicyVersion.historical_perspective(policy.id,
Date.new(2024, 3, 1),
Date.new(2024, 7, 1)) # => coverage_amount: 150000 (after correction)
Reference
Temporal Modeling Comparison
| Approach | History Tracking | Query Complexity | Storage Overhead | Audit Trail |
|---|---|---|---|---|
| Separate History Table | Full | Medium | Medium | Good |
| Inline Temporal Columns | Full | Low | High | Good |
| SCD Type 1 | None | Very Low | None | None |
| SCD Type 2 | Full | Low | Medium | Good |
| SCD Type 3 | Limited | Low | Low | Poor |
| Event Sourcing | Complete | High | High | Excellent |
| Bi-temporal | Complete | Very High | Very High | Excellent |
| Snapshots | Periodic | Low | Very High | Fair |
Temporal Query Patterns
| Query Type | SQL Pattern | Use Case |
|---|---|---|
| Current State | WHERE valid_to IS NULL | Operational queries |
| Point-in-time | WHERE valid_from <= date AND (valid_to IS NULL OR valid_to > date) | Historical reconstruction |
| Temporal Range | WHERE valid_from <= end_date AND (valid_to IS NULL OR valid_to > start_date) | Period analysis |
| Temporal Join | JOIN ON entity_id AND temporal_overlap_condition | Cross-entity history |
| Change History | ORDER BY valid_from | Audit trail |
| Duration Calculation | SUM(COALESCE(valid_to, CURRENT_TIMESTAMP) - valid_from) | Time in state |
Ruby Gem Comparison
| Gem | Temporal Type | Active Record Integration | Complexity | Best For |
|---|---|---|---|---|
| temporal_rails | Uni-temporal | High | Low | Simple temporal tracking |
| rails_event_store | Event sourcing | Medium | High | Event-driven systems |
| paper_trail | Audit trail | High | Low | Version history |
| chrono_model | Bi-temporal | Medium | Medium | PostgreSQL bi-temporal |
| audited | Audit trail | High | Low | Compliance tracking |
Index Strategy Guidelines
| Scenario | Recommended Index | Reason |
|---|---|---|
| Current records only | Partial index on is_current = true | Optimizes operational queries |
| Point-in-time queries | Composite on (entity_id, valid_from, valid_to) | Supports temporal range scans |
| Temporal joins | Composite on (foreign_key, valid_from, valid_to) | Accelerates temporal joins |
| Audit queries | Index on transaction_time or created_at | Speeds historical lookups |
| Bi-temporal | Separate indexes on valid and transaction time ranges | Supports both temporal dimensions |
Common Temporal Validations
| Validation | Implementation | Purpose |
|---|---|---|
| Non-overlapping periods | Check no overlaps for same entity | Prevent inconsistent state |
| Valid temporal range | Ensure valid_from < valid_to | Data integrity |
| Future-dated constraints | Limit how far ahead effective dates can be | Business rules |
| Immutable history | Prevent updates to closed periods | Audit integrity |
| Referential temporal integrity | Validate temporal foreign keys exist | Consistency |
Performance Optimization Checklist
| Optimization | Impact | Implementation Complexity |
|---|---|---|
| Partial indexes on current records | High | Low |
| Table partitioning by time period | High | Medium |
| Materialized views for complex queries | Medium | Medium |
| Separate history tables | Medium | Low |
| Archival of old data | High | Medium |
| Covering indexes | Medium | Low |
| Query result caching | Medium | Low |
| Denormalization of temporal joins | High | High |