Skip to content

SQLList & session

sqllist

SQLList — module prototype serving one Model over envelope v2 with the viur-actions hook chain (can<X>/on<X>/then<X>/<X>Skel); suffix-less defaults are fail-closed.

ModelList

Bases: list

Instances plus getCursor()/get_orders() for render_list.

Source code in src/viur/models/sqllist.py
class ModelList(list):
    """Instances plus ``getCursor()``/``get_orders()`` for ``render_list``."""

    def __init__(self, items: t.Iterable = (), *, cursor: str | None = None,
                 orders: t.Iterable = ()):
        super().__init__(items)
        self._cursor = cursor
        self._orders = list(orders)

    def getCursor(self) -> str | None:
        return self._cursor

    def get_orders(self) -> list:
        return self._orders

SQLList

Bases: ActionModule, Module

Module prototype serving one Model.

Source code in src/viur/models/sqllist.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
class SQLList(ActionModule, Module):
    """Module prototype serving one ``Model``."""

    handler = "list"

    #: Renderer families (``__build_app`` reads truthy class attributes).
    json = True
    vi = True

    #: Envelope version; ``viur.actions.install()`` upgrades every mount carrying it.
    json_version = 2

    model: t.ClassVar[type[Model] | None] = None

    def __init__(self, moduleName: str, modulePath: str, *args: t.Any, **kwargs: t.Any):
        if type(self).model is None:
            raise NotImplementedError(
                f"{type(self).__name__} must set the ``model`` class attribute "
                "to the Model it serves."
            )
        super().__init__(moduleName, modulePath, *args, **kwargs)
        # first mount wins; caches built before it carry the kind fallback
        if MODULE_BY_MODEL.setdefault(type(self).model, moduleName) == moduleName:
            _drop_structure_caches()

    # --- suffix-less default hooks (viur-actions fallback chain) -----------

    def can(self, instance: Model | None) -> bool:
        """Fail-closed default."""
        return False

    def on(self, instance: Model) -> None:
        """Pre-commit hook default."""

    def then(self, instance: Model) -> None:
        """Post-commit hook default."""

    def skel(self, *args: t.Any, **kwargs: t.Any) -> type[Model]:
        """Model factory slot (``<x>Skel`` may narrow the model)."""
        return type(self).model

    def sqlFilter(self, stmt: t.Any) -> t.Any:
        """``listFilter`` analogue."""
        return stmt

    # --- helpers ------------------------------------------------------------

    def _require_v2_render(self) -> None:
        """``NotAcceptable`` unless the render reports ``version >= 2`` (duck-typed)."""
        if getattr(getattr(self, "render", None), "version", 0) < 2:
            raise errors.NotAcceptable(
                f"module {self.moduleName!r} serves envelope v2 only, but its "
                "render is not v2-capable. Call viur.actions.install() at app "
                "boot — SQLList's json_version = 2 pin upgrades every mount "
                "from there."
            )

    def _check(self, hooks: t.Any, instance: Model | None) -> None:
        if not get_hook_method(self, hooks, "can")(instance):
            raise errors.Forbidden()

    def _with_relations(
        self, stmt: t.Any, model_cls: type[Model], only: frozenset | None = None,
    ) -> t.Any:
        """Eager-load relations (dumps run detached); ``only`` limits them to the requested
        bones. Association links chain-load ``dest``."""
        for rel_name, info in model_cls.viur_relations().items():
            if only is not None and rel_name not in only:
                continue
            loader = selectinload(getattr(model_cls, rel_name))
            if info.get("link"):
                loader = loader.selectinload(getattr(info["link"], info["dest_rel"]))
            stmt = stmt.options(loader)
        return stmt

    def _restrict(
        self, stmt: t.Any, model_cls: type[Model], bones: frozenset | None,
        extra: t.Iterable[str] = (),
    ) -> t.Any:
        """Fetch only what a client bonelist needs — core unserializes a bone on access, here
        it is not even read: ``load_only`` on the requested columns (plus ``extra``, e.g. the
        sort column the cursor reads), ``selectinload`` on the requested relations. A computed
        bone reads arbitrary columns, so any of them keeps the full row. ``None``: full load."""
        if bones is None:
            return self._with_relations(stmt, model_cls)
        relations = model_cls.viur_relations()
        if not bones & set(model_cls.model_computed_fields):
            names = {"id"}
            for name in (*bones, *extra):
                if name in relations:
                    if (fk := relations[name]["fk"]) is not None:
                        names.add(fk)
                elif name in model_cls.model_fields:
                    names.add(name)
            stmt = stmt.options(load_only(*(getattr(model_cls, name) for name in names)))
        return self._with_relations(stmt, model_cls, only=bones)

    def _load(
        self, model_cls: type[Model], session: t.Any, key: t.Any,
        bones: frozenset | None = None,
    ) -> Model:
        primary_key = model_cls.viur_parse_key(str(key))
        if primary_key is None:
            raise errors.NotFound()
        stmt = self._restrict(
            select(model_cls).where(model_cls.id == primary_key), model_cls, bones,
        )
        instance = session.exec(stmt).one_or_none()
        if instance is None:
            raise errors.NotFound()
        return instance

    def _verify_relations(
        self, model_cls: type[Model], instance: Model, session: t.Any,
    ) -> list:
        """Existence check of relational input (``viur_from_client`` validates the key format only)."""
        from .client import relation_error

        pending = instance.__dict__.get("_viur_pending_relations", {})
        errs = []
        for rel_name, info in model_cls.viur_relations().items():
            if info.get("crossstore"):
                continue  # checked by read_dest
            if info.get("link"):
                if any(
                    session.get(info["target"], getattr(link, info["dest_fk"])) is None
                    for link in pending.get(rel_name, ())
                ):
                    errs.append(relation_error(rel_name, "Unknown key"))
            elif info["multiple"]:
                if any(
                    session.get(info["target"], primary_key) is None
                    for primary_key in pending.get(rel_name, ())
                ):
                    errs.append(relation_error(rel_name, "Unknown key"))
            elif (foreign_key := getattr(instance, info["fk"])) is not None \
                    and session.get(info["target"], foreign_key) is None:
                errs.append(relation_error(rel_name, "Unknown key"))
        return errs

    def _assign_pending_relations(
        self,
        model_cls: type[Model],
        instance: Model,
        source: Model,
        session: t.Any,
    ) -> None:
        """Resolve parked many-to-many keys into instances and assign them; ``source`` carries
        the pending map (the validated instance on edit, the instance itself on add)."""
        pending = source.__dict__.get("_viur_pending_relations")
        if not pending:
            return
        relations = model_cls.viur_relations()
        for rel_name, primary_keys in pending.items():
            info = relations[rel_name]
            if info.get("crossstore") or info.get("link"):
                targets = list(primary_keys)  # ready link rows; parent FK set on flush
            else:
                targets = [
                    session.get(info["target"], primary_key)
                    for primary_key in primary_keys
                ]
            setattr(instance, rel_name, targets)

    # --- actions ------------------------------------------------------------

    @action
    @exposed
    def list(self, **kwargs: t.Any) -> t.Any:
        self._require_v2_render()
        hooks = get_resolved_hooks(self, "list")
        self._check(hooks, None)
        model_cls = get_hook_method(self, hooks, "skel")()
        structure = model_cls._viur_structure_shared()  # read-only hot path
        bones = _client_bones(structure, model_cls)

        limit = _clamp_limit(kwargs.pop("limit", DEFAULT_LIMIT))
        cursor_payload = _decode_cursor(kwargs.pop("cursor", None))
        orderby = kwargs.pop("orderby", None)
        descending = str(kwargs.pop("orderdir", "0")).lower() in ("1", "desc", "descending")

        stmt = select(model_cls)

        # search: OR-LIKE over string-family fields; nothing searchable → unsatisfiable
        write_only = model_cls.viur_write_only()
        if (term := kwargs.pop("search", None)) not in (None, ""):
            searchable = [
                getattr(model_cls, name)
                for name, bone in structure.items()
                if bone["type"] in ("str", "text") or bone["type"].startswith("str.")
                if not bone["readonly"] and not bone["languages"]
                and name not in write_only and hasattr(model_cls, name)
            ]
            if searchable:
                pattern = f"%{_escape_like(str(term))}%"
                stmt = stmt.where(or_(*[
                    _ilike(column, pattern, model_cls) for column in searchable
                ]))
            else:
                stmt = stmt.where(false())

        # filters: field=value (lists → IN), $lt/$le/$gt/$ge/$lk on structure-known
        # writable scalars. Relations, cross-store refs and write-only bones have no
        # usable SQL expression — gates filters AND orderby.
        unqueryable = (
            write_only
            | set(model_cls.viur_relations())
            | set(model_cls.viur_crossstore())
        )
        for raw_key, value in kwargs.items():
            name, _, operator = raw_key.partition("$")
            if name not in structure or structure[name]["readonly"] \
                    or name in unqueryable or not hasattr(model_cls, name):
                continue
            if operator and isinstance(value, (list, tuple)):
                continue  # operators take scalars
            if structure[name]["type"].startswith("numeric"):
                if isinstance(value, (list, tuple)):
                    value = [v for v in map(_coerce_numeric, value) if v is not None]
                elif (value := _coerce_numeric(value)) is None:
                    continue
            column = getattr(model_cls, name)
            if operator == "lt":
                stmt = stmt.where(column < value)
            elif operator == "le":
                stmt = stmt.where(column <= value)
            elif operator == "gt":
                stmt = stmt.where(column > value)
            elif operator == "ge":
                stmt = stmt.where(column >= value)
            elif operator == "lk":
                stmt = stmt.where(
                    _ilike(column, f"{_escape_like(str(value))}%", model_cls),
                )
            elif operator:  # unknown suffix: equality
                stmt = stmt.where(column == value)
            elif isinstance(value, (list, tuple)):
                stmt = stmt.where(column.in_(value))
            else:
                stmt = stmt.where(column == value)

        # total order (sort column NULLS LAST, id tiebreaker); the cursor seeks past
        # the last row instead of an OFFSET
        orders = []
        sort_column = None
        if orderby and orderby in structure and not structure[orderby]["readonly"] \
                and orderby not in unqueryable and hasattr(model_cls, orderby):
            sort_column = getattr(model_cls, orderby)
            stmt = stmt.order_by(
                nullslast(sort_column.desc() if descending else sort_column.asc()),
                model_cls.id.asc(),
            )
            orders.append((orderby, "desc" if descending else "asc"))
        else:
            orderby = None
            stmt = stmt.order_by(model_cls.id.asc())

        # a cursor is only valid for its order
        if cursor_payload is not None and (
            cursor_payload.get("o") != orderby
            or cursor_payload.get("d") != ("desc" if descending else "asc")
        ):
            cursor_payload = None
        if cursor_payload is not None:
            values = cursor_payload["v"]
            if orderby is None:
                stmt = stmt.where(model_cls.id > values[-1])
            elif (last_value := _coerce_cursor_value(sort_column, values[0])) is None:
                # the previous page ended inside the NULL tail
                stmt = stmt.where(and_(
                    sort_column.is_(None), model_cls.id > values[-1],
                ))
            else:
                past_value = (
                    and_(sort_column < last_value, sort_column.is_not(None))
                    if descending else sort_column > last_value
                )
                stmt = stmt.where(or_(
                    past_value,
                    and_(sort_column == last_value, model_cls.id > values[-1]),
                    sort_column.is_(None),  # NULLS LAST: the tail comes after
                ))

        stmt = self._restrict(stmt, model_cls, bones, extra=(orderby,) if orderby else ())
        stmt = self.sqlFilter(stmt).limit(limit + 1)
        with get_session(model_cls) as session:
            rows = list(session.exec(stmt).all())

        has_more = len(rows) > limit
        rows = rows[:limit]
        cursor = _encode_cursor(orderby, descending, rows[-1]) if has_more else None
        if bones:
            for row in rows:
                row.__dict__["_viur_bones"] = bones
        return self.render.list(ModelList(rows, cursor=cursor, orders=orders))

    @action
    @exposed
    def view(self, key: str, **kwargs: t.Any) -> t.Any:
        self._require_v2_render()
        hooks = get_resolved_hooks(self, "view")
        model_cls = get_hook_method(self, hooks, "skel")()
        bones = _client_bones(model_cls._viur_structure_shared(), model_cls)
        with get_session(model_cls) as session:
            instance = self._load(model_cls, session, key, bones)
            self._check(hooks, instance)  # inside the session, like edit/delete
        if bones:
            instance.__dict__["_viur_bones"] = bones
        return self.render.view(instance)

    @action
    @force_ssl
    @exposed
    @skey(allow_empty=True)
    def add(self, **kwargs: t.Any) -> t.Any:
        self._require_v2_render()
        hooks = get_resolved_hooks(self, "add")
        self._check(hooks, None)
        model_cls = get_hook_method(self, hooks, "skel")()
        kwargs.pop("skey", None)
        bounce = _truthy(kwargs.pop("bounce", None))  # validated re-render, never writes

        if not kwargs:  # fresh form
            return self.render.add(model_cls())

        instance, client_errors = model_cls.viur_from_client(kwargs)
        if client_errors:  # rejected re-render
            instance.errors = client_errors
            return self.render.add(instance)

        with get_session(model_cls) as session:
            if relation_errors := self._verify_relations(model_cls, instance, session):
                instance.errors = relation_errors
                return self.render.add(instance)  # unknown relation target
            if bounce or not _is_post_request():
                return self.render.add(instance)  # validated preview
            self._assign_pending_relations(model_cls, instance, instance, session)
            get_hook_method(self, hooks, "on")(instance)  # onAdd
            session.add(instance)
            session.flush()
            _crossstore.sync_index(instance, session)
        get_hook_method(self, hooks, "then")(instance)  # thenAdd
        return self.render.addSuccess(instance)

    @action
    @force_ssl
    @exposed
    @skey(allow_empty=True)
    def edit(self, key: str, **kwargs: t.Any) -> t.Any:
        self._require_v2_render()
        hooks = get_resolved_hooks(self, "edit")
        model_cls = get_hook_method(self, hooks, "skel")()
        kwargs.pop("skey", None)
        bounce = _truthy(kwargs.pop("bounce", None))
        structure = model_cls._viur_structure_shared()  # read-only hot path
        relations = model_cls.viur_relations()
        editable = [
            name for name, bone in structure.items()
            if name != "key" and not bone["readonly"]
        ]
        # to-one relations are assigned via their FK column, many-to-many from the pending map
        assignable = [name for name in editable if name not in relations]
        assignable += [
            info["fk"] for name, info in relations.items()
            if name in editable and info["fk"] is not None
        ]

        with get_session(model_cls) as session:
            instance = self._load(model_cls, session, key)
            self._check(hooks, instance)

            if not kwargs:  # fresh form
                return self.render.edit(instance)

            # merge over the stored dump; write-only excluded (masked dump), multiples
            # cleared (browsers post nothing for an empty selection)
            write_only = model_cls.viur_write_only()
            multiple = {
                name for name in editable if structure[name].get("multiple")
            }
            merged = (
                {
                    name: value for name, value in instance.viur_dump().items()
                    if name in editable
                    and name not in write_only and name not in multiple
                }
                | {name: [] for name in multiple}
                | kwargs
            )
            validated, client_errors = model_cls.viur_from_client(merged)
            if client_errors:  # rejected re-render, keyed like the stored row
                validated.id = instance.id
                validated.errors = client_errors
                return self.render.edit(validated)

            if relation_errors := self._verify_relations(model_cls, validated, session):
                validated.id = instance.id
                validated.errors = relation_errors
                return self.render.edit(validated)  # unknown relation target

            if bounce or not _is_post_request():  # validated preview; validated is transient
                validated.id = instance.id
                return self.render.edit(validated)

            for name in assignable:
                if name in write_only and getattr(validated, name) in (None, ""):
                    continue  # empty write-only input keeps the stored value
                setattr(instance, name, getattr(validated, name))
            # expire loaded to-one relations (an FK may have changed); many-to-many are assigned below
            if single := [n for n, i in relations.items() if not i["multiple"]]:
                session.expire(instance, single)
            self._assign_pending_relations(model_cls, instance, validated, session)
            _crossstore.sync_index(instance, session)
            instance.changedate = _utcnow()
            get_hook_method(self, hooks, "on")(instance)  # onEdit

        get_hook_method(self, hooks, "then")(instance)  # thenEdit
        return self.render.editSuccess(instance)

    @action
    @force_ssl
    @force_post
    @exposed
    @skey
    def delete(self, key: str, **kwargs: t.Any) -> t.Any:
        self._require_v2_render()
        hooks = get_resolved_hooks(self, "delete")
        model_cls = get_hook_method(self, hooks, "skel")()
        with get_session(model_cls) as session:
            instance = self._load(model_cls, session, key)
            self._check(hooks, instance)
            get_hook_method(self, hooks, "on")(instance)  # onDelete
            _crossstore.drop_index(instance, session)
            session.delete(instance)
        get_hook_method(self, hooks, "then")(instance)  # thenDelete
        return self.render.deleteSuccess(instance)

    @exposed
    def structure(self, action: str = "view") -> t.Any:
        """Structure per action (``<action>Skel`` + ``can<action>``), like ``List.structure``."""
        self._require_v2_render()
        try:
            hooks = get_resolved_hooks(self, action)
        except KeyError:
            raise errors.NotImplemented(f"The action {action!r} is not implemented.")
        model_cls = get_hook_method(self, hooks, "skel")()
        self._check(hooks, None)
        return self.render.render(f"structure.{action}", model_cls())

