MySQL

Finch gebruikt het mysql_client_plus-pakket voor MySQL. De verbinding wordt automatisch tot stand gebracht door FinchApp bij het opstarten, op basis van je FinchMysqlConfig-instellingen, en wordt blootgesteld als een DatabaseDriver<MySQLConnectionPool> via app.mysqlDriver.

De SQL-integratie van Finch (gedeeld tussen MySQL en SQLite) bestaat uit drie lagen die samenwerken, allemaal afkomstig uit het sqler-pakket:

  1. MTable / MField* — Dart-klassen die je databaseschema beschrijven. Ze genereren CREATE TABLE-SQL voor migraties en dienen daarnaast als formuliervalidators per veld.
  2. Sqler — Een fluent querybouwer die op een veilige manier geparametriseerde SQL samenstelt.
  3. DatabaseDriver — Eén enkele connection-wrapperklasse die een opgebouwde query uitvoert tegen MySQL of SQLite (het onderzoekt het onderliggende verbindingstype tijdens runtime) en een SqlDatabaseResult retourneert.

Configuratie

Voeg mysqlConfig toe aan FinchConfigs in je app.dart. Waarden moeten afkomstig zijn uit omgevingsvariabelen:

FinchConfigs configs = FinchConfigs(
  mysqlConfig: FinchMysqlConfig(
    enable: true,
    host: env.get('MYSQL_HOST', 'localhost'),
    port: env.getInt('MYSQL_PORT', 3306),
    user: env.get('MYSQL_USER', 'db_user'),
    pass: env.get('MYSQL_PASS', 'db_password'),
    databaseName: env.get('MYSQL_DATABASE', 'my_db'),
    maxConnections: 10,  // grootte van de MySQLConnectionPool
  ),
);

Let op: FinchMysqlConfig.port is een int (in tegenstelling tot FinchDBConfig.port voor MongoDB, dat een String is).

Toegang tot de driver

Zodra de app draait, kun je overal waar je toegang hebt tot de app-instantie bij de database driver:

var driver = app.mysqlDriver; // DatabaseDriver<MySQLConnectionPool>

// You can also check whether the connection is active
bool ok = app.mysqlDb.connected;

Geef driver door aan je datalaag-klassen in plaats van app.mysqlDriver rechtstreeks in controllers aan te roepen. Dit houdt controllers dun en je datalaag testbaar.

Een tabel definiëren (MTable)

MTable vertegenwoordigt een databasetabel in Dart. Je definieert hem één keer en hergebruikt hem voor:

  • Migraties — finch migrate leest je MTable-definities om CREATE TABLE- en ALTER TABLE-instructies te genereren.
  • Validatie — de validators op elk veld worden gedeeld met AdvancedForm.
  • Query's opbouwen — table.allSelectFields() en de hieronder beschreven hulpmethoden besparen je het steeds opnieuw opsommen van kolomlijsten.

Elke kolom wordt vertegenwoordigd door een MField*-klasse:

import 'package:finch/finch_mysql.dart';
import 'package:finch/finch_ui.dart'; // voor FieldValidator

final table = MTable(
  name: 'books',
  fields: [
    MFieldInt(
      name: 'id',
      isPrimaryKey: true,
      isAutoIncrement: true,
      isNullable: false,
    ),
    MFieldVarchar(
      name: 'title',
      length: 255,
      isNullable: false,
      comment: 'Title of the book',
      validators: [
        FieldValidator.requiredField().toSimple(),
        FieldValidator.fieldLength(min: 3, max: 255).toSimple(),
      ],
    ),
    MFieldVarchar(name: 'author', length: 255, isNullable: false),
    MFieldDate(name: 'published_date', isNullable: false),
    MFieldInt(name: 'category_id', isNullable: true),
    MFieldBoolean(name: 'is_published', defaultValue: 'FALSE'),
  ],
  foreignKeys: [
    ForeignKey(
      name: 'category_id',
      refTable: 'categories',
      refColumn: 'id',
      onDelete: 'SET NULL',
    ),
  ],
);

Available Field Types

MField* beschrijft het volledige scala aan MySQL-kolomtypen. De typen die je het vaakst zult gebruiken:

