Second Brain documentation

ADR 0028 – Moteur de vues : langage fermé traduit en SQL

Accepté (2026-10-15, mode autonome)

← Toutes les décisions

ADR 0028 – Moteur de vues : langage fermé traduit en SQL

Statut : Accepté (2026-10-15, mode autonome)

Décisions prises en mode autonome (Aurélien, 2026-10-15 : options les plus simples, sûres et réversibles).

Contexte

Le jalon 2 veut des vues déclarées dans config/views/ : quels nœuds, triés comment, avec quelles colonnes. La condition de filtre est écrite par l’utilisateur dans un fichier YAML. Elle ne doit jamais devenir du code exécuté, ni du SQL concaténé.

Décision

Format

name: factures-a-payer
label: Factures à payer
icon: receipt_long            # facultatif
query:
  aspects: [facture]          # le nœud les porte tous
  types: [facture-fournisseur] # l'un d'eux
  tags: [maison]              # tous, sous-tags compris (ADR 0006)
  where: "!facture.payee && facture.echeance <= today + 14d"
  sort: [facture.echeance asc, title desc]   # 5 clés au plus, vides en dernier
  limit: 200                  # 1 à 1000, défaut 200
display:
  mode: table                 # table (défaut), list, kanban
  columns: [title, facture.montant, facture.echeance, link:concerne-projet]
  groupBy: tache.statut       # enum, type ou booléen ; exigé par kanban

Les colonnes possibles sont :

  • title, type, tags, createdAt, updatedAt ;
  • <aspect>.<champ> (champ de premier niveau) ;
  • link:<type de lien>, qui donne les cibles sortantes.

Le méta-schéma view.schema.json fixe la forme. Le chargeur vérifie ensuite que les aspects, types, champs et liens existent, et il analyse puis type la condition. Une vue fautive est ignorée et signalée, comme les autres définitions. Lors d’un rechargement, sa version précédente est conservée si elle reste valide.

Langage where

La grammaire est fermée (definitions/query/ExprParser) :

  • || et && ;
  • ! ;
  • les comparaisons == != < <= > >= ;
  • les parenthèses ;
  • les littéraux 'texte' ou "texte", les nombres (négatifs compris), true, false, null ;
  • today et now, avec ou sans décalage ± N d|w|m.

Les champs sont ceux des colonnes, sans tags ni liens. Il n’existe ni appel de fonction, ni opérateur arithmétique, ni accès imbriqué.

Garde-fous :

  • 2 000 caractères au plus ;
  • 32 niveaux d’imbrication au plus ;
  • décalage de 4 chiffres au plus.

Les erreurs sont en français, avec leur position.

Le contrôle de type (ExprChecker) vérifie les points suivants :

  • la condition est booléenne ;
  • chaque comparaison porte sur au moins un champ ;
  • le littéral a le type du champ : nombre, booléen, date AAAA-MM-JJ ou today pour une date, valeur de l’enum ou type chargé, UUID pour une référence ;
  • < <= > >= ne servent que pour le texte, les nombres et les dates ;
  • null ne s’emploie qu’avec == et != ;
  • now est refusé sur une date (seul today y est admis).

Le type d’un champ vient de son JSON Schema (Fields).

Traduction SQL (node/ViewSql)

Chaque aspect cité est joint une fois (left join node_aspect aN). Le nom de l’aspect, le nom du champ et les valeurs sont des paramètres liés. Les opérateurs viennent d’une table fixe. today et now sont résolus par le serveur dans le fuseau zone du navigateur, avec l’horloge de l’application.