can

can(instance: Model | None) -> bool

Fail-closed default.

Source code in src/viur/models/sqllist.py
def can(self, instance: Model | None) -> bool:
    """Fail-closed default."""
    return False

on

on(instance: Model) -> None

Pre-commit hook default.

Source code in src/viur/models/sqllist.py
def on(self, instance: Model) -> None:
    """Pre-commit hook default."""

then

then(instance: Model) -> None

Post-commit hook default.

Source code in src/viur/models/sqllist.py
def then(self, instance: Model) -> None:
    """Post-commit hook default."""

skel

skel(*args: Any, **kwargs: Any) -> type[Model]

Model factory slot (<x>Skel may narrow the model).

Source code in src/viur/models/sqllist.py
def skel(self, *args: t.Any, **kwargs: t.Any) -> type[Model]:
    """Model factory slot (``<x>Skel`` may narrow the model)."""
    return type(self).model

sqlFilter

sqlFilter(stmt: Any) -> Any

listFilter analogue.

Source code in src/viur/models/sqllist.py
def sqlFilter(self, stmt: t.Any) -> t.Any:
    """``listFilter`` analogue."""
    return stmt

structure

structure(action: str = 'view') -> Any

Structure per action (<action>Skel + can<action>), like List.structure.

Source code in src/viur/models/sqllist.py
@exposed
def structure(self, action: str = "view") -> t.Any:
    """Structure per action (``<action>Skel`` + ``can<action>``), like ``List.structure``."""
    self._require_v2_render()
    try:
        hooks = get_resolved_hooks(self, action)
    except KeyError:
        raise errors.NotImplemented(f"The action {action!r} is not implemented.")
    model_cls = get_hook_method(self, hooks, "skel")()
    self._check(hooks, None)
    return self.render.render(f"structure.{action}", model_cls())

