Extend with refinement
Create splent_feature_notes_tags, a refinement feature that adds tags to notes without forking the original feature.
Table of contents
- Prerequisites
- The problem
- Step 1. Scaffold the refinement feature
- Step 2. Configure the refinement
- Step 3. Write the model mixin
- Step 4. Write the service override
- Step 5. Add the tag filter route
- Step 6. Write the migration
- Step 7. Add to the SPL
- Step 8. Verify the refinement
- Step 9. Verify in the browser
- Step 10. Test the uninstall cycle
- Step 11. Re-add the feature
- What you learned
- Next
Prerequisites
You have completed Tutorial 5, Release and deploy. You are inside the CLI container with my_first_app selected and running in development.
(my_first_app) /workspace$
Tutorial 5 removed
splent_feature_notesfrom the product to deploy without it. Add it back before continuing. Its source and its database table are still there, so nothing else is needed.splent feature:add splent-io/splent_feature_notesIf you published it for real in Tutorial 5 and attached
v1.0.0, go back to your editable copy instead, since this tutorial and the next one change its code. Runsplent feature:detach splent-io/splent_feature_notes v1.0.0, then thefeature:addabove.
The problem
You want tags on notes, which means a tags column on the Notes model, a service method to filter by tag and a route that shows tagged notes. You do not want to fork splent_feature_notes, because other products use it as it is.
A refinement feature declares what it extends and overrides in a base feature, and the framework applies it at startup.
Step 1. Scaffold the refinement feature
Deselect the product and create the feature. As in Tutorial 3, feature:create works either way, but spl:add-feature in Step 7 needs no product selected.
splent product:deselect
splent feature:create splent-io/splent_feature_notes_tags
It creates the usual skeleton at /workspace/splent_feature_notes_tags/.
Step 2. Configure the refinement
Option A. Using the wizard (recommended)
Select the product again and run the refinement wizard.
splent product:select my_first_app
splent feature:refine splent_feature_notes_tags
The wizard reads the contracts of the product’s features and lists what each one lets you extend.
Step 1: Which feature do you want to refine?
1. auth
2 service(s), 3 model(s), 16 template(s), 12 hook(s), routes
2. profile
1 service(s), 1 model(s), 4 template(s), 5 hook(s), routes
3. public
1 template(s), 4 hook(s), routes
4. redis
routes
5. session_redis
routes
6. settings
1 service(s), 1 model(s), 2 template(s), 1 hook(s), routes
7. theme
7 template(s), 8 hook(s), routes
8. notes
1 service(s), 1 model(s), 3 template(s), 1 hook(s), routes
Select: 8
Select notes (8). The wizard then asks what to extend or override, one kind at a time. Each question takes space-separated numbers, and 0, the default, means none.
- Models. Type
1forNotes. The wizard names the mixinNotesTagsMixin. - Services. Type
1forNotesService. The wizard names the replacementNotesServiceWithTags. - Templates and hooks. Press Enter to skip.
- Add routes to the base feature’s blueprint? Press Enter (No). The tag route lives on this feature’s own blueprint.
It previews the TOML and asks for confirmation.
Step 3: Review
────────────────────────────────────────────────────────
# -- Refinement (auto-generated by feature:refine) ---------
[tool.splent.refinement]
refines = "splent_feature_notes"
[tool.splent.refinement.extends]
models = [{ target = "Notes", mixin = "NotesTagsMixin" }]
[tool.splent.refinement.overrides]
services = [{ target = "NotesService", replacement = "NotesServiceWithTags" }]
Write to pyproject.toml and scaffold code? [Y/n]: Y
pyproject.toml updated.
scaffolded: models.py <- NotesTagsMixin
scaffolded: services.py <- NotesServiceWithTags
scaffolded: __init__.py
kept: migrations/env.py (the framework migrates the columns the mixin injects)
contract updated.
notes_tags added to features
reinstalling in web container...
done.
Press Y. The wizard writes [tool.splent.refinement] to pyproject.toml, scaffolds the mixin and the service in models.py and services.py, wires them in __init__.py and adds the feature to the product. It leaves migrations/env.py alone, since the framework already puts the injected columns in this feature’s migrations (Step 6).
Option B. Manual configuration
To configure it by hand, add this to splent_feature_notes_tags/pyproject.toml.
[tool.splent.refinement]
refines = "splent_feature_notes"
[tool.splent.refinement.extends]
models = [{ target = "Notes", mixin = "NotesTagsMixin" }]
[tool.splent.refinement.overrides]
services = [{ target = "NotesService", replacement = "NotesServiceWithTags" }]
You then write the mixin and the service yourself in Steps 3 and 4. After that, select the product again (Step 1 left you detached) and update the contract.
splent product:select my_first_app
splent feature:contract splent_feature_notes_tags --write
What happens at startup
- Model extension. The mixin is applied to the
Notesmodel, adding its columns without touching the base feature’smodels.py. - Service override. The replacement takes the place of
NotesServicein the service locator, so every consumer that resolves it there (get_service_classorservice_proxy) gets your version. The notes routes from Tutorial 3 create their ownNotesService()when the module loads, so they do not get it yet. Tutorial 7 switches them toservice_proxy. - Validation. The framework checks that
NotesandNotesServiceexist in the base feature’s contract.
Add the refinement feature to the product
The wizard (Option A) already added it, as its last lines say. If you configured the refinement by hand (Option B), add it now so it takes part in migrations, composition and feature:xray.
splent product:select my_first_app # if not already selected
splent feature:add splent-io/splent_feature_notes_tags
After the wizard it does no harm and answers notes_tags already in features.
Step 3. Write the model mixin
A mixin is a plain Python class whose columns and methods (also relationships, properties and classmethods, see the full list) the framework injects into an existing model at startup. You extend another feature’s model from your own, and removing your feature returns the model to its original state.
If you used the wizard, replace the skeleton it wrote in
models.pywith the code below.
Edit splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/models.py.
from splent_framework.db import db
class NotesTagsMixin:
"""Mixin applied to the Notes model at startup.
Adds a comma-separated tags column. The base Notes model is not modified --
this mixin is injected by the refinement system via refine_model().
"""
tags = db.Column(db.String(500), nullable=True, default="")
def get_tags_list(self):
"""Return tags as a Python list."""
if not self.tags:
return []
return [t.strip() for t in self.tags.split(",") if t.strip()]
def has_tag(self, tag):
return tag.lower() in [t.lower() for t in self.get_tags_list()]
The tags column and the two methods go into Notes. Code that already queries Notes keeps working, because tags is nullable and defaults to empty.
Step 4. Write the service override
If you used the wizard, replace the skeleton it wrote in
services.pywith the code below.
Edit splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/services.py.
from sqlalchemy import select
from splent_framework.db import db
class NotesServiceWithTags:
"""Extends NotesService with tag-based queries.
Inherits from the base NotesService at runtime (resolved from the service
locator) so it picks up all existing methods. Do **not** define `__init__`
here: `refine_service` makes this class inherit `NotesService`, whose
`__init__` already wires up the repository. The service locator instantiates
services with no arguments (`svc_cls()`), so adding an `__init__(self, repository)`
would raise `TypeError: missing 1 required positional argument: 'repository'`.
"""
def get_by_tag(self, user_id, tag):
"""Return all notes for a user that contain the given tag."""
Notes = self.repository.model
stmt = (
select(Notes)
.where(Notes.user_id == user_id)
.where(Notes.tags.ilike(f"%{tag}%"))
)
return list(db.session.scalars(stmt).all())
def get_all_tags(self, user_id):
"""Return a sorted list of unique tags across all notes for a user."""
Notes = self.repository.model
stmt = (
select(Notes.tags).where(Notes.user_id == user_id).where(Notes.tags != "")
)
rows = db.session.scalars(stmt).all()
tags = set()
for row in rows:
for tag in row.split(","):
tag = tag.strip()
if tag:
tags.add(tag)
return sorted(tags)
def create(self, **kwargs):
"""Normalize tags before creating."""
if "tags" in kwargs and kwargs["tags"]:
kwargs["tags"] = ", ".join(
t.strip() for t in kwargs["tags"].split(",") if t.strip()
)
return super().create(**kwargs)
The wizard already wrote
__init__.pywith the imports and therefine_model()andrefine_service()calls. If you configured the refinement by hand, make yours match the code below.
from splent_framework.blueprints.base_blueprint import create_blueprint
from splent_framework.refinement import refine_model, refine_service
from .models import NotesTagsMixin
from .services import NotesServiceWithTags
notes_tags_bp = create_blueprint(__name__)
def init_feature(app):
refine_model("Notes", NotesTagsMixin)
refine_service(app, "NotesService", NotesServiceWithTags)
def inject_context_vars(app):
return {}
refine_model injects the mixin into the base model. refine_service makes the replacement inherit from the base service at runtime and registers it under the same name. You never import the base feature, since the framework resolves it.
Step 5. Add the tag filter route
Edit splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/routes.py.
from flask import render_template, current_app
from flask_login import current_user, login_required
from splent_framework.services.service_locator import get_service_class
from splent_io.splent_feature_notes_tags import notes_tags_bp
@notes_tags_bp.route("/tag/<string:tag>")
@login_required
def by_tag(tag):
"""Show all notes with a given tag."""
svc_cls = get_service_class(current_app, "NotesService")
svc = svc_cls()
notes = svc.get_by_tag(current_user.id, tag)
all_tags = svc.get_all_tags(current_user.id)
return render_template(
"notes_tags/by_tag.html",
notes=notes,
current_tag=tag,
all_tags=all_tags,
)
Create the template at splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/templates/notes_tags/by_tag.html.
{% extends "base_template.html" %}
{% block title %}Notes tagged "{{ current_tag }}"{% endblock %}
{% block content %}
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Notes tagged "{{ current_tag }}"</h1>
<a href="{{ url_for('notes.index') }}" class="btn btn-outline-secondary">All Notes</a>
</div>
<div class="mb-3">
<strong>Filter by tag:</strong>
{% for tag in all_tags %}
<a href="{{ url_for('notes_tags.by_tag', tag=tag) }}"
class="badge {{ 'bg-primary' if tag == current_tag else 'bg-secondary' }} text-decoration-none">{{ tag }}</a>
{% endfor %}
</div>
{% if notes %}
<div class="list-group">
{% for note in notes %}
<div class="list-group-item">
<h5 class="mb-1">{{ note.title }}</h5>
<p class="mb-1 text-muted">{{ note.content or "No content" }}</p>
{% for t in note.get_tags_list() %}
<span class="badge bg-info text-dark">{{ t }}</span>
{% endfor %}
<br><small class="text-muted">{{ note.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No notes with this tag.</p>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
{% endblock %}
Step 6. Write the migration
The tags column goes into the notes table but belongs to this feature’s migrations. The framework marks every injected column with the refiner that injected it, and autogenerate uses that mark. A refiner’s migration adds its columns, with any index or foreign key built on them, and never creates, drops or otherwise alters the base table. The base feature’s migrations leave those columns out.
The notes table already exists from Tutorial 3, and it stayed in the database when Tutorial 5 removed the feature. Generate the migration.
splent db:migrate splent_feature_notes_tags -m "add tags column to notes"
Among Alembic’s log lines you see the added column, then the new file (the revision ID is random).
INFO [alembic.autogenerate.compare.tables] Detected added column 'notes.tags'
Generating /workspace/my_first_app/features/splent_io/splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/migrations/versions/3e0010499811_add_tags_column_to_notes.py ... done
📝 splent_feature_notes_tags: new migration generated
Read the generated file in splent_feature_notes_tags/.../migrations/versions/ before applying it, as with any autogenerated migration. upgrade() adds the tags column to notes and downgrade() drops it, and nothing else.
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("notes", sa.Column("tags", sa.String(length=500), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("notes", "tags")
# ### end Alembic commands ###
The table is
notes, the base feature’s, notnotes_tags. The refiner adds a column to an existing table.
Apply it.
splent db:upgrade
INFO [alembic.runtime.migration] Running upgrade -> 3e0010499811, add tags column to notes
splent_feature_notes_tags -> 3e0010499811
Step 7. Add to the SPL
The SPL must know that notes_tags exists and depends on notes.
splent product:deselect
splent spl:add-feature sample_splent_spl splent_feature_notes_tags
It detects that notes_tags refines notes and adds notes_tags => notes to your working copy of the model, splent_spl_sample_splent/, created in Tutorial 3.
Detected dependencies:
notes_tags => notes
Add 'notes_tags' to sample_splent_spl? [Y/n]: y
✅ Feature 'notes_tags' added to sample_splent_spl.
Model: splent_spl_sample_splent/sample_splent_spl.uvl
Constraints: notes_tags => notes
Select the product again and validate.
splent product:select my_first_app
splent product:validate
Step 8. Verify the refinement
splent feature:xray --validate
As in Tutorial 3, it first offers to refresh the new feature’s contract. Press Y.
⚠ 1 feature(s) with potentially stale contracts:
- splent_feature_notes_tags
Update stale contracts now? [Y/n]: Y
✔ splent_feature_notes_tags contract updated.
Then it shows which features refine which, which models are extended and which services overridden.
splent_feature_notes (refined by splent_feature_notes_tags)
service: NotesService ← overridden by splent_feature_notes_tags
model: Notes ← extended by splent_feature_notes_tags
hook: layout.sidebar.authenticated.items ← splent_feature_profile
splent_feature_notes_tags (refines splent_feature_notes)
service: NotesService → NotesServiceWithTags
model: Notes + NotesTagsMixin
splent_feature_public, splent_feature_redis, splent_feature_session_redis — no active refinements
2 refinement(s) across 1 feature(s).
── Validation ──
✅ All 10 checks passed. (4 shared hook(s), 1 unused layout hook(s))
Step 9. Verify in the browser
Open http://localhost:5591/notes/ (splent product:port prints the URL). The notes pages work as before, still on the base service they created when the module loaded. The new route, http://localhost:5591/tag/work, looks the service up through the locator, so it runs NotesServiceWithTags, and answers No notes with this tag. since no note has tags yet. The UI has not changed. Tutorial 7 switches the notes routes to service_proxy and adds tags to the pages.
Step 10. Test the uninstall cycle
A refinement is fully reversible. Roll back the migration, then remove the feature.
splent db:rollback splent_feature_notes_tags
It drops the tags column from notes.
Rolling a feature all the way back offers to delete its migration files (
Permanently delete 1 migration file(s)? (irreversible) [y/N]). Answer No, the default. Step 11 applies the same migration again and needs the file.
splent feature:remove splent_feature_notes_tags
It removes the feature from the product’s pyproject.toml and removes its link. Check the product.
splent product:validate
The product is valid without notes_tags. Notes has no tags column, and NotesService is the original again.
Step 11. Re-add the feature
The next tutorial builds on notes_tags, so add it back.
splent feature:add splent-io/splent_feature_notes_tags
splent db:upgrade
What you learned
| Concept | What it means |
|---|---|
| Refinement | A feature that extends another without modifying its source code |
| Model mixin | refine_model() injects columns and methods into existing SQLAlchemy models |
| Service override | refine_service() replaces a service class in the service locator, so every consumer that resolves it there (get_service_class or service_proxy) gets the new version |
| Refinement wizard | feature:refine reads contracts, writes the refinement to pyproject.toml, scaffolds code and adds the feature to the product |
| UVL constraint | notes_tags => notes declares the dependency, so it is validated |
| Reversible uninstall | db:rollback and feature:remove undo everything |
Next
Continue with Tutorial 7, Frontend refinement, to make the base routes refinement-aware and fill the base feature’s hook slots from the refinement.