Klasse SQL-type Opmerkingen
MFieldInt INT Primaire sleutel + auto-increment ondersteund
MBigInt / MMediumInt / MSmallInt / MTinyInt BIGINT / MEDIUMINT / SMALLINT / TINYINT Smallere/bredere integer-bereiken
MFieldVarchar VARCHAR(n) Standaard length is 255
MFieldChar CHAR(n) String met vaste lengte
MFieldText / MFieldTinyText / MFieldMediumText / MFieldLongText TEXT-varianten Voor lange strings, ingedeeld op groottelimiet
MFieldDate DATE Opgeslagen als YYYY-MM-DD
MFieldDateTime / MFieldTimestamp DATETIME / TIMESTAMP Volledige timestamp; MFieldTimestamp wordt doorgaans gebruikt voor created_at/updated_at
MFieldBoolean TINYINT(1) Opgeslagen als 0/1 — niet MFieldBool
MFieldFloat (m, optioneel d) FLOAT(m,d) Benaderende drijvende-kommagetallen
MFieldDecimal (m: 10, d: 2) DECIMAL(m,d) Exact vast-kommagetal — gebruik dit voor geldbedragen in plaats van MFieldFloat
MFieldEnum (values: [...]) ENUM(...) Vaste verzameling stringwaarden
MFieldJson JSON Native JSON-kolom (MySQL 5.7+)
MFieldBlob-familie, MFieldBinary/MFieldVarBinary BLOB / BINARY Binaire gegevens
MFieldBit, MFieldTime, MFieldYear, MFieldPoint, MFieldPolygon — Minder gangbare typen, hetzelfde constructorpatroon

Er bestaat geen MFieldDouble — gebruik MFieldFloat voor benaderende getallen of MFieldDecimal voor exacte getallen.

Foreign Keys

Geef ForeignKey-instanties door aan MTable.foreignKeys. Elke instantie genereert een ALTER TABLE ... ADD CONSTRAINT-instructie wanneer de tabel wordt gemigreerd:

ForeignKey(
  name: 'category_id',       // kolom in deze tabel
  refTable: 'categories',    // tabel waarnaar wordt verwezen
  refColumn: 'id',           // kolom waarnaar wordt verwezen (standaard: 'id')
  onDelete: 'CASCADE',       // 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION'
  onUpdate: 'RESTRICT',
)

Queries met Sqler

Sqler genereert altijd geparametriseerde queries via QVar, dat waarden escapet en SQL-injectie voorkomt — je voegt nooit gebruikersinvoer rechtstreeks samen in een querystring.

Stel een query samen met de fluent API en geef deze vervolgens door aan driver.execute(query):

import 'package:finch/finch_mysql.dart';

Future<SqlDatabaseResult> getAllBooks(DatabaseDriver db) async {
  var query = Sqler()
    ..from(QField(table.name, as: 'b'))
    ..selects([
      QSelect('b.id'),
      QSelect('b.title'),
      QSelect('b.author'),
      QSelect('b.published_date'),
    ])
    ..orderBy(QOrder('b.id', desc: true))
    ..limit(20);

  return db.execute(query);
}

Invoegen

Sqler.insert() neemt de doeltabel en een lijst van rij-maps (zodat één enkele multi-row INSERT ... VALUES (...), (...) in één aanroep kan worden opgebouwd):

Future<void> insertBook(DatabaseDriver db, Map<String, QVar> data) async {
  var query = Sqler().insert(QField(table.name), [data]);
  await db.execute(query);
}

Bijwerken

Gebruik .update(table) om een tabel te targeten, roep vervolgens .updateSet(field, value) één keer per veld aan, en gebruik .where() om de rijen af te bakenen:

Future<void> updateBook(DatabaseDriver db, int id, Map<String, QVar> data) async {
  var query = Sqler()..update(QField(table.name));
  data.forEach((field, value) => query.updateSet(field, value));
  query.where(WhereOne(QField('id'), QO.EQ, QVar(id)));

  await db.execute(query);
}

Verwijderen

Combineer .delete() met .from(). Neem altijd een .where()-clausule op om te voorkomen dat alle rijen worden verwijderd:

Future<void> deleteBook(DatabaseDriver db, int id) async {
  var query = Sqler()
    ..delete()
    ..from(QField(table.name))
    ..where(WhereOne(QField('id'), QO.EQ, QVar(id)));

  await db.execute(query);
}

Table Convenience Methods

Elke MTable krijgt daarnaast een set kant-en-klare methoden (via een extension) waarmee je voor de meestvoorkomende gevallen geen handmatige Sqler-aanroepen hoeft te schrijven:

await table.existsTable(driver);        // bool — bestaat de tabel?
await table.createTable(driver);        // CREATE TABLE op basis van de MTable-definitie
await table.createForeignKeys(driver);  // ALTER TABLE ... ADD CONSTRAINT voor elke ForeignKey
await table.dropTable(driver);          // DROP TABLE IF EXISTS

await table.insert(driver, {'title': QVar('Dart in Action'), 'author': QVar('Alice')});
await table.insertMany(driver, [
  {'title': QVar('Book A'), 'author': QVar('Alice')},
  {'title': QVar('Book B'), 'author': QVar('Bob')},
]);

await table.select(driver, Sqler()..from(table.qName)..selects(table.allSelectFields()));
await table.delete(driver, Sqler()..delete()..from(table.qName)..where(WhereOne(QField('id'), QO.EQ, QVar(1))));

// Valideer een formulierinzending tegen de veldvalidators van deze tabel
var formResult = await table.formValidateUI({'title': 'x', 'author': 'Alice'});

table.qName is een snelkoppeling voor QField(table.name), en table.allSelectFields() retourneert QSelect-items voor elk gedefinieerd veld, zodat je kolommen niet handmatig hoeft op te sommen.

Repository-basisklasse (MysqlTable)

Voor een klein repositorypatroon extend je de abstracte klasse MysqlTable in plaats van in elke methode ruwe queries te schrijven. Deze implementeert al deleteBy, deleteById, findById en countBy; je hoeft alleen de abstracte findAll/updateFilters te implementeren:

class BooksRepository extends MysqlTable {
  @override
  DatabaseDriver get db => app.mysqlDriver;

  @override
  String get tableName => 'books';

  @override
  Sqler updateFilters(Sqler query, Map<String, dynamic> filter) {
    if (filter['author'] != null) {
      query.where(WhereOne(QField('author'), QO.EQ, QVar(filter['author'])));
    }
    return query;
  }

  @override
  Future<({int count, SqlDatabaseResult rows})> findAll({
    String orderBy = 'id',
    bool orderReverse = true,
    Map<String, dynamic> filters = const {},
    int? pageSize,
    int? offset,
  }) async {
    var query = Sqler()..from(qName)..selects(table.allSelectFields());
    query = updateFilters(query, filters);
    query.orderBy(QOrder(orderBy, desc: orderReverse));
    if (pageSize != null) query.limit(pageSize, offset);

    var count = await countBy(filters.isEmpty ? WhereOne(QField('id'), QO.GT, QVar(0)) : Where());
    var rows = await db.execute(query);
    return (count: count, rows: rows);
  }
}

// Gebruik:
var books = BooksRepository();
await books.deleteById(3);
var found = await books.findById(1);

Result Handling

db.execute() retourneert een SqlDatabaseResult. Specifiek voor MySQL is .rows een lijst van ResultSetRow (uit mysql_client_plus), die .colByName(name) ondersteunt — let op: dit retourneert de ruwe waarde direct, en gooit een fout als de kolomnaam niet bestaat, dus gebruik het alleen voor kolommen waarvan je weet dat ze in de query voorkomen:

var result = await getAllBooks(app.mysqlDriver);

for (var row in result.rows) {
  var title = row.colByName('title') ?? '';
  var id    = row.colByName('id') ?? 0;
}

De database-onafhankelijke oplossing — en de enige optie voor SQLite, zie SQLite — is .assoc/.assocFirst, die een gewone Map<String, dynamic> retourneren:

for (var row in result.assoc) {
  print(row['title']);
}

var firstRow = result.assocFirst;   // Map<String, dynamic>? — eerste rij of null
var numRows  = result.numRows;      // Aantal rijen in dit resultaat
var newId    = result.insertId;     // Auto-increment ID van de laatste INSERT
var affected = result.affectedRows; // Rijen geraakt door de laatste INSERT/UPDATE/DELETE

Voor een pagineringsquery in de stijl van een totaal-rijenaantal alias je je COUNT(*) als count_records en lees je deze terug met .countRecords:

var countQuery = Sqler()..from(table.qName)..addSelect(SQL.count(QField('id', as: 'count_records')));
var total = (await table.execute(driver, countQuery)).countRecords;

Migraties

Zie Database Migration voor het aanmaken en uitvoeren van migratiebestanden.