db

Named engines (Model.viur_database), one session per action, RecordJSON column type.

RecordJSON

Bases: TypeDecorator

JSON column for nested records: record_cls instances (or lists) dump on write, validate on read.

Source code in src/viur/models/db.py
class RecordJSON(TypeDecorator):
    """JSON column for nested records: ``record_cls`` instances (or lists) dump on write, validate on read."""

    impl = JSON
    cache_ok = True

    def __init__(self, record_cls: type[SQLModel]):
        super().__init__()
        self.record_cls = record_cls

    def process_bind_param(self, value: t.Any, dialect: t.Any) -> t.Any:
        if value is None:
            return None
        if isinstance(value, (list, tuple)):
            return [self._to_plain(item) for item in value]
        return self._to_plain(value)

    def process_result_value(self, value: t.Any, dialect: t.Any) -> t.Any:
        if value is None:
            return None
        if isinstance(value, list):
            return [self.record_cls.model_validate(item) for item in value]
        return self.record_cls.model_validate(value)

    @staticmethod
    def _to_plain(value: t.Any) -> t.Any:
        return value.model_dump(mode="json") if isinstance(value, SQLModel) else value

configure

configure(engine_or_url: Engine | str, *, name: str = DEFAULT, **create_engine_kwargs: Any) -> Engine

