Aller au contenu

Chisel

Chisel est une couche d’accès aux données typée pour PetraDB écrite en Scala 3. Elle mappe les Values vers des types Scala, construit du SQL à l’abri des injections grâce à un interpolateur de chaînes, et génère les opérations CRUD pour une table — sans le poids d’un ORM complet (pas d’identity map, pas d’unit of work, pas de proxies paresseux). Elle fonctionne partout où PetraDB fonctionne : JVM, Node.js (Scala.js) et Native, en utilisant la dérivation à la compilation plutôt que la réflexion à l’exécution.

Chisel dialogue directement avec la Session du moteur via des paramètres liés, elle n’a donc besoin d’aucun JDBC et ne rend aucune valeur dans le texte SQL.

libraryDependencies += "io.github.edadma" %%% "petradb-chisel" % "1.5.1"

Chisel dépend du moteur pour obtenir une session :

libraryDependencies += "io.github.edadma" %%% "petradb-engine" % "1.5.5"

Tout s’exécute sur une Session et nécessite un ExecutionContext. Les opérations de Chisel retournent des Futures.

import io.github.edadma.petradb.Session
import io.github.edadma.petradb.engine.MemoryDB
import io.github.edadma.petradb.chisel.*
import scala.concurrent.ExecutionContext.Implicits.global
given Session = new MemoryDB().connect()

La session est passée implicitement à chaque opération de Chisel ; un given Session dans la portée est donc tout ce qui est requis.

Chisel fait correspondre les valeurs Scala et les Values de PetraDB à travers quatre type classes :

Type classDirectionRôle
Get[A]Value => Adécode une colonne
Put[A]A => Valueencode une valeur (lie les paramètres)
Read[A]Row => Adécode une ligne entière
Write[A]A => Seq[(String, Value)]encode une ligne entière

Des instances de Get et Put sont fournies pour les types scalaires courants :

Type ScalaType PetraDB
Int, Long, Short, Bytefamille entière
Double, Floatvirgule flottante
BigDecimalNUMERIC
StringTEXT
BooleanBOOLEAN
java.time.LocalDateDATE
java.time.LocalTimeTIME
java.time.LocalDateTimeTIMESTAMP
java.time.OffsetDateTimeTIMESTAMPTZ
java.time.OffsetTimeTIMETZ
java.time.DurationINTERVAL
Array[Byte]BYTEA
java.util.UUIDUUID
Option[A]A nullable (None ⇄ SQL NULL)

Une colonne dont le type ne correspond pas lève une DecodeException.

Dérivez Read et Write pour une case class afin de mapper des lignes entières. La dérivation se fait par nom de champ, elle est donc robuste au réordonnancement des colonnes et fonctionne directement avec SELECT * :

case class User(id: Long, name: String, age: Int) derives Read, Write

Les champs Option correspondent à des colonnes nullables :

case class Account(id: Long, nickname: Option[String]) derives Read, Write

Tout produit dérive, y compris un tuple nommé — pratique pour des projections ad hoc sans déclarer de classe :

val r = Read.derived[(id: Long, name: String)]

Get/Read possèdent .map et Put/Write possèdent .contramap pour s’adapter à des types que Chisel ne connaît pas :

enum Color:
case Red, Green, Blue
given Get[Color] = Get[String].map(Color.valueOf)
given Put[Color] = Put[String].contramap[Color](_.toString)

sql"…" construit un Fragment — du texte SQL accompagné de paramètres liés. Chaque argument interpolé est encodé via son Put et lié comme paramètre, de sorte que les valeurs ne deviennent jamais du texte SQL et que l’injection est impossible :

val minAge = 18
val frag = sql"select id, name, age from users where age >= $minAge"

Un Fragment se rend sous la forme à espaces réservés $1, $2, … du moteur :

frag.sql // "select id, name, age from users where age >= $1"
frag.params // Seq(NumberValue(18))

Les Option et les Values pré-construites peuvent aussi être interpolées — None lie un NULL SQL.

Les fragments sont stockés non rendus, ils se composent donc avec ++ et renumérotent automatiquement les espaces réservés. Fragment.const apporte du texte brut (pour des identifiants de confiance comme les noms de tables), Fragment.param une seule valeur liée :

val table = "users"
val q = sql"select * from " ++ Fragment.const(table) ++ sql" where age >= $minAge"

Attachez un décodeur de lignes avec .query[A], puis choisissez comment collecter le résultat :

val users: Future[List[User]] =
sql"select * from users order by id".query[User].toList
val one: Future[Option[User]] =
sql"select * from users where id = ${1L}".query[User].option
val exactlyOne: Future[User] =
sql"select * from users where id = ${1L}".query[User].unique
MéthodeRésultatNotes
.toListFuture[List[A]]toutes les lignes
.toVectorFuture[Vector[A]]toutes les lignes
.optionFuture[Option[A]]première ligne ou None
.uniqueFuture[A]exactement une ligne ; échoue sinon

INSERT … RETURNING se décode également via .query :

val inserted: Future[User] =
sql"insert into users (name, age) values (${"alice"}, ${30}) returning *"
.query[User].unique

Lisez directement la première colonne de la première ligne avec queryValue — pour count(*), exists, max et compagnie :

val total: Future[Long] = sql"select count(*) from users".queryValue[Long]
val maybeMax: Future[Option[Int]] = sql"select max(age) from users".queryValueOption[Int]

Pour le SQL hors requête, .update retourne le nombre de lignes affectées et .run retourne les résultats bruts du moteur :

val changed: Future[Int] =
sql"update users set age = ${31} where id = ${1L}".update
val raw: Future[Seq[Result]] =
sql"create table users (id serial primary key, name text, age integer)".run

Repo[A, Id] génère les opérations CRUD pour une seule table à partir des instances Read/Write de l’entité. Utilisez sql"…" pour tout ce qu’il ne couvre pas.

case class User(id: Long, name: String, age: Int) derives Read, Write
val users = Repo[User, Long]("users") // idColumn = "id", generatedId = true

Par défaut, Repo suppose une clé générée par la base de données (SERIAL), de sorte que insert/insertReturning omettent la colonne id et que la base l’attribue. Passez un id factice lors de la construction de l’entité — il est ignoré à l’insertion :

val saved: Future[User] = users.insertReturning(User(0, "alice", 30))
// saved.id est la clé générée
users.findById(1L) // Future[Option[User]]
users.findAll // Future[List[User]]
users.count // Future[Long]
users.existsById(1L) // Future[Boolean]
users.insert(User(0, "bob", 25)) // Future[Int] — lignes insérées
users.insertReturning(User(0, "eve", 22)) // Future[User] — récupère l'id généré
users.update(saved.copy(age = 31)) // Future[Int] — définit toutes les colonnes hors id où l'id correspond
users.deleteById(1L) // Future[Int]
users.deleteAll // Future[Int]

Pour les tables dont la clé primaire est fournie par l’application (et non générée), définissez generatedId = false et nommez la colonne id si ce n’est pas "id" :

case class Widget(sku: String, label: String) derives Read, Write
val widgets = Repo[Widget, String]("widgets", idColumn = "sku", generatedId = false)
widgets.insert(Widget("w-1", "Sprocket"))
widgets.findById("w-1")

Chisel est publié pour JVM, Scala.js et Scala Native à partir d’une source unique. Le décodage des lignes utilise la dérivation Mirror inline de Scala 3, il n’y a donc aucune réflexion à l’exécution sur aucune plateforme.

summon[Session].close()