MySQL
Finch 使用 mysql_client_plus 包来支持 MySQL。应用启动时,FinchApp 会根据你的 FinchMysqlConfig 配置自动建立连接,并通过 app.mysqlDriver 以 DatabaseDriver<MySQLConnectionPool> 的形式暴露出来。
Finch 的 SQL 集成(由 MySQL 与 SQLite 共享)分为三层,彼此协作,全部来自 sqler 包:
MTable/MField*—— 用于描述数据库模式的 Dart 类。它们会为迁移生成CREATE TABLESQL,同时也可以直接充当逐字段的表单验证器。Sqler—— 一个流式(fluent)查询构建器,用于安全地构造参数化 SQL。DatabaseDriver—— 一个连接包装类,用于针对 MySQL 或 SQLite 执行已构建的查询(它会在运行时检查底层连接的类型),并返回一个SqlDatabaseResult。
配置
在你 app.dart 的 FinchConfigs 中添加 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.port是int类型(不同于 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 TABLE和ALTER 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 |
MFieldFloat(m,可选 d) |
FLOAT(m,d) | 近似浮点数 |
MFieldDecimal(m: 10,d: 2) |
DECIMAL(m,d) | 精确定点数 —— 金额等场景应使用它而不是 MFieldFloat |
MFieldEnum(values: [...]) |
ENUM(...) | 固定的字符串取值集合 |
MFieldJson |
JSON | 原生 JSON 列(MySQL 5.7+) |
MFieldBlob 系列、MFieldBinary/MFieldVarBinary |
BLOB / BINARY | 二进制数据 |
MFieldBit、MFieldTime、MFieldYear、MFieldPoint、MFieldPolygon |
—— | 不太常用的类型,构造方式相同 |
不存在
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.qName 是 QField(table.name) 的简写,而 table.allSelectFields() 会为每个已定义字段返回对应的 QSelect 条目,因此你不必手动列出各个列名。
仓储基类(MysqlTable)
如果想使用小型的仓储(repository)模式,可以继承抽象类 MysqlTable,而不必在每个方法里都手写原始查询。它已经实现了 deleteBy、deleteById、findById 和 countBy;你只需要实现抽象方法 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。