Register the engine name from a URL (NullPool default) or a ready engine. At boot.

Source code in src/viur/models/db.py
def configure(
    engine_or_url: "Engine | str", *, name: str = DEFAULT, **create_engine_kwargs: t.Any,
) -> "Engine":
    """Register the engine ``name`` from a URL (``NullPool`` default) or a ready engine. At boot."""
    if isinstance(engine_or_url, str):
        create_engine_kwargs.setdefault("poolclass", NullPool)
        engine = create_engine(engine_or_url, **create_engine_kwargs)
    else:
        engine = engine_or_url
    if engine.dialect.name == "bigquery":
        import logging

        from .bigquery import apply_engine_workarounds

        for note in apply_engine_workarounds(engine):
            logging.getLogger(__name__).warning("viur-models[bigquery]: %s", note)
    _engines[name] = engine
    return engine

tables_for

tables_for(target: Target) -> list[Any]

Tables of target's database: those of models bound to it plus every table without a viur_database owner (viur-models' internal tables, present in each database).

Source code in src/viur/models/db.py
def tables_for(target: Target) -> list[t.Any]:
    """Tables of ``target``'s database: those of models bound to it plus every table
    without a ``viur_database`` owner (viur-models' internal tables, present in each database)."""
    name = database_of(target)
    owners = {
        mapper.local_table: mapper.class_
        for mapper in SQLModel._sa_registry.mappers
    }
    return [
        table for table in SQLModel.metadata.tables.values()
        if not hasattr(owners.get(table), "viur_database")
        or database_of(owners[table]) == name
    ]

