Pour utiliser une base de données dans une application web, nous devons construire une API. Mais que se passerait-il si nous pouvions utiliser une base de données directement depuis le navigateur ?

Cet article part du principe que nous allons interroger la base de données à partir d'un simple serveur statique. Nous ne pourrons donc faire que des opérations de lecture, ce qui est ce dont j'ai réellement besoin, mais peut-être pas pour tous les lecteurs de ces lignes.

Cela fait un moment que le cherche un moyen de partager facilement une base de données pour un petit projet ne justifiant pas la mise en place d’un “vrai” serveur de base de données. Jusque là, j’envisageais de tester un DBaaS un peu innovant, du genre PlanetScale, Prisma, Back4App ou OrbitDB. Mais si l’envie de tester ces services est toujours là, cela ne répondait pas vraiment à mon objectif. Cela ne ferait que déplacer le “vrai” serveur vers un tiers, ce que ne justifiait pas en termes d’infrastructure (et donc d’impact … écologique) mon petit projet.

Alors certes, qui dit petit projet dit petite base de données facilement exportable en json ou en csv. Mais je suis tombé sur cet excellent article de phiresky, “Hosting SQLite databases on Github Pages”, qui m’a bien donné envie de tester cette solution à base d’une vraie base de données plutôt que de partir sur de simples fichiers d’export. Et je vais faire le test avec reac-admin.

Un client SQLite pour le web

sql.js-httpvfs c’est le petit nom du composant open source de phiresky. Il s’agit en fait d’un fork de sql.js. Je ne rentrerais pas dans les détails, le post le blog original le fera mieux que moi, mais l’utilisation de ce composant s’appuyant sur des web workers et du WebAssembly, cela implique deux choses.

Tout d’abord, il rend plus difficile le bootstrap d’une application react-admin. En effet, on se complique les choses en lançant un create-react-app car on est alors obligé de surcharger la configuration du webpack en version 4 afin de pouvoir gérer correctement la partie Wasm du composant. Et personne n’aime à avoir à surcharger une configuration sur une application create-react-app. Pour ma part, je suis partie sur un dépôt de bootstrap React incluant directement le webpack 5.

Ensuite, le lancement du web worker permettant d’accéder au client SQlite indispensable au data provider de react-admin étant asynchrone, il faut attendre la fin démarrage de ce web worker avant d’instancier notre application react-admin.

Ce qui donne :

// in src/App.js
import React, { useState, useEffect } from 'react'
import { Admin, Resource, Loading } from 'react-admin'
import { createDbWorker } from 'sql.js-httpvfs'

import dataProviderFactory from './dataProvider';

const workerUrl = new URL(
  'sql.js-httpvfs/dist/sqlite.worker.js',
  import.meta.url
)
const wasmUrl = new URL('sql.js-httpvfs/dist/sql-wasm.wasm', import.meta.url)
const config = {
  from: "inline",
  config: {
    serverMode: "full", // file is just a plain old full sqlite database
    requestChunkSize: 4096, // the page size of the  sqlite database (by default 4096)
    url: process.env.DB_URL // url to the database (relative or full)
  }
};

const App = () => {
  const [dataProvider, setDataProvider] = useState(null);

  useEffect(() => {
    const startDataProvider = async () => {
      const dbClient = await createDbWorker(
        [config],
        workerUrl.toString(), wasmUrl.toString()
      );
      setDataProvider(dataProviderFactory(dbClient));
    }
    if (dataProvider === null) {
      startDataProvider();
    }
  }, [dataProvider]);

  if (dataProvider === null) {
    return <Loading />
  }

  return <Admin dataProvider={dataProvider} />
}

export default App

Un data provider requêtant du sql

Classiquement, un data provider react-admin va chercher les données via un appel à une API. Ici, il va en fait directement faire des requêtes à la base de données SQlite (chargée en mémoire via le web worker).

Pour me faciliter le travail, j’ai cherché un query builder JavaScript compatible SQlite, en l’occurence SQL Bricks.js et son extention SQlite.

Voila à quoi cela ressemble :

