Skip to content

Data Adapters

The abstraction layer between NiceView components and storage backends.

← Back to the overview

Data Adapters

Adapters decouple UI components from storage. Pass them explicitly for full control, or let the from_* factory methods create them transparently.

Adapter Backs Description
ListAdapter(Type, list) Grid In-memory list
JsonAdapter(Type, path) Form Single object in a JSON file; supports lock_field=, created_field=, strict=
JsonListAdapter(Type, path) Grid List of objects in a JSON file; supports created_field=, strict=
JsonDirectoryAdapter(Type, dir_path, key_field=) Grid, Form, DrillDownWrapper One parsed object per file, named by a model-owned key; supports sort_key=, created_field=, lock_field=, strict=
SqlModelAdapter(Type, engine) Grid, Form SQLModel / SQLAlchemy table (requires the sqlmodel extra, see Installation)
DirectoryAdapter(dir_path) DrillDownWrapper One file per item in a directory; items are filename metadata (FileEntry), not parsed content — supports rename(); suffix=None for a mixed-extension file browser
FilteredAdapter(inner, predicate, defaults=) Grid, DrillDownWrapper Filtered view of another CollectionAdapter (see below)

All JSON adapters write atomically (.tmp → rename). JsonListAdapter, JsonDirectoryAdapter and SqlModelAdapter all implement ReloadableAdapter: reload() re-reads from disk (JsonListAdapter) or fires a grid-refresh notification (SqlModelAdapter and JsonDirectoryAdapter, where every read() is already live from disk).

