MySQL

Finch 使用 mysql_client_plus 包来支持 MySQL。应用启动时,FinchApp 会根据你的 FinchMysqlConfig 配置自动建立连接,并通过 app.mysqlDriverDatabaseDriver<MySQLConnectionPool> 的形式暴露出来。

Finch 的 SQL 集成(由 MySQL 与 SQLite 共享)分为三层,彼此协作,全部来自 sqler 包:

  1. MTable / MField* —— 用于描述数据库模式的 Dart 类。它们会为迁移生成 CREATE TABLE SQL,同时也可以直接充当逐字段的表单验证器。
  2. Sqler —— 一个流式(fluent)查询构建器,用于安全地构造参数化 SQL。
  3. DatabaseDriver —— 一个连接包装类,用于针对 MySQL 或 SQLite 执行已构建的查询(它会在运行时检查底层连接的类型),并返回一个 SqlDatabaseResult

配置

在你 app.dartFinchConfigs 中添加 mysqlConfig。各个值应来自环境变量:

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,  // MySQLConnectionPool 的大小
  ),
);

注意: FinchMysqlConfig.portint 类型(不同于 MongoDB 的 FinchDBConfig.port,后者是 String 类型)。

访问驱动

应用运行起来之后,任何能够访问 app 实例的地方都可以获取数据库驱动:

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

// 你也可以检查连接是否处于活动状态
bool ok = app.mysqlDb.connected;

请将 driver 传入你的数据层类中,而不要在控制器里直接调用 app.mysqlDriver。这样可以让控制器保持轻量,也让你的数据层更易于测试。

定义表(MTable)

MTable 用 Dart 表示一张数据库表。你只需定义一次,即可将其复用于:

  • 迁移 —— finch migrate 会读取你的 MTable 定义,生成 CREATE TABLEALTER TABLE 语句。
  • 验证 —— 每个字段上的 validators 会与 AdvancedForm 共享。
  • 查询构建 —— table.allSelectFields() 以及下方的便捷方法,可以让你不必反复手写列名列表。

每一列都由一个 MField* 类表示:

import 'package:finch/finch_mysql.dart';
import 'package:finch/finch_ui.dart'; // 用于 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',
    ),
  ],
);

可用字段类型

MField* 覆盖了 MySQL 列类型的完整范围。以下是你最常用到的一些:

SQL 类型 说明
MFieldInt INT 支持主键 + 自增
MBigInt / MMediumInt / MSmallInt / MTinyInt BIGINT / MEDIUMINT / SMALLINT / TINYINT 更窄/更宽的整数范围
MFieldVarchar VARCHAR(n) 默认 length 为 255
MFieldChar CHAR(n) 定长字符串
MFieldText / MFieldTinyText / MFieldMediumText / MFieldLongText TEXT 系列 用于长字符串,按大小上限区分
MFieldDate DATE YYYY-MM-DD 存储
MFieldDateTime / MFieldTimestamp DATETIME / TIMESTAMP 完整时间戳;MFieldTimestamp 常用于 created_at/updated_at
MFieldBoolean TINYINT(1) 以 0/1 存储 —— 不是 MFieldBool
MFieldFloatm,可选 d FLOAT(m,d) 近似浮点数
MFieldDecimalm: 10d: 2 DECIMAL(m,d) 精确定点数 —— 金额等场景应使用它而不是 MFieldFloat
MFieldEnumvalues: [...] ENUM(...) 固定的字符串取值集合
MFieldJson JSON 原生 JSON 列(MySQL 5.7+)
MFieldBlob 系列、MFieldBinary/MFieldVarBinary BLOB / BINARY 二进制数据
MFieldBitMFieldTimeMFieldYearMFieldPointMFieldPolygon —— 不太常用的类型,构造方式相同

不存在 MFieldDouble —— 近似数值请使用 MFieldFloat,精确数值请使用 MFieldDecimal

外键

ForeignKey 实例传给 MTable.foreignKeys。在迁移该表时,每一个实例都会生成一条 ALTER TABLE ... ADD CONSTRAINT 语句:

ForeignKey(
  name: 'category_id',       // 本表中的列
  refTable: 'categories',    // 指向的表
  refColumn: 'id',           // 指向的列(默认为 'id')
  onDelete: 'CASCADE',       // 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION'
  onUpdate: 'RESTRICT',
)

使用 Sqler 查询

Sqler 始终通过 QVar 生成参数化查询,QVar 会对值进行转义并防止 SQL 注入 —— 你永远不需要把用户输入拼接进查询字符串。

使用流式 API 构造查询,然后将其传给 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);
}

插入

Sqler.insert() 接受目标表以及一个列表形式的行 map(因此一次调用即可构建出多行的 INSERT ... VALUES (...), (...)):

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

更新

使用 .update(table) 指定目标表,再对每个要设置的字段调用一次 .updateSet(field, value),最后用 .where() 限定行范围:

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);
}

删除

.delete().from() 组合使用。请始终加上 .where() 子句,以免删除全部行:

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);
}

表的便捷方法

每个 MTable 还会(通过扩展)获得一整套现成的方法,让你在常见场景下无需手写 Sqler 调用:

await table.existsTable(driver);        // bool — 该表是否存在?
await table.createTable(driver);        // 根据 MTable 定义执行 CREATE TABLE
await table.createForeignKeys(driver);  // 为每个 ForeignKey 执行 ALTER TABLE ... ADD CONSTRAINT
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))));

// 根据该表的字段验证器校验一次表单提交
var formResult = await table.formValidateUI({'title': 'x', 'author': 'Alice'});

table.qNameQField(table.name) 的简写,而 table.allSelectFields() 会为每个已定义字段返回对应的 QSelect 条目,因此你不必手动列出各个列名。

仓储基类(MysqlTable)

如果想使用小型的仓储(repository)模式,可以继承抽象类 MysqlTable,而不必在每个方法里都手写原始查询。它已经实现了 deleteBydeleteByIdfindByIdcountBy;你只需要实现抽象方法 findAll/updateFilters

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);
  }
}

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

结果处理

db.execute() 会返回一个 SqlDatabaseResult。对于 MySQL,.rows 具体是一个 ResultSetRow(来自 mysql_client_plus)的列表,它支持 .colByName(name) —— 注意该方法直接返回原始值,并且当列名不存在时会抛出异常,因此只应把它用于你确定存在于查询结果中的列:

var result = await getAllBooks(app.mysqlDriver);

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

数据库无关的替代方案 —— 也是 SQLite 唯一可用的方式,参见 SQLite —— 是 .assoc/.assocFirst,它们返回普通的 Map<String, dynamic>

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

var firstRow = result.assocFirst;   // Map<String, dynamic>? — 第一行,不存在则为 null
var numRows  = result.numRows;      // 本结果集中的行数
var newId    = result.insertId;     // 最近一次 INSERT 产生的自增 ID
var affected = result.affectedRows; // 最近一次 INSERT/UPDATE/DELETE 影响的行数

对于总行数统计这类分页查询,请把 COUNT(*) 起别名为 count_records,再通过 .countRecords 读取它:

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

迁移

有关如何创建和运行迁移文件,请参阅 Database Migration