On applique une logique à deux valeurs, pour que ! se comporte comme on l’attend :

  • une comparaison d’ordre avec une valeur absente est fausse (coalesce(…, false)) ;
  • == et != traitent l’absence comme une valeur (is [not] distinct from), donc tache.echeance != '2026-10-20' retient les tâches sans échéance ;
  • un booléen absent vaut faux, donc !facture.payee retient les factures sans le champ ;
  • champ == null est vrai si la valeur est absente : clé manquante, null, ou valeur du mauvais type JSON.
  • Date-heure comparée à un jour (today, 'AAAA-MM-JJ') : le jour est l’intervalle [minuit, minuit du lendemain[ du fuseau. Ainsi createdAt == today retient ce qui date d’aujourd’hui et createdAt <= '2026-10-15' inclut le 15.
  • Référence : un UUID littéral est canonisé en minuscules, comme les valeurs stockées.

Les données sont tolérées à la lecture : une valeur du mauvais type JSON vaut absente, et la requête n’échoue jamais.

  • Nombre : il est lu seulement si jsonb_typeof = 'number' (de même 'string' pour un texte, un enum, une référence, et 'boolean' pour un booléen).
  • Date : elle est comparée en texte AAAA-MM-JJ, en collation C, si elle a cette forme.
  • Date-heure d’aspect : elle passe par sb_try_timestamptz (migration V9), fonction SQL inlinable sans bloc d’exception : forme ISO 8601 exigée, puis pg_input_is_valid (PostgreSQL 16 et plus). Elle rend null au lieu d’une erreur, et refuse now ou infinity.

Ordre de tri :

  1. les clés de la vue, valeurs vides en dernier ;
  2. le titre, sans tenir compte de la casse ;
  3. l’id.

Un enum se trie dans l’ordre déclaré du schéma (array_position), si bien que priorite desc donne haute, normale, basse.

API

  • GET /api/definitions/views : définitions, triées par libellé.
  • GET /api/views/{name}/results?zone=&limit= renvoie :
    • les colonnes (clé, libellé, nature, valeurs d’enum) ;
    • les lignes (values par clé de colonne, group si groupBy) ;
    • groups, dans l’ordre du schéma ;
    • truncated.

limit est plafonnée par celle de la vue. Les autres conventions :

  • un champ référence est rendu par l’id de sa cible, une colonne link: par la liste des ids de ses cibles hors corbeille, triées par titre ;
  • nodes décrit ces nœuds par id (NodeReference : titre, type, deleted) ; un id absent de nodes est introuvable ;
  • tags réunit les tags saisis et ceux du corps.

Pas de pagination par curseur pour l’instant : la limite et truncated suffisent aux volumes d’un deuxième cerveau personnel.

Interface

  • Le menu « Vue : … ▾ » de l’en-tête liste les vues sous les pages.
  • La route /views/:name affiche les résultats en table AG Grid, en lecture seule.
  • Un clic sur une ligne, ou Entrée sur une cellule, ouvre le nœud.
  • L’onglet de page porte le libellé et l’icône de la vue.
  • Les modes list et kanban, le tri, le regroupement et l’édition en ligne viennent avec les tranches suivantes. D’ici là, toute vue s’affiche en table.

Amendement (ADR 0029) : la table n’est plus en lecture seule. Tri par en-tête (paramètre sort), regroupement et édition en ligne des champs d’aspect.

Amendement (ADR 0030) : le mode list s’affiche en liste compacte, avec une bascule Table/Liste sur la page.

Amendement (ADR 0035) : le mode kanban s’affiche en plateau (colonnes, couloirs, limite WIP, déplacement qui écrit le champ).

Alternatives écartées

  • SpEL ou un évaluateur d’expressions : il serait trop puissant, car une expression écrite dans un fichier deviendrait du code. Il serait aussi évalué en Java, après lecture de tous les nœuds.
  • JSONPath ou jsonb_path_query : non typé, sans contrôle au chargement, avec des messages d’erreur peu lisibles.
  • Cast SQL direct (::date, ::timestamptz) : une seule valeur ancienne non conforme ferait échouer toute la vue.

Conséquences

  • Ajouter un opérateur ou une fonction (par exemple in, contains sur un tableau, ou un filtre de sous-arbre) demande de modifier, avec leurs tests :
    • la grammaire ;
    • le contrôle de type ;
    • la traduction SQL.
  • Le filtre de sous-arbre, paramétré par le nœud courant, arrive avec les vues contextuelles.

    Amendement (ADR 0036) : les vues contextuelles apportent node et parent (parent == node : enfants directs) ; le sous-arbre complet (descendants) est écarté tant qu’aucun besoin ne le demande.

  • Les tags de query.tags sont canonisés à l’exécution mais pas contrôlés au chargement : un tag refusé par la normalisation ne retient aucun nœud.
  • Une vue nouvelle qui cite un aspect dont le fichier est devenu invalide (et qui garde sa version précédente) est écartée : elle n’est contrôlée que contre les définitions fraîches.
  • today dépend du fuseau de la requête : une même vue peut différer d’un jour selon le navigateur, comme les valeurs par défaut (ADR 0017).