Lab 11Write your own code generator
A working BESSER generator that turns a B-UML class model into Ruby on Rails model classes.
- Time
- 1 h
- Level
- Intermediate
- Runs with
- Python
- Do first
- Lab 2
You'll learn to
- Implement a generator class on top of BESSER's GeneratorInterface
- Load and render a Jinja template the way the built-in generators do
- Traverse classes, attributes and association ends of a domain model from a template
- Map multiplicities to Rails associations (has_many, belongs_to, has_and_belongs_to_many)
You'll need
- Python 3.11 or 3.12
- A terminal and a code editor
- Basic Jinja or other template syntax helps but is not required
Files for this lab
Every BESSER generator follows the same recipe: a Python class receives a B-UML model, a Jinja template walks through that model, and the rendered text is written to a file. Once you know the recipe, you can target any language or framework BESSER does not support yet.
In this lab you build a generator for Ruby on Rails. Rails follows the Model-View-Controller pattern, and you generate the Model part: one ApplicationRecord class per B-UML class, with its attributes and its associations. You work with the same Library, Book and Author model used in earlier labs.
You do not need Ruby or Rails installed. The generator only writes text; checking it with Ruby is optional.
Set up the project folder
-
Create a folder for the generator and a virtual environment with BESSER in it.
mkdir rails-generator cd rails-generator python -m venv .venv source .venv/bin/activate python -m pip install bessermkdir rails-generator cd rails-generator python -m venv .venv .venv\Scripts\Activate.ps1 python -m pip install besser -
Download the four starter files from the top of this page. Put rails_generator.py, library_model.py and generate.py in
rails-generator/. Create atemplates/folder and put rails_models.rb.j2 in it.rails-generator/ ├── generate.py ├── library_model.py ├── rails_generator.py └── templates/ └── rails_models.rb.j2 -
Check the installed version:
python -c "from importlib.metadata import version; print(version('besser'))"8.0.1
library_model.py builds this model with the B-UML Python API:
| Class | Attributes | Associations |
|---|---|---|
| Library | name: str, address: str | locatedIn 1 on the Library side, has 0..* on the Book side |
| Book | title: str, pages: int, release: datetime | publishes 0..* on the Book side, writtenBy 1..* on the Author side |
| Author | name: str, email: str |
Read the generator class
Open rails_generator.py. Without its docstrings, this is the whole generator:
import os
from jinja2 import Environment, FileSystemLoader
from besser.BUML.metamodel.structural import DomainModel
from besser.generators import GeneratorInterface
from besser.utilities import sort_by_timestamp
class RailsGenerator(GeneratorInterface):
def __init__(self, model: DomainModel, output_dir: str = None):
super().__init__(model, output_dir)
def generate(self):
file_path = self.build_generation_path(file_name="models.rb")
templates_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
env = Environment(loader=FileSystemLoader(templates_path),
trim_blocks=True, lstrip_blocks=True)
env.globals["sort_by_timestamp"] = sort_by_timestamp
template = env.get_template("rails_models.rb.j2")
with open(file_path, mode="w", encoding="utf-8") as f:
f.write(template.render(model=self.model))
print("Code generated in the location: " + file_path)
What each piece does:
GeneratorInterface(inbesser/generators/generator_interface.py) is an abstract base class with two abstract methods:__init__(self, model, output_dir=None)andgenerate(self). Callingsuper().__init__stores the model inself.modeland the folder inself.output_dir.build_generation_path("models.rb")is a helper from the interface. It createsoutput_dir, or./outputwhen you passed none, and returns the full path of the file to write.- The Jinja
Environmentloads templates from thetemplates/folder next to the generator file, so the generator works from any working directory. The built-in generators, for examplebesser/generators/java_classes/java_generator.py, use the same pattern withtrim_blocksandlstrip_blocks, which stop{% %}lines from leaving blank lines and indentation in the output. env.globals["sort_by_timestamp"]makes a BESSER helper available inside the template. You will see why it matters in the next step.template.render(model=self.model)exposes the domain model to the template asmodel.
Write a first template that lists the classes
Open templates/rails_models.rb.j2:
{% for class in sort_by_timestamp(model.get_classes()) %}
class {{ class.name }} < ApplicationRecord
end
{% endfor %}
model.get_classes() returns a Python set, so its order changes from run to run. On one run it came back as:
>>> [c.name for c in library_model.get_classes()]
['Author', 'Book', 'Library']
Every B-UML element records when it was created, and sort_by_timestamp turns the set into a list in creation order. The built-in generators use it for the same reason: the same model always produces the same file, so diffs between two generations stay meaningful.
Run the generator on the Library model
generate.py imports the model and calls the generator:
from library_model import library_model
from rails_generator import RailsGenerator
RailsGenerator(model=library_model).generate()
Run it from the rails-generator/ folder:
python generate.py
Code generated in the location: /path/to/rails-generator/output/models.rb
Open output/models.rb:
class Library < ApplicationRecord
end
class Book < ApplicationRecord
end
class Author < ApplicationRecord
end
Pass output_dir to write somewhere else, for example straight into a Rails project: RailsGenerator(model=library_model, output_dir="app/models").generate().
Add attributes with a type mapping
Rails needs a type for each attribute. B-UML primitive types have short names: StringType.name is str, and the others are int, float, bool, date, datetime and time. Map them to Rails types with a dictionary at the top of the template, and loop over each class’s attributes:
{% set rails_types = {"str": "string", "int": "integer", "float": "float", "bool": "boolean",
"date": "date", "datetime": "datetime", "time": "time"} %}
{% for class in sort_by_timestamp(model.get_classes()) %}
class {{ class.name }} < ApplicationRecord
{% for attr in sort_by_timestamp(class.attributes) %}
attribute :{{ attr.name }}, :{{ rails_types.get(attr.type.name, "string") }}
{% endfor %}
end
{% endfor %}
class.attributes is also a set, so it gets the same sorting. Types that are not in the dictionary, such as an enumeration, fall back to string.
Run python generate.py again:
class Library < ApplicationRecord
attribute :name, :string
attribute :address, :string
end
class Book < ApplicationRecord
attribute :title, :string
attribute :pages, :integer
attribute :release, :datetime
end
class Author < ApplicationRecord
attribute :name, :string
attribute :email, :string
end
Explore how the model exposes associations
Before you generate associations, look at what the model gives you. Create explore_associations.py in the project folder:
from besser.utilities import sort_by_timestamp
from library_model import library_model
for cls in sort_by_timestamp(library_model.get_classes()):
print(cls.name)
for end in sort_by_timestamp(cls.association_ends()):
mine = end.opposite_end()
print(f" via {end.owner.name}: this side '{mine.name}' {mine.multiplicity.min}..{mine.multiplicity.max}, "
f"other side '{end.name}' -> {end.type.name} {end.multiplicity.min}..{end.multiplicity.max}, "
f"navigable={end.is_navigable}")
Run it with python explore_associations.py:
Library
via lib_book_assoc: this side 'locatedIn' 1..1, other side 'has' -> Book 0..9999, navigable=True
Book
via lib_book_assoc: this side 'has' 0..9999, other side 'locatedIn' -> Library 1..1, navigable=True
via book_author_assoc: this side 'publishes' 0..9999, other side 'writtenBy' -> Author 1..9999, navigable=True
Author
via book_author_assoc: this side 'writtenBy' 1..9999, other side 'publishes' -> Book 0..9999, navigable=True
What this tells you about the API:
- An association end is a
Property. Itstypeis the class at that end, and itsowneris theBinaryAssociationit belongs to. cls.association_ends()returns the ends at the other side of each of the class’s associations, the ones you navigate to fromcls. For Book that islocatedIn(to Library) andwrittenBy(to Author).end.opposite_end()returns the end on the class’s own side of the same association.end.multiplicity.minandend.multiplicity.maxare integers. An unbounded"*"is stored as9999, so testmax > 1for “many”, notmax == "*".end.is_navigableisFalsewhen the association cannot be traversed in that direction. Skip those ends in generated code.cls.associationsreturns the associations themselves, andassociation.endsreturns both ends.
Exercise: generate Rails associations and validations
Show a solution
Add a second inner loop after the attributes: {% for end in sort_by_timestamp(class.association_ends()) if end.is_navigable %}. Inside it, compare end.multiplicity.max with end.opposite_end().multiplicity.max. Many on both sides gives has_and_belongs_to_many. Many on the other side only gives has_many. One on the other side and many on this side gives belongs_to. Build the name with end.type.name | lower, adding an s for the plural forms. Watch the blank line between classes: loop.last helps you avoid a trailing one.
Show a solution
Add one more loop over sort_by_timestamp(class.attributes) with the filter if not attr.is_optional, and append , uniqueness: true inside {% if attr.is_external_id %}. With the two model changes, Book should get validates :title, presence: true, uniqueness: true and Author should get no validation for email.
To make your generator available in the Web Modeling Editor’s Generate menu, it has to be registered in a BESSER source checkout: in SUPPORTED_GENERATORS in besser/utilities/web_modeling_editor/backend/config/generators.py, plus a menu entry in the editor frontend, followed by running the editor locally. The generator guide covers the backend part. Extend the B-UML metamodel shows how to set up the source checkout.