import queryBuilder from './sqlQueryBuilder'

const formatSqlResult = ({ columns, values }) => {
  return values.map((value) => {
    return value.reduce(
      (acc, data, index) => ({ ...acc, [columns[index]]: data }),
      {}
    )
  })
}

const getPaginatedListQuery = (resource, params) => {
  return queryBuilder
    .select()
    .from(resource)
    .where(params.filter)
    .limit(params.pagination.perPage)
    .offset((params.pagination.page - 1) * params.pagination.perPage)
    .orderBy(`${params.sort.field} ${params.sort.order}`)
    .toParams({ placeholder: '?%d' })
}

const getFilteredCountListQuery = (resource, params) => {
  return queryBuilder
    .select('COUNT(*)')
    .from(resource)
    .where(params.filter)
    .toParams({ placeholder: '?%d' })
}

const getTotalFromQueryCount = (result) => result[0].values[0][0]

export default (dbClient) => ({
  getList: (resource, params) => {
    const { text: countQuery, values: countParams } = getFilteredCountListQuery(
      resource,
      params
    );
    return dbClient.db
      .exec(countQuery, countParams)
      .then((countResult) => {
        const total = getTotalFromQueryCount(countResult);
        const { text: listQuery, values: listParams } = getPaginatedListQuery(
          resource,
          params
        )
        return total ? dbClient.db
          .exec(listQuery, listParams)
          .then((result) => {
            return {
              data: formatSqlResult(result[0]),
              total,
            }
          }) : {
            data: [],
            total,
          }
      })
      .catch((error) => {
        console.log('SQL error: ', error)
        return error
      })
  },

  // ...
})

Introspection

L’avantage avec une base de données, c’est qu’il est assez facile d’obtenir des informations sur les tables et les contenus de ces tables.

Par exemple, en SQlite, on peut obtenir la liste des tables avec la requête

SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';

On peut donc imaginer une fonction capable de retourner une description complète de la base, quelque chose ressemblant à :

export const getDbDescription = async dbClient => {
    const tableNames = await dbClient.db
        .exec("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
        .then((result) => result[0].values.map(v => v[0]));
    
    const tableQueries = tableNames.map(name => {
        const { text, values } = queryBuilder
            .select('sql')
            .from('sqlite_master')
            .where({ name })
            .toParams({ placeholder: '?%d' });
        return dbClient.db
            .exec(text, values)
            .then((result) => getRessourceFromCreateQuery(result[0].values[0][0]));
    });
    
    const tablesDescriptions = await Promise.all(tableQueries);

    global.console.log('** DB description **');
    tablesDescriptions.forEach((table, index) => {
        global.console.log(`- table "${tableNames[index]}"`, table);
    });
}

Ce qui donne dans la console :

La sortie de getDbDescription dans la console du navigateur
La sortie de getDbDescription dans la console du navigateur

Rien n’empêche d’imaginer de mettre en place à partir de ces informations un mécanisme permettant de générer à la voler les ressources react-admin convenablement configurées, à l’image de ce que fait le composant <AdminGuesser /> d’API Platform.

Conclusion

Pour ma part, je suis pleinement convaincu de l’intérêt sql.js-httpvfs pour pouvoir exposer facilement et publiquement le contenu d’une base de données à des fin de consultation. react-admin est peut-être un peu sous-exploité dans l’illustration que je viens d’en donner, car toutes la partie mutation des données n’est pas utilisable. N’en reste pas moins que cela permet de générer à moindre coût des listes filtrables et paginables du contenu de la base ou de profiter des fonctionnalités d’export pour y faire des extraction assez précises.

Vous pouvez pour vous en convaincre aller voir le site illustrant cet article : https://marmelab.com/ra-sqlite-dataprovider.

Et si la solution peut vous servir, pourquoi ne pas proposer une pull request sur le dépôt poussant plus en amont les fonctionnalités d’introspection afin de mettre en place un composant <AdminSQliteGuesser dbClient={dbClient} /> capable de générer automatiquement toutes les interfaces des objets accessibles depuis la base de données …