DirectoryAdapter models "files in a directory" for DrillDownWrapper's file-per-item use case. items()/read() return FileEntry(name, mtime, size) — metadata only, never the parsed file content; open the file's own JsonAdapter/JsonListAdapter for that (typically inside render_detail). Keys never carry the file suffix — name is always the bare filename stem, so a "Name" widget in render_detail never has to show or strip .json itself; if a user types the suffix anyway, create()/rename() strip a trailing match rather than doubling it up ("note.json"note, file note.json). create(item=None) picks a free 'untitled-01', 'untitled-02', ... name and writes default_content; rename(key, new_key) renames the file on disk. Both are meant to be called directly by application code (an "Add" handler, a "Name" widget's blur handler), not through DrillDownWrapper's generic item_type()-based Add flow — see examples/13_directory_drilldown.py.

JsonDirectoryAdapter is the parsed sibling of DirectoryAdapter: each file holds one Pydantic model (like JsonAdapter), but the collection is a directory of them (like JsonListAdapter), one item per file. The key is the value of a model field (key_field=, default 'id') and is also the file name — item x lives at <dir>/<x.id>.json. So a model must own a stable key (typically id: str = Field(default_factory=lambda: uuid4().hex)) that never changes, which is what lets other records reference an item by key across renames of any other field — the natural backing store for a key-select foreign key (see Concepts → Relationships). CRUD is strict: create() refuses an existing key, update()/delete() an absent one, matching the DrillDownWrapper Add-then-autosave flow. Reads always hit disk, so the listing is always fresh (reload() just fires a refresh notification — there is no cache); a file that cannot yield a keyed record — malformed JSON, a non-object root, or a missing/invalid key — is skipped from the listing (logged), never listed as an empty ghost. strict= (default False, like the other JSON adapters) decides what happens to a recoverable file — a valid object with one bad field: lenient drops the field, strict rejects (and skips) it. Per-file IO goes through JsonAdapter, so created_field=/lock_field= optimistic locking work per item. sort_key= orders the listing (default: by key).

Passing suffix=None (or '') switches DirectoryAdapter into all-files mode — a general file browser over a directory with mixed extensions. Keys become the full filename (extension included, no stripping), and create() takes a full name verbatim (create() without an item generates a bare untitled-NN). The default suffix mode (suffix='.json') keeps keying by stem.

In both modes the listing covers regular files only, hidden dotfiles are excluded, and an optional name_filter=Callable[[str], bool] narrows it further. name_filter always receives the full filename including the extension, never the key — so it reads the same in either mode. Note that dotfile exclusion is not redundant in suffix mode: pathlib's glob('*.json') does match .hidden.json. Files whose key would be unusable (a bare .json in suffix mode, or a name containing a path separator) are skipped rather than raising, so a single odd file cannot break the whole listing; addressing such a file explicitly by key still raises ValueError.

FilteredAdapter wraps any CollectionAdapter and filters iteration by a predicate — the standard way to show a parent-filtered view of a child collection (e.g. only the books of one author) while mutations still go through the inner adapter for persistence. defaults= injects field values on create(), so new items automatically belong to the current parent. Change notifications and reload() are forwarded from/to the inner adapter. See examples/11_tree_navigation.py.

from niceview import FilteredAdapter

books_of_author = FilteredAdapter(books_adapter,
    predicate=lambda b: b.author_id == author.id,
    defaults={'author_id': author.id},   # applied to every create()
)
ModelGrid(Book, books_of_author).render()

Lenient loading (default) vs strict loading: JsonAdapter and JsonListAdapter default to strict=False, which means a hand-edited or partially-migrated file does not crash the application:

  • Malformed JSON or a non-object/non-array root → log.error, return a default instance / empty list.
  • Unknown fieldlog.error, field ignored.
  • Field with invalid valuelog.error, field dropped and replaced by its model default.
  • Required field absent (no default)log.error, item/load fails and raises (last resort).
  • OSError reading the file (JsonAdapter only) → log.error, return Type() with all defaults.

Set strict=True to restore the original behaviour where any read error raises immediately. The StorageError raised by JsonAdapter.save() during an optimistic-locking check is only possible in strict mode (in lenient mode a corrupted file returns None lock values, so the check is skipped).

The helper functions lenient_model_load and lenient_list_load are also importable from niceview for use outside the built-in adapters.

Breaking change (since lenient default): code that relied on JsonAdapter.read() or JsonListAdapter raising on a malformed file must pass strict=True explicitly.

from niceview import lenient_model_load, lenient_list_load

# Load a single model from a JSON string, tolerating bad fields:
item = lenient_model_load(MyModel, json_text, context='myfile.json')

# Load a list, skipping unrecoverable items:
items = lenient_list_load(MyModel, json_text, context='myfile.json')

Optimistic locking: When lock_field= is set, save() compares the stored timestamp against the one in memory before writing. The check only fires when both sides have a non-None value — a None on either side is treated as "no lock data" and the save proceeds. Two exceptions can be raised; both are caught by ModelForm.save() and EditGridWrapper with a user-facing ui.notify:

Exception When raised
niceview.ConflictError Both timestamps are set but differ (concurrent modification)
niceview.StorageError The stored file cannot be read during the lock check (corrupted, wrong schema, I/O error)

Custom code calling adapter.save() directly should handle both:

from niceview import ConflictError, StorageError

try:
    adapter.save(item)
except ConflictError:
    ui.notify('Someone else changed this item. Please reload.', color='warning')
except StorageError:
    ui.notify('The data file could not be read. Please contact your administrator.', color='negative')

Adapter protocols — implement these for custom backends:

Protocol Methods Used by
ItemAdapter[T] read() -> T, save(item) -> T ModelForm
CollectionAdapter[T] __iter__, key_from_item(item) -> str, items() -> Iterator[(str, T)], read(key) -> T, create(item) -> T, update(item) -> T, delete(key) ModelGrid, EditGridWrapper
ReloadableAdapter reload() EditGridWrapper (Refresh button), FilteredAdapter (forwarded)
ReactiveAdapter on_change(handler) ModelGrid (auto-update)

update() returns the stored item, which may differ from the input (e.g. SqlModelAdapter refreshes updated_at). key_from_item() raises KeyError if the item is not in the adapter; read() raises KeyError/ValueError if the key is not found.

items() yields (key, item) pairs — like dict.items(), useful whenever key and item are needed together (e.g. building navigation URLs):

for key, project in projects_adapter.items():
    with ui.card().on('click', lambda k=key: ui.navigate.to(f'/projects/{k}')):
        ui.label(project.name)

BoundItem(adapter, key) wraps a CollectionAdapter + a string key into an ItemAdapter — the standard bridge for master-detail navigation (e.g. ModelForm.from_adapter()). BoundItem can be imported directly from niceview (from niceview import BoundItem).

BoundFieldAdapter(parent_adapter, field_name) wraps an ItemAdapter + a field name into an ItemAdapter for one named sub-field of the parent item. Its purpose is the one thing ModelForm does not render on its own: a single embedded model (address: Address). Bind a second form to that field by focusing the parent adapter onto it:

parent = JsonAdapter(Device, path)
ModelForm.from_adapter(Device, parent).render()                         # the parent's own fields
ModelForm.from_adapter(Address, BoundFieldAdapter(parent, 'address')).render()   # the nested model

save() is read-modify-write: it re-reads the parent, sets only this one field, and saves the parent back. So sibling fields are never touched, and several BoundFieldAdapters over the same parent stay independent — one ModelForm per card, edited in any order, each save picking up the others' saved values. The flip side: it does not take part in the parent adapter's optimistic locking (re-reading right before the write makes the lock token always match). Do not set a lock_field on a parent reached only through BoundFieldAdapters — it adds no protection and its token bump would make a concurrently open full-parent form see a false conflict. For real single-field locking, bind a ModelForm to the parent adapter directly instead.

Reactive updates

All built-in adapters implement ReactiveAdapter via the _ChangeNotifier mixin. ModelGrid.render() detects this and registers update_rows() automatically, so structural mutations through the adapter (create / update / delete) refresh the grid without any manual call — for in-memory lists, JSON files, and SQL databases alike.

What is not caught automatically: in-place attribute changes on existing items (item.name = 'new'). These bypass the adapter entirely. Use grid.update_rows() or the EditGridWrapper Refresh button for that case.

# Adapter mutations → grid auto-updates (all adapter types)
adapter = ListAdapter(User, items)
adapter = ListAdapter(User, items, created_field='created_at')   # set created_at on create()
grid = ModelGridInlineEdit.from_adapter(User, adapter)
grid.render()
adapter.create(User(name='Carol'))   # grid refreshes automatically
adapter.delete(key)                  # grid refreshes automatically

# In-place attribute change → manual refresh needed
items[0].name = 'new name'
grid.update_rows()                   # must call explicitly

ObservableList additionally catches direct mutations on the list object that bypass the adapter — useful when non-NiceView code appends to the same list:

from nicegui.observables import ObservableList

obs = ObservableList([User(name='Alice')])
grid = ModelGrid.from_list(User, obs)
grid.render()
obs.append(User(name='Bob'))   # also triggers update_rows(), no adapter call needed
from niceview import SqlModelAdapter

adapter = SqlModelAdapter(Book, engine)                                          # optimistic locking on updated_at
adapter = SqlModelAdapter(Book, engine, lock_field=None)                         # without locking
adapter = SqlModelAdapter(Book, engine, created_field='created_at')              # set created_at on create()
adapter = SqlModelAdapter(Book, engine, created_field='created_at', lock_field='updated_at')  # both

# Form for a specific record (fields only) — key must be str
form = ModelForm.from_adapter(Book, adapter, str(book_id))
form.render()

# Form with chrome (title + save/refresh buttons)
EditFormWrapper.from_adapter(Book, adapter, str(book_id), title='Edit Book').render()

# Grid over the full table
ModelGrid(Book, adapter).render()
EditGridWrapper.from_adapter(Book, adapter, title='Books').render()