reset

reset() -> None

Dispose and drop every engine (test isolation).

Source code in src/viur/models/db.py
def reset() -> None:
    """Dispose and drop every engine (test isolation)."""
    for engine in _engines.values():
        engine.dispose()
    _engines.clear()

get_session

get_session(target: Target = None) -> Iterator[Session]

Commit on success, rollback on error. expire_on_commit=False — instances stay readable after close.

Source code in src/viur/models/db.py
@contextmanager
def get_session(target: Target = None) -> t.Iterator[Session]:
    """Commit on success, rollback on error. ``expire_on_commit=False`` — instances stay readable after close."""
    session = Session(get_engine(target), expire_on_commit=False)
    try:
        yield session
        session.commit()
    except BaseException:
        session.rollback()
        raise
    finally:
        session.close()

schema_revision

schema_revision(engine: Engine | None = None) -> str | None

Stamped Alembic revision via plain SQL; None without an alembic_version table.

Source code in src/viur/models/db.py
def schema_revision(engine: "Engine | None" = None) -> str | None:
    """Stamped Alembic revision via plain SQL; ``None`` without an ``alembic_version`` table."""
    from sqlalchemy import inspect, text

    engine = engine or get_engine()
    with engine.connect() as connection:
        if not inspect(connection).has_table(ALEMBIC_VERSION_TABLE):
            return None
        row = connection.execute(
            text(f"SELECT version_num FROM {ALEMBIC_VERSION_TABLE}"),  # noqa: S608
        ).first()
    return row[0] if row else None

