Extend with refinement

Create splent_feature_notes_tags, a refinement feature that adds tags to notes without forking the original feature.


Table of contents

  1. Prerequisites
  2. The problem
  3. Step 1. Scaffold the refinement feature
  4. Step 2. Configure the refinement
    1. Option A. Using the wizard (recommended)
    2. Option B. Manual configuration
    3. What happens at startup
    4. Add the refinement feature to the product
  5. Step 3. Write the model mixin
  6. Step 4. Write the service override
  7. Step 5. Add the tag filter route
  8. Step 6. Write the migration
  9. Step 7. Add to the SPL
  10. Step 8. Verify the refinement
  11. Step 9. Verify in the browser
  12. Step 10. Test the uninstall cycle
  13. Step 11. Re-add the feature
  14. What you learned
  15. 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_notes from 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_notes

If 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. Run splent feature:detach splent-io/splent_feature_notes v1.0.0, then the feature:add above.


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.

How splent_feature_notes_tags extends splent_feature_notes splent_feature_notes AT STARTUP splent_feature_notes_tags Notes the model Notes, with a tags column refine_model NotesTagsMixin a column and two methods NotesService the service The locator serves the new class refine_service NotesServiceWithTags tag queries notes the table notes gains the tags column db:migrate Migration adds that column only
The refiner adds to the base feature when the app starts, and its own migration adds the column. The base feature's files never change, and removing the refiner leaves it as it was.

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

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 1 for Notes. The wizard names the mixin NotesTagsMixin.
  • Services. Type 1 for NotesService. The wizard names the replacement NotesServiceWithTags.
  • 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

  1. Model extension. The mixin is applied to the Notes model, adding its columns without touching the base feature’s models.py.
  2. Service override. The replacement takes the place of NotesService in the service locator, so every consumer that resolves it there (get_service_class or service_proxy) gets your version. The notes routes from Tutorial 3 create their own NotesService() when the module loads, so they do not get it yet. Tutorial 7 switches them to service_proxy.
  3. Validation. The framework checks that Notes and NotesService exist 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.py with 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.py with 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__.py with the imports and the refine_model() and refine_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, not notes_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.


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es