A Hands-On Guide to Object-Document Mapping
This chapter provides a step-by-step walkthrough of Voyage, an object-to-document mapper for MongoDB. We'll explore a straightforward domain model: superheroes, their abilities, and equipment. You'll discover how to persist and retrieve domain objects with ease.
Establishing Database Connectivity
After setting up MongoDB, connecting to the database requires just a few lines of code:
| dbConnection |
dbConnection := VOMongoRepository
host: '127.0.0.1'
database: 'superHeroes'.
dbConnection enableSingleton.
For rapid prototyping scenarios where you don't need persistent storage, Voyage offers an in-memory repository that behaves identically to a real database connection:
| dbConnection |
dbConnection := VOMemoryRepository new.
dbConnection enableSingleton
This approach enables transparent switching between storage backends during development. A common pattern is defining a repository setup method within your domain class:
Hero class >> establishConnection
| repo |
repo := VOMongoRepository
host: '127.0.0.1'
database: 'superHeroes'.
repo enableSingleton.
Domain Model Overview
The following diagram illustrates the model structure we'll build throughout this tutorial.
Defining the Character Class
Let's implement the Character class to represent our superheroes:
Object subclass: #Character
instanceVariableNames: 'name rank abilities'
classVariableNames: ''
package: 'SuperHeroes'
Character >> name
^ name
Character >> name: aString
name := aString
Character >> rank
^ rank
Character >> rank: anObject
rank := anObject
Character >> abilities
^ abilities ifNil: [ abilities := Set new ]
Character >> learnAbility: anAbility
self abilities add: anAbility
Defining the Ability Class
Now we'll create the Ability class:
Object subclass: #Ability
instanceVariableNames: 'name'
classVariableNames: ''
package: 'SuperHeroes'
Ability >> name
^ name
Ability >> name: aString
name := aString
Implementing printOn: enhances debugging and object navigation.
Declaring Persistent Roots
To persist and query objects, we must designate which classes serve as entry points into our object graph. This is accomplished by implementing the class method isVoyageRoot. While we'll explore the implications later, for now we'll mark Character as a root:
Character class >> isVoyageRoot
^ true
Now we can instantiate and persist our heroes:
Character new
name: 'Spiderman';
rank: 'epic';
learnAbility: (Ability new name: 'Super-strength');
learnAbility: (Ability new name: 'Wall-crawling');
learnAbility: (Ability new name: 'Spider-sense');
save.
Character new
name: 'Wolverine';
rank: 'epic';
learnAbility: (Ability new name: 'Regeneration');
learnAbility: (Ability new name: 'Adamantium claws');
save.
Inspecting Persisted Data
We can examine how objects are stored directly in MongoDB:
> show dbs
local 0.078GB
superHeroes 0.078GB
> use superHeroes
switched to db superHeroes
> show collections
Character
Retrieving the first document shows how Voyage stores our data:
> db.Character.findOne()
{
"_id" : ObjectId("d847065c56d0ad09b4000001"),
"#version" : 688076276,
"#instanceOf" : "Character",
"rank" : "epic",
"name" : "Spiderman",
"abilities" : [
{
"#instanceOf" : "Ability",
"name" : "Spider-sense"
},
{
"#instanceOf" : "Ability",
"name" : "Super-strength"
},
{
"#instanceOf" : "Ability",
"name" : "Wall-crawling"
}
]
}
Notice that abilities are embedded directly within the character document rather than stored separately.
Querying Data
Back in Pharo, we can retrieve objects using various query methods:
Character selectAll.
> an OrderedCollection(a Character( Spiderman ) a Character( Wolverine ))
Character selectOne: [ :each | each name = 'Spiderman' ].
> a Character( Spiderman )
Character selectMany: [ :each | each rank = 'epic' ].
> an OrderedCollection(a Character( Spiderman ) a Character( Wolverine ))
Since MongoDB uses JSON internally, we can also pass dictionary-based criteria:
Character selectOne: { #name -> 'Spiderman' } asDictionary.
> a Character( Spiderman )
Character selectMany: { #rank -> 'epic' } asDictionary.
> an OrderedCollection(a Character( Spiderman ) a Character( Wolverine ))
More sophisticated queries support sorting and pagination:
Character
selectMany: { #rank -> 'epic' } asDictionary
sortBy: { #name -> VOOrder ascending } asDictionary
limit: 10
offset: 0
Fundamental Operations
Counting Records
Character count.
> 2
Character count: [ :each | each name = 'Spiderman' ]
> 1
Removing Records
hero := Character selectAll anyOne.
hero remove.
> a Character
To remove all instances of a class:
Character removeAll.
> Character class
Introducing Additional Root Classes
Suppose our requirements evolve to require querying Ability instances independently. When introducing new root classes, you may need to refresh the database or perform migrations to load and re-save existing objects.
Any time you modify the database schema, reset it with:
VORepository current reset.
When to Designate New Roots
Consider two key factors when deciding whether a class needs root status:
- Whether you need to query instances independently versus accessing them only through references
- Whether shared instances should be deduplicated across the object graph—for example, if two characters could share the same ability and you want to ensure only one instance exists when loading both
Making Ability a Root Class
Ability class >> isVoyageRoot
^ true
Now we can persist abilities as standalone documents:
Ability new name: 'Flight'; save.
Ability new name: 'Super-strength'; save.
If newly saved objects don't appear in collections, reset the repository cache:
VORepository current reset.
After resetting and verifying:
> show collections
Character
Ability
We can now create characters that reference these shared ability instances. For testing purposes, let's clear existing characters:
| flight superPower |
flight := Ability selectOne: [ :each | each name = 'Flight'].
superPower := Ability selectOne: [ :each | each name = 'Super-strength'].
Character new
name: 'Superman'; rank: 'epic';
learnAbility: flight;
learnAbility: superPower;
save.
Note that while abilities are saved as independent documents, they can still be referenced from multiple characters. Saving a character automatically persists its abilities too.
Querying the database reveals that characters now reference external ability documents instead of embedding them.
Handling Relationships
Voyage supports circular references between root objects but prevents them within embedded objects.
Enhancing the Character Class
We'll extend Character with equipment support. Note that root declarations are static—when a superclass is marked as a root, its subclasses share the same MongoDB collection. To give each subclass its own collection, you must explicitly mark each as a root.
Add an equipment instance variable to Character:
Object subclass: #Character
instanceVariableNames: 'name rank abilities equipment'
classVariableNames: ''
package: 'SuperHeroes'
Character >> equipment
^ equipment ifNil: [ equipment := Set new ]
Character >> equip: anItem
self equipment add: anItem
Reset the repository cache after structural changes:
VORepository current reset
Creating the Equipment Class Hierarchy
Define Equipment as a new root class:
Object subclass: #Equipment
instanceVariableNames: ''
classVariableNames: ''
package: 'SuperHeroes'
Equipment class >> isVoyageRoot
^ true
Create specialized subclasses:
Equipment subclass: #Weapon
instanceVariableNames: ''
classVariableNames: ''
category: 'SuperHeroes'
Equipment subclass: #Armor
instanceVariableNames: ''
classVariableNames: ''
category: 'SuperHeroes'
Now we can equip our characters:
Character new
name: 'Iron-Man';
rank: 'epic';
equip: Armor new;
save.
Examining the stored document:
> db.Character.find()[1]
{
"_id" : ObjectId("d8475734421aa909b4000001"),
"#instanceOf" : "Character",
"#version" : NumberLong("2898020230"),
"equipment" : [
{
"#instanceOf" : "Armor"
}
],
"rank" : "epic",
"name" : "Iron-Man",
"abilities" : null
}
Since we didn't mark Weapon and as separate roots, only the Equipment collection exists, containing both item types.
Equipment with Abilities
Some equipment may possess abilities—consider Thor's hammer. Let's extend Equipment:
Object subclass: #Equipment
instanceVariableNames: 'abilities'
classVariableNames: ''
package: 'SuperPowers'
Equipment >> abilities
^ abilities ifNil: [ abilities := Set new ]
Equipment >> learnAbility: anAbility
self abilities add: anAbility
Reset the repository after these changes:
VORepository current reset
Equip Iron-Man with a powered suit:
| hero flight superPower |
hero := Character selectOne: [ :each | each name = 'Iron-Man' ].
flight := Ability selectOne: [ :each | each name = 'Flight' ].
superPower := Ability selectOne: [ :each | each name = 'Super-strength' ].
hero equip: (Armor new
learnAbility: flight;
learnAbility: superPower;
yourself);
save.
The database shows equipment documents:
> db.Equipment.find()[0]
{
"_id" : ObjectId("d8475777421aa909b4000003"),
"#instanceOf" : "Armor",
"#version" : NumberLong("4204064627")
}
Equipment can contain references to abilities. Since Equipment is a root class, circular references within equipment are properly handled.