url_from_preset

url_from_preset(engine: str | None, *, sqlite_file: str = 'viur_models.sqlite3', postgres_dsn: str = '', bigquery_dsn: str = '') -> str

Database URL for a conf.models preset.

Source code in src/viur/models/db.py
def url_from_preset(
    engine: str | None, *, sqlite_file: str = "viur_models.sqlite3",
    postgres_dsn: str = "", bigquery_dsn: str = "",
) -> str:
    """Database URL for a ``conf.models`` preset."""
    if engine == "memory":
        return "sqlite://"
    if engine == "sqlite":
        return f"sqlite:///{sqlite_file}"
    if engine == "postgres":
        if not postgres_dsn:
            raise RuntimeError(
                'engine "postgres" needs a DSN (postgres_dsn, '
                'e.g. "postgresql+pg8000://user:pw@host:5432/db")'
            )
        return postgres_dsn
    if engine == "bigquery":
        if not bigquery_dsn:
            raise RuntimeError(
                'engine "bigquery" needs a DSN (bigquery_dsn, '
                'e.g. "bigquery://my-project/my_dataset") — see '
                "viur.models.bigquery for the backend's compromises"
            )
        return bigquery_dsn
    raise RuntimeError(
        'engine must be "memory", "sqlite", "postgres" or "bigquery" '
        f"(got {engine!r}) — set it in conf.models.databases before building the engine"
    )

url_from_conf

url_from_conf(name: str = DEFAULT) -> str

Database URL of a conf.models preset (url entries pass through).

Source code in src/viur/models/db.py
def url_from_conf(name: str = DEFAULT) -> str:
    """Database URL of a ``conf.models`` preset (``url`` entries pass through)."""
    settings = _settings(name)
    if url := settings.get("url"):
        return url
    return url_from_preset(
        settings.get("engine"),
        sqlite_file=settings.get("sqlite_file", "viur_models.sqlite3"),
        postgres_dsn=settings.get("postgres_dsn", ""),
        bigquery_dsn=settings.get("bigquery_dsn", ""),
    )

configure_from_conf

configure_from_conf() -> Engine

One engine per conf.models.databases entry. Returns the default engine.

Source code in src/viur/models/db.py
def configure_from_conf() -> "Engine":
    """One engine per ``conf.models.databases`` entry. Returns the default engine."""
    from .config import install_config

    engines = {name: _configure_preset(name) for name in install_config().databases}
    if DEFAULT not in engines:
        raise RuntimeError(f"conf.models.databases has no entry {DEFAULT!r}")
    return engines[DEFAULT]

bigquery

BigQuery backend: string ids, TIMESTAMP system fields, dialect workarounds. Extra spltz-viur-models[bigquery]; constraints in docs/bigquery.md.

BigQueryModel

Bases: Model

Model with a client-generated string id and TIMESTAMP system datetimes.

Source code in src/viur/models/bigquery.py
class BigQueryModel(Model):
    """``Model`` with a client-generated string ``id`` and ``TIMESTAMP`` system datetimes."""

    __mapper_args__ = {"confirm_deleted_rows": False}

    id: str | None = SQLModelField(default_factory=new_id, primary_key=True)
    creationdate: datetime | None = Field(
        default_factory=lambda: datetime.now(timezone.utc),
        sa_type=sa.TIMESTAMP(timezone=True),
        readonly=True, visible=False, tags=("technical",),
        descr="created at", compute={"method": "Once"},
    )
    changedate: datetime | None = Field(
        default_factory=lambda: datetime.now(timezone.utc),
        sa_type=sa.TIMESTAMP(timezone=True),
        readonly=True, visible=False, tags=("technical",),
        descr="updated at", compute={"method": "OnWrite"},
    )

new_id

new_id(*, _now_ms: Callable[[], int] | None = None) -> str

ULID-style id: 48-bit ms timestamp + 80 random bits, 26 chars Crockford base32. Never all digits — viur_parse_key would read those as an int key.

Source code in src/viur/models/bigquery.py
def new_id(*, _now_ms: t.Callable[[], int] | None = None) -> str:
    """ULID-style id: 48-bit ms timestamp + 80 random bits, 26 chars Crockford base32.
    Never all digits — ``viur_parse_key`` would read those as an int key."""
    now_ms = _now_ms or (lambda: time.time_ns() // 1_000_000)
    while True:
        candidate = (
            _encode_base32(now_ms() & (2**48 - 1), 10)
            + _encode_base32(int.from_bytes(os.urandom(10)), 16)
        )
        if not candidate.isdigit():
            return candidate

apply_engine_workarounds

apply_engine_workarounds(engine: Any) -> list[str]

Clear the rowcount capabilities BigQuery DML cannot satisfy; returns log notes.

Source code in src/viur/models/bigquery.py
def apply_engine_workarounds(engine: t.Any) -> list[str]:
    """Clear the rowcount capabilities BigQuery DML cannot satisfy; returns log notes."""
    notes = []
    if engine.dialect.supports_sane_rowcount or engine.dialect.supports_sane_multi_rowcount:
        engine.dialect.supports_sane_rowcount = False
        engine.dialect.supports_sane_multi_rowcount = False
        notes.append("rowcount checks disabled (DML jobs do not report matched rows)")
    notes.append(
        "transactions are NO-OPS on BigQuery — session.rollback() cannot undo "
        "anything; a failed action may leave earlier statements applied"
    )
    return notes