Skip to content

SQLite 数据库

MetaTrader 5 对 SQLite 数据库提供了原生支持。它是一个轻量级但功能齐全的数据库管理系统(DBMS)。传统上,这类系统专注于处理数据表,在数据表中,相同类型的记录存储着一组共同的属性,并且不同类型(即不同表)的记录之间可以建立不同的对应关系(链接或关联),因此这类数据库也被称为关系型数据库。我们已经研究过经济日历结构之间的此类连接示例,不过日历数据库存储在终端内部,而本节的函数将使你能够从 MQL 程序创建任意数据库。

数据库管理系统对这些数据结构的专门处理,使得许多常见操作能够得到优化——加速并简化,比如对大量数据进行排序、搜索、筛选、求和,或者计算其他聚合函数。

然而,这也有另一面:对数据库管理系统进行编程需要用到其特有的 SQL(结构化查询语言),仅掌握纯 MQL5 是不够的。与 MQL5 这种命令式语言(使用指示做什么、怎么做、按什么顺序做的运算符)不同,SQL 是声明式语言,也就是说,它描述初始数据和期望的结果,而不指定执行计算的方式和顺序。SQL 中算法的含义以 SQL 查询的形式来描述。查询类似于一个独立的 MQL5 运算符,是使用特殊语法以字符串形式构成的。

我们无需编写复杂的循环和比较程序,只需通过向 SQLite 函数(例如 DatabaseExecuteDatabasePrepare)传递 SQL 查询来调用它们即可。要将查询结果获取到现成的 MQL5 结构中,可以使用 DatabaseReadBind 函数。这将使你能够在一次调用中一次性读取记录(结构)的所有字段。

借助数据库函数,很容易创建表、向表中添加记录、进行修改,以及根据复杂条件进行筛选,例如用于以下任务:

  • 获取交易历史和报价
  • 保存优化和测试结果
  • 与其他分析软件包准备和交换数据
  • 分析经济日历数据
  • 存储 MQL5 程序的设置和状态

此外,在 SQL 查询中可以使用各种常见的、统计的和数学的函数。而且,即使不创建表,也可以计算包含这些函数的表达式。

SQLite 不需要单独的应用程序、配置和管理,对资源的要求不高,并且支持流行的 SQL92 标准的大多数命令。一个额外的便利之处是,整个数据库驻留在用户计算机硬盘上的单个文件中,并且可以轻松传输或备份。不过,为了加快读取、写入和修改操作,也可以使用标志 DATABASE_OPEN_MEMORY 在内存中打开/创建数据库,但是在这种情况下,这样的数据库仅对这个特定程序可用,不能用于多个程序的联合工作。

需要重点注意的是,与功能齐全的数据库管理系统相比,SQLite 相对简单,同时也存在一些限制。特别是,SQLite 没有专门的进程(系统服务或应用程序)来提供对数据库和表管理 API 的集中访问,这就是为什么不能保证不同进程对同一个数据库(文件)的并行、共享访问。所以,如果你需要从执行同一个专家顾问实例的优化代理中同时对数据库进行读写操作,就需要在其中编写代码来同步访问(否则,正在写入和读取的数据将处于不一致的状态:毕竟,来自并发且未同步的进程的写入、修改、删除和读取顺序是随机的)。此外,同时尝试修改数据库可能会导致 MQL 程序收到 “数据库繁忙” 错误(并且请求的操作不会执行)。唯一不需要对与 SQLite 相关的并行操作进行同步的场景是只涉及读取操作的时候。

我们将仅介绍开始应用 SQL 所需的基础知识。对 SQL 语法及其工作方式的完整描述超出了本书的范围。请查看 SQLite 网站上的文档。不过,请注意,MQL5 和 MetaEditor 支持的命令和 SQL 语法结构是有限的子集。

MetaEditor 中的 MQL 向导有一个嵌入选项用于创建数据库,它会立即提供通过定义字段列表来创建第一个表的功能。此外,导航器提供了一个单独的选项卡用于处理数据库。

使用向导或导航器的上下文菜单,你可以创建一个支持格式(.db、.sql、*.sqlite 等)的空数据库(磁盘上的一个文件,默认放置在 MQL5/Files 目录中)。另外,在上下文菜单中,你可以从 sql 文件导入整个数据库,或者从 csv 文件导入单个表。

可以通过相同的菜单轻松打开现有的或已创建的数据库。之后,其表将显示在导航器中,窗口右侧的主要区域将显示一个带有调试 SQL 查询工具的面板以及一个包含结果的表。例如,双击表名会快速查询所有记录字段,这对应于出现在顶部输入字段中的 “SELECT * FROM 'table'” 语句。

在 MetaEditor 中查看 SQLite 数据库

在 MetaEditor 中查看 SQLite 数据库

你可以编辑请求并点击 “执行” 按钮来激活它。潜在的 SQL 语法错误会输出到日志中。

有关向导、数据库的导入/导出以及与之进行交互式操作的更多详细信息,请参阅 MetaEditor 文档。

MQL5 中数据库操作的原理

数据库以表格的形式存储信息。对数据库进行数据获取、修改和添加新数据的操作,是通过 SQL 语言的查询语句来完成的。我们将在后续章节详细描述其具体特性。在此之前,让我们使用与交易无关的 DatabaseRead.mq5 脚本,来了解如何创建一个简单的数据库并从中获取信息。这里提到的所有函数,后续都会详细介绍。现在,重要的是了解其一般原理。

数据库的创建与关闭

使用内置的 DatabaseOpenDatabaseClose 函数来创建和关闭数据库,这与文件操作类似,我们同样要为数据库创建一个描述符,对其进行检查,并在最后关闭它。

cpp
void OnStart()
{
   string filename = "company.sqlite";
   // 创建或打开一个数据库
   int db = DatabaseOpen(filename, DATABASE_OPEN_READWRITE | DATABASE_OPEN_CREATE);
   if(db == INVALID_HANDLE)
   {
      Print("DB: ", filename, " open failed with code ", _LastError);
      return;
   }
   ...// 后续对数据库的操作
   // 关闭数据库
   DatabaseClose(db);
}

打开数据库后,我们要确保其中不存在我们所需名称的表。如果表已经存在,当尝试向其中插入与示例中相同的数据时,就会出错。因此,我们使用 DatabaseTableExists 函数。

表的删除与创建

表的删除和创建是通过查询语句完成的,通过两次调用 DatabaseExecute 函数将查询语句发送到数据库,并进行错误检查。

cpp
...
// 如果 COMPANY 表存在,则删除它
if(DatabaseTableExists(db, "COMPANY"))
{
   if(!DatabaseExecute(db, "DROP TABLE COMPANY"))
   {
      Print("Failed to drop table COMPANY with code ", _LastError);
      DatabaseClose(db);
      return;
   }
}
// 创建 COMPANY 表
if(!DatabaseExecute(db, "CREATE TABLE COMPANY("
  "ID      INT     PRIMARY KEY NOT NULL,"
  "NAME    TEXT    NOT NULL,"
  "AGE     INT     NOT NULL,"
  "ADDRESS CHAR(50),"
  "SALARY  REAL );"))
{
   Print("DB: ", filename, " create table failed with code ", _LastError);
   DatabaseClose(db);
   return;
}
...

SQL 查询语句解释

COMPANY 表中,我们仅有 5 个字段:记录 ID、姓名、年龄、地址和薪水。这里的 ID 字段是主键,也就是唯一索引。索引能让每条记录被唯一标识,并且可以在不同表之间使用,将它们关联起来。这类似于持仓 ID 关联所有属于特定持仓的交易和订单。

向表中插入数据

现在需要向表中填充数据,这通过 INSERT 查询语句来实现:

cpp
// 向表中插入数据
if(!DatabaseExecute(db,
   "INSERT INTO COMPANY (ID,NAME,AGE,ADDRESS,SALARY) VALUES (1,'Paul',32,'California',25000.00); "
   "INSERT INTO COMPANY (ID,NAME,AGE,ADDRESS,SALARY) VALUES (2,'Allen',25,'Texas',15000.00); "
   "INSERT INTO COMPANY (ID,NAME,AGE,ADDRESS,SALARY) VALUES (3,'Teddy',23,'Norway',20000.00);"
   "INSERT INTO COMPANY (ID,NAME,AGE,ADDRESS,SALARY) VALUES (4,'Mark',25,'Rich-Mond',65000.00);"))
{
   Print("DB: ", filename, " insert failed with code ", _LastError);
   DatabaseClose(db);
   return;
}
...

这里向 COMPANY 表中添加了 4 条记录,每条记录都有一个字段列表,并指定了要写入这些字段的值。记录是通过单独的 INSERT... 查询语句插入的,这些语句通过特殊分隔符 ; 组合成一行,但我们也可以通过单独调用 DatabaseExecute 函数将每条记录插入表中。

由于脚本结束时,数据库会保存到 company.sqlite 文件中,下次运行脚本时,我们会尝试将相同 ID 的相同数据写入 COMPANY 表,这会导致错误。这就是我们之前删除表的原因,这样每次运行脚本时都能从头开始。

从表中获取数据

现在,我们要从 COMPANY 表中获取所有薪水字段大于 15000 的记录。这通过 DatabasePrepare 函数完成,该函数会 “编译” 请求文本,并返回其描述符,以便后续在 DatabaseReadDatabaseReadBind 函数中使用。

cpp
// 准备带有描述符的请求
int request = DatabasePrepare(db, "SELECT * FROM COMPANY WHERE SALARY>15000");
if(request == INVALID_HANDLE)
{
   Print("DB: ", filename, " request failed with code ", _LastError);
   DatabaseClose(db);
   return;
}
...

请求成功创建后,我们需要获取其执行结果。这可以通过 DatabaseRead 函数实现,该函数在第一次调用时会执行查询,并跳转到结果中的第一条记录。每次后续调用时,它会读取下一条记录,直到结束。此时,它将返回 false,表示 “没有更多记录”。

cpp
// 打印所有薪水超过 15000 的记录
int id, age;
string name, address;
double salary;
Print("Persons with salary > 15000:");
for(int i = 0; DatabaseRead(request); i++)
{
   // 通过记录编号从接收到的记录中读取每个字段的值
   if(DatabaseColumnInteger(request, 0, id) && DatabaseColumnText(request, 1, name) &&
      DatabaseColumnInteger(request, 2, age) && DatabaseColumnText(request, 3, address) &&
      DatabaseColumnDouble(request, 4, salary))
      Print(i, ":  ", id, " ", name, " ", age, " ", address, " ", salary);
   else
   {
      Print(i, ": DatabaseRead() failed with code ", _LastError);
      DatabaseFinalize(request);
      DatabaseClose(db);
      return;
   }
}
// 使用完后删除描述符
DatabaseFinalize(request);

执行结果如下:

Persons with salary > 15000:
0:  1 Paul 32 California 25000.0
1:  3 Teddy 23 Norway 20000.0
2:  4 Mark 25 Rich-Mond  65000.0

DatabaseRead 函数允许遍历查询结果中的所有记录,然后通过 DatabaseColumn 函数获取结果表中每列的完整信息。这些函数旨在通用地处理任何查询结果,但代价是代码冗余。

使用 DatabaseReadBind 函数

如果查询结果的结构事先已知,最好使用 DatabaseReadBind 函数,它允许将整条记录一次性读入一个结构体中。我们可以用这种方式改写前面的示例,并将其命名为 DatabaseReadBind.mq5。首先,声明 Person 结构体:

cpp
struct Person
{
   int    id;
   string name;
   int    age;
   string address;
   double salary;
};

然后,在循环中使用 DatabaseReadBind(request, person) 从查询结果中提取每条记录,只要该函数返回 true 就继续循环:

cpp
Person person;
Print("Persons with salary > 15000:");
for(int i = 0; DatabaseReadBind(request, person); i++)
   Print(i, ":  ", person.id, " ", person.name, " ", person.age,
      " ", person.address, " ", person.salary);
DatabaseFinalize(request);

这样,我们可以立即获取当前记录中所有字段的值,而无需分别读取它们。

这个入门示例取自文章《SQLite: native work with SQL databases in MQL5》,除了这个示例,文章还探讨了数据库在交易者中的几种应用方式。具体来说,你可以在文章中找到从交易记录中恢复持仓历史、从策略、交易品种或最偏好的交易时段等方面分析交易报告,以及处理优化结果的技术。

掌握这些内容可能需要一些基本的 SQL 知识,因此我们将在后续章节简要介绍。

SQL 基础

在 SQLite 中执行的所有任务都假定存在一个或多个可用的工作数据库,因此创建和打开数据库(类似于打开一个文件)是建立必要编程环境的必备基础操作。SQLite 中没有通过编程方式删除数据库的工具,因为可以直接从磁盘上删除数据库文件。

在打开的数据库上下文中,可用的操作可以有条件地分为以下主要几类:

  1. 创建和删除表,以及修改表的模式,即列的描述,包括确定数据类型、名称和约束条件。
  2. 创建(添加)、读取、编辑和删除表中的记录;这些操作通常用通用缩写 CRUD(创建、读取、更新、删除)来表示。
  3. 根据复杂条件构建查询,从一个表或多个表的组合中选择记录。
  4. 通过在选定的列上建立索引、使用视图(view)、将批量操作包装在事务中、声明事件处理触发器以及其他高级工具来优化算法。

在 SQL 数据库中,所有这些操作都是使用 SQL 保留命令(或语句)来执行的。由于与 MQL5 集成的特殊性,其中一些操作是通过 MQL5 的内置函数来执行的。例如,打开、提交或回滚事务是由 DatabaseTransaction 函数集来执行的,尽管 SQL 标准(以及 SQLite 的公开实现)有相应的 SQL 命令(BEGIN TRANSACTIONCOMMITROLLBACK)。

大多数 SQL 命令在 MQL 程序中也可用:它们作为 DatabaseExecuteDatabasePrepare 函数的字符串参数传递给 SQLite 执行引擎。这两种方式之间的区别在于一些细微之处。

DatabasePrepare 允许你准备一个查询,以便在后续的每次迭代中使用不同的参数值进行批量循环执行(参数本身,即在查询中的参数名称,是相同的)。此外,这些预准备的查询提供了一种使用 DatabaseReadDatabaseReadBind 读取结果的机制。因此,你可以使用它们对一组选定的记录进行操作。

相比之下,DatabaseExecute 函数单方面执行传入的单个查询:该命令进入 SQLite 引擎内部,对数据执行一些操作,但不返回任何内容。这通常用于创建表或批量修改数据。

在未来,我们经常需要使用几个基本概念。让我们来介绍一下它们:

  1. :一种结构化的数据集合,由行和列组成。每一行都是一个单独的数据记录,其字段(属性)使用相应列的名称和类型进行描述。所有数据库表都物理存储在数据库文件中,并且可以进行读写操作(前提是在打开数据库时权限没有受到限制)。
  2. 视图:一种由 SQLite 引擎根据给定的 SQL 查询、其他表或视图计算生成的虚拟表。视图是只读的。与任何表(包括 SQL 允许在程序会话期间在内存中创建的临时表)不同,视图在每次访问时都会动态重新计算。
  3. 索引:一种服务数据结构(平衡树,B 树),用于根据预定义字段(属性)的值或它们的组合快速搜索记录。
  4. 触发器:一个或多个 SQL 语句组成的子例程,被指定为在特定表中添加、更改或删除记录的事件(之前或之后)发生时自动运行。

以下是最常用的 SQL 语句及其执行的操作的简要列表:

  1. CREATE:创建一个数据库对象(表、视图、索引、触发器);
  2. ALTER:更改一个对象(表);
  3. DROP:删除一个对象(表、视图、索引、触发器);
  4. SELECT:选择满足给定条件的记录或计算值;
  5. INSERT:添加新数据(一条或一组记录);
  6. UPDATE:更改现有记录;
  7. DELETE:从表中删除记录;

该列表仅显示了启动相应 SQL 语言结构的关键字。下面将展示更详细的语法。它们的实际应用将在以下示例中展示。

每个语句可以跨越多行(换行符和额外的空格会被忽略)。如果需要,你可以一次向 SQLite 发送多个命令。在这种情况下,每个命令之后应该使用命令终止字符 ;(分号)。

系统分析命令中的文本时不区分大小写,但在 SQL 中习惯将关键字用大写字母书写。

创建表时,我们必须指定表的名称,以及括号内用逗号分隔的列列表。每一列都要给出一个名称、一种数据类型,并且可以选择添加一个约束条件。最简单的形式是:

sql
CREATE TABLE table_name
  ( column_name type [ constraints ] [, column_name type [ constraints ...] ...]);

我们将在下一节中了解 SQL 中的约束条件。与此同时,让我们看一个清晰的示例(包含不同的数据类型和选项):

sql
CREATE TABLE IF NOT EXISTS example_table
   (id INTEGER PRIMARY KEY,
    name TEXT,
    timestamp INTEGER DEFAULT CURRENT_STAMP,
    income REAL,
    data BLOB);

创建索引的语法是:

sql
CREATE [ UNIQUE ] INDEX index_name
  ON table_name( column_name [, column_name ...]);

在对相应列有过滤条件的查询中,现有的索引会自动被使用。如果没有索引,查询过程会更慢。

删除一个表(如果已经向其中写入了数据,数据也会一并删除)非常简单:

sql
DROP TABLE table_name;

你可以像这样向表中插入数据:

sql
INSERT INTO table_name [ ( column_name [, column_name ...] ) ]
  VALUES( value [, value ...]);

括号内的第一个列表包含列名,这是可选的(见下面的解释)。它必须与第二个包含对应列值的列表相匹配。例如:

sql
INSERT INTO example_table (name, income) VALUES ('Morning Flat Breakout', 1000);

请注意,在 SQL 中字符串字面量要用单引号括起来。

如果在 INSERT 语句中省略了列名,那么 VALUES 关键字后面的内容将被假定为表中所有列的值,并且顺序与表中描述的列顺序完全一致。

该操作符还有更复杂的形式,特别是允许从其他表或查询结果中插入记录。

根据条件选择记录,并可以选择限制返回的字段(列)列表,这是通过 SELECT 命令来执行的:

sql
SELECT column_name [, column_name ...] FROM table_name [WHERE condition ];

如果你想完整地返回每一条匹配的记录(所有列),可以使用星号表示法:

sql
SELECT *FROM table_name [WHERE condition ];

当没有条件时,系统将返回表中的所有记录。

作为条件,你可以代入一个逻辑表达式,该表达式可以包括列名、各种比较运算符,以及内置的 SQL 函数和嵌套 SELECT 查询的结果(这样的查询写在括号内)。比较运算符包括:

  1. 逻辑与(AND
  2. 逻辑或(OR
  3. IN:用于匹配列表中的一个值
  4. NOT IN:用于匹配列表之外的一个值
  5. BETWEEN:用于匹配一个范围内的值
  6. LIKE:与带有特殊通配符(%_)的模式在拼写上类似
  7. EXISTS:检查嵌套查询结果是否非空

例如,选择收入至少为 1000 且不超过一年(预先四舍五入到最近的月份)的记录名称:

sql
SELECT name FROM example_table
  WHERE income >= 1000 AND timestamp > datetime('now', 'start of month', '-1 year');

此外,选择的结果可以按升序或降序排序(ORDER BY),按特征分组(GROUP BY),并按组进行过滤(HAVING)。我们还可以限制其中记录的数量(LIMITOFFSET)。对于每个组,你可以返回任何聚合函数的值,特别是 COUNTSUMMINMAXAVG,这些函数是在所有组记录上计算得出的。

sql
SELECT [ DISTINCT ] column_name [, column_name...](i) FROM table_name
  [ WHERE condition ]
  [ORDER BY column_name [ ASC | DESC ]
     [ LIMIT quantity OFFSET start_offset ] ]
  [ GROUP BY column_name ⌠ HAVING condition ] ];

可选关键字 DISTINCT 允许你删除重复项(如果根据当前的选择标准在结果中发现了重复项)。它只有在没有分组的情况下才有意义。

只有在存在排序的情况下,LIMIT 才会给出可重复的结果。

如果需要,SELECT 选择操作可以不是从一个表中进行,而是从多个表中进行,并根据所需的字段组合将它们合并起来。为此使用关键字 JOIN

sql
SELECT [...] FROM table name_1
  [ INNER | OUTER | CROSS ] JOIN table_name_2
  ON boolean_condition

或者

sql
SELECT [...] FROM table name_1
  [ INNER | OUTER | CROSS ] JOIN table_name_2
  USING ( common_column_name [, common_column_name ...] )

SQLite 支持三种类型的 JOIN:内连接(INNER JOIN)、外连接(OUTER JOIN)和交叉连接(CROSS JOIN)。本书通过示例提供了它们的大致概念,你可以自行进一步探索细节。

例如,使用 JOIN,你可以构建一个表中的记录与另一个表中的记录的所有组合,或者根据位置标识符匹配的原则,将交易表(我们称之为 “deals”)中的交易与同一个表中的交易进行比较,但要使得交易的方向(进入市场/退出市场)相反,从而得到一个虚拟的交易表。

sql
SELECT // 列出结果表的列并带有别名(在 'as' 之后)
  d1.time as time_in, d1.position_id as position, d1.type as type, // 表 d1
   d1.volume as volume, d1.symbol as symbol, d1.price as price_in,
  d2.time as time_out, d2.price as price_out,                      // 表 d2
   d2.swap as swap, d2.profit as profit,
  d1.commission + d2.commission as commission                      // 组合
  FROM deals d1 INNER JOIN deals d2      // d1 和 d2 - 同一个表 "deals" 的别名
  ON d1.position_id = d2.position_id     // 按位置合并条件
  WHERE d1.entry = 0 AND d2.entry = 1    // 选择条件 "进入/退出"

这是一个来自 MQL5 帮助文档中的 SQL 查询,在 DatabaseExecuteDatabasePrepare 函数的描述中可以找到 JOIN 的示例。

SELECT 的基本属性是它总是将结果返回给调用程序,这与其他查询(如 CREATEINSERT 等)不同。然而,从 SQLite 3.35 版本开始,INSERTUPDATEDELETE 语句在必要时也能够使用额外的 RETURNING 关键字返回值。例如:

sql
INSERT INTO example_table (name, income) VALUES ('Morning Flat Breakout', 1000)
   RETURNING id;

无论如何,在 MQL5 中查询结果是通过 DatabaseColumn 函数、DatabaseReadDatabaseReadBind 来访问的。

此外,SELECT 允许计算表达式的结果,并按原样返回这些结果或将它们与表中的结果进行组合。表达式可以包括我们在 MQL5 表达式中熟悉的大多数运算符,以及内置的 SQL 函数。完整列表请参阅 SQLite 文档。例如,这是如何在你的终端和编辑器实例中查找 SQLite 的当前构建版本,这对于了解哪些选项可用可能很重要。

sql
SELECT sqlite_version();

这里整个表达式由对 sqlite_version 函数的单个调用组成。与从表中选择多个列类似,你可以计算用逗号分隔的多个表达式。

还有几个流行的统计和数学函数也可用。

应该使用 UPDATE 语句来编辑记录:

sql
UPDATE table_name SET column_name = value [, column_name = value ...] 
  WHERE condition;

删除命令的语法如下:

sql
DELETE FROM table_name WHERE condition;

表格结构:数据类型和约束

数据类型差异

MQL5 是强类型语言,变量或结构体字段会严格遵循声明的数据类型。而 SQL 是弱类型语言,在表格描述中指定的类型只是一种建议,程序可以向任意“单元格”(记录中的字段)写入任意类型的值,且该“单元格”类型会改变,可通过 MQL 内置函数 DatabaseColumnType 检测。

SQL 类型机制

SQL 有大量描述类型的关键字,但最终归结为五个存储类。SQLite 作为简化版 SQL,多数情况下不区分同一组的关键字,用组名描述类型更合理,特定类型仅为与其他数据库管理系统(DBMS)兼容。

MQL5 与 SQL 数据类型对应关系

MQL5 类型通用 SQL 类型
NULL(MQL5 中不是类型)NULL(无值)
bool, char, short, int, long, uchar, ushort, uint, ulong, datetime, color, enumINTEGER
float, doubleREAL
(固定精度实数,MQL5 无对应)NUMERIC
stringTEXT
(任意“原始”数据,类似 uchar[] 数组等)BLOB(二进制大对象), NONE

写入值时的类型判断规则

  • 无引号、小数点和指数,为 INTEGER。
  • 有小数点和指数,为 REAL。
  • 用单引号或双引号括起来,为 TEXT 类型。
  • 无引号的 NULL 值,对应 NULL 类。
  • 二进制数据的字面量(常量)写成以 'x' 为前缀的十六进制字符串。

类型检查与比较

可使用 SQL 特殊函数 typeof 检查值的类型,例如 SELECT typeof(100), typeof(10.0), typeof('100'), typeof(x'1000'), typeof(NULL);。检查值是否为 NULL 不能用 '=' 比较,应使用 NOT NULL 运算符。

SQLite 数据存储限制

  • 表中最大列数为 2000。
  • 一行、BLOB 以及一条记录的大小不能超过 100 万字节。
  • SQL 查询长度限制为 100 万字节。

日期和时间存储格式

  • INTEGER:自 1970 年 1 月 1 日(“Unix 纪元”)起的秒数。
  • REAL:自公元前 4714 年 11 月 24 日起的天数(含小数)。
  • TEXT:格式为 "YYYY - MM - DD HH:mm:SS.sss" 的日期和时间,精确到毫秒,可选时区,后缀 "[±]HH:mm" 表示与 UTC 的偏移。

字段约束

  • DEFAULT expression:添加新记录时,若未指定字段值,系统自动填入指定值(常量)或计算表达式(函数)。
  • CHECK ( boolean_expression ):添加新记录时,系统检查表达式(可包含字段名作为变量)是否为真,为假则不插入记录并返回错误。
  • UNIQUE:系统检查表中所有记录该字段值是否不同,尝试添加已有值的记录会出错,添加失败,系统会隐式为该字段创建索引。
  • PRIMARY KEY:标记该属性的字段用于系统识别表中的记录及与其他表的关联,形成关系型数据库的关系,包含唯一索引。若表中无 INTEGER 类型的主键字段,系统会隐式创建名为 rowid 的列;若有整数类型主键字段,也可通过 rowid 别名访问。插入记录时 rowid 省略或为 NULL,SQLite 会自动分配比表中最大 rowid 大 1 的整数(64 位,对应 MQL5 中的 long),初始值为 1。主键可以由多列组成。
  • AUTOINCREMENT:只能作为 PRIMARY KEY 的补充,确保标识符持续递增,已删除记录的 ID 不会被重新选用,但此机制在计算资源使用上不如简单的 PRIMARY KEY 高效,不建议使用。
  • NOT NULL:禁止向表中添加该字段未填充的记录,默认无约束时,非唯一字段可省略,值设为 NULL。
  • CURRENT_TIME、CURRENT_DATE、CURRENT_TIMESTAMP:若 INSERT SQL 语句未显式写入该字段(包括 NULL),可自动用插入记录时的时间(无日期)、日期(无时间)或完整 UTC 时间填充该字段。但 SQLite 实现有缺陷,字段为 NULL 时不应用时间戳,解决方法是在编译查询中用 SQL 函数 STRFTIME('%s') 替代相应列。

面向对象编程(MQL5)与 SQL 的集成:对象关系映射(ORM)概念

在 MQL 程序中使用数据库意味着算法被分为两部分:控制部分用 MQL5 编写,执行部分用 SQL 编写。结果是,源代码可能会开始看起来像拼凑起来的,并且需要特别留意以保持一致性。为了避免这种情况,面向对象语言发展出了对象关系映射(ORM)的概念,也就是将对象映射到关系表记录,反之亦然。

这种方法的本质是将所有用 SQL 语言进行的操作封装在一个特殊层的类/结构中。这样一来,程序的应用部分可以用纯面向对象编程(OOP)语言(例如 MQL5)编写,而不会被 SQL 的细微差别分散注意力。

如果存在一个成熟的 ORM 实现(以包含所有命令的 “黑匣子” 形式),应用程序开发人员通常就有机会无需学习 SQL。

此外,ORM 允许在必要时 “不知不觉地” 更改数据库管理系统(DBMS)的 “引擎”。这对于 MQL5 来说并不是特别重要,因为它只内置了 SQLite 数据库,但一些开发人员更喜欢使用功能完备的数据库管理系统,并通过导入动态链接库(DLL)将它们连接到 MetaTrader 5。

当我们需要自动获取和释放资源时,使用带有构造函数和析构函数的对象非常有用。我们在 “文件描述符管理” 部分已经介绍过这个概念(资源获取即初始化,RAII),不过,正如我们稍后将看到的,与数据库的交互也是基于不同类型描述符的分配和释放。

下图以示意图的形式描绘了以 ORM 形式集成 OOP 和 SQL 时不同软件层之间的交互。

ORM(对象关系映射)

ORM(对象关系映射)

另外,一个对象 “包装器”(不仅仅是特定于数据库的 ORM)将自动完成数据准备和转换工作,还会检查数据的正确性,以防止出现一些错误。

在接下来的部分中,当我们学习使用与数据库相关的内置函数时,我们将实现一些示例,逐步构建我们自己的简单 ORM 层。由于 MQL5 的一些特性,我们的类无法提供能涵盖 100% 任务的通用性,但对许多项目来说仍然是有用的。

数据库的创建、打开与关闭

DatabaseOpenDatabaseClose 函数用于实现数据库的创建和打开操作。

c
int DatabaseOpen(const string filename, uint flags)

这个函数用于打开或创建一个名为 filename 的文件中的数据库。该参数不仅可以包含文件名,还可以包含相对于 MQL5/Files 的路径(针对特定的终端实例,或者在共享文件夹中,具体见下面的标志说明)。文件扩展名可以省略,此时默认会在文件名后添加 .sqlite

如果在 filename 参数中指定为 NULL 或者空字符串 "",那么数据库将创建在一个临时文件中,在数据库关闭后该临时文件会自动删除。

如果在 filename 参数中指定字符串 :memory:,数据库将创建在内存中。这样的临时数据库在关闭后会自动删除。

flags 参数包含了一些标志的组合,这些标志描述了从 ENUM_DATABASE_OPEN_FLAGS 枚举中创建或打开数据库的附加条件。

标识符描述

| DATABASE_OPEN_READONLY | 以只读方式打开 | | DATABASE_OPEN_READWRITE | 以读写方式打开 | | DATABASE_OPEN_CREATE | 如果磁盘上不存在该文件,则创建一个 | | DATABASE_OPEN_MEMORY | 创建一个内存数据库 | | DATABASE_OPEN_COMMON | 文件位于所有终端的共享文件夹中 |

如果在 flags 参数中未指定 DATABASE_OPEN_READONLYDATABASE_OPEN_READWRITE 标志中的任何一个,将使用 DATABASE_OPEN_READWRITE 标志。

如果函数执行成功,将返回数据库的句柄,该句柄随后将作为其他函数访问数据库的参数。否则,返回 INVALID_HANDLE,并且错误代码可以在 _LastError 中找到。

c
void DatabaseClose(int database)

DatabaseClose 函数通过之前从 DatabaseOpen 函数获取的句柄来关闭数据库。

调用 DatabaseClose 之后,我们将在后续章节中学习为打开的数据库创建的所有查询句柄都会自动被移除并失效。

该函数不返回任何值。但是,如果向它传递了不正确的句柄,它将把 _LastError 设置为 ERR_DATABASE_INVALID_HANDLE

接下来,让我们在 DBSQLite.mqh 文件中开发一个面向对象的数据库包装器。

DBSQlite 类将确保数据库的创建、打开和关闭操作。我们稍后会对其进行扩展。

cpp
class DBSQLite
{
protected:
   const string path;
   const int handle;
   const uint flags;
   
public:
   DBSQLite(const string file, const uint opts =
      DATABASE_OPEN_CREATE | DATABASE_OPEN_READWRITE):
      path(file), flags(opts), handle(DatabaseOpen(file, opts))
   {
   }
   
   ~DBSQLite(void)
   {
      if(handle != INVALID_HANDLE)
      {
         DatabaseClose(handle);
      }
   }
   
   int getHandle() const
   {
      return handle;
   }
   
   bool isOpen() const
   {
      return handle != INVALID_HANDLE;
   }
};

请注意,数据库在对象创建时会自动创建或打开,在对象销毁时会自动关闭。

使用这个类,让我们编写一个简单的脚本 DBinit.mq5,它将创建或打开指定的数据库。

cpp
input string Database = "MQL5Book/DB/Example1";
   
void OnStart()
{
   DBSQLite db(Database);                   // 在构造函数中创建或打开数据库
   PRTF(db.getHandle());                    // 65537 / 正常
   PRTF(FileIsExist(Database + ".sqlite")); // true / 正常
} // 数据库在析构函数中关闭

在第一次运行后,使用默认设置,我们应该得到一个新文件 MQL5/Files/MQL5Book/DB/Example1.sqlite。代码中通过检查文件是否存在来确认这一点。在后续使用相同名称运行时,脚本只是简单地打开数据库并记录当前的描述符(一个整数值)。

无需绑定MQL5数据即可执行查询

有些SQL查询是那种只需原样发送给引擎的命令。它们既不需要变量输入,也不需要获取结果。例如,如果我们的MQL程序需要在数据库中创建一个具有特定结构和名称的表、索引或视图,我们可以将其编写为包含“CREATE...”语句的常量字符串。此外,使用此类查询来对记录进行批处理或对记录进行组合(合并、计算聚合指标以及进行同类型修改)也很方便。也就是说,通过一条查询语句,你可以转换整个表的数据,或者基于该表填充其他表。这些结果可以在后续查询中进行分析。

在所有这些情况下,重要的是获得操作成功的确认。这类查询使用DatabaseExecute函数来执行。

c
bool DatabaseExecute(int database, const string sql)

该函数在由database描述符指定的数据库中执行查询。查询本身作为一个现成的字符串sql发送。

该函数返回成功指示(true)或错误指示(false)。

例如,我们可以为我们的DBSQLite类添加这个方法(描述符已经在对象内部)。

c
class DBSQLite
{
   ...
   bool execute(const string sql)
   {
      return DatabaseExecute(handle, sql);
   }
};

然后,创建新表(如果有必要,还会提前创建数据库本身)的脚本可能如下所示(DBcreateTable.mq5)。

c
input string Database = "MQL5Book/DB/Example1";
input string Table = "table1";
   
void OnStart()
{
   DBSQLite db(Database);
   if(db.isOpen())
   {
      PRTF(db.execute(StringFormat("CREATE TABLE %s (msg text)", Table))); // true
   }
}

执行脚本后,尝试在MetaEditor中打开指定的数据库,并确保它包含一个只有“msg”文本字段的空表。但也可以通过编程方式来完成(请参阅下一节)。

如果我们使用相同的参数再次运行该脚本,将会得到一个错误(尽管是非关键错误,不会导致程序关闭)。

database error, table table1 already exists
db.execute(StringFormat(CREATE TABLE %s (msg text),Table))=false / DATABASE_ERROR(5601)

这是因为不能重新创建一个已存在的表。但是SQL允许抑制此错误,并且仅当表尚不存在时才创建表,否则几乎不执行任何操作并返回成功指示。为此,只需在查询中的表名前面添加“IF NOT EXISTS”即可。

c
   db.execute(StringFormat("CREATE TABLE IF NOT EXISTS %s (msg text)", Table));

在实践中,需要使用表来存储应用程序领域中有关对象的信息,例如报价、交易和交易信号。因此,希望能够根据MQL5中对象的描述自动创建表。正如我们将在下面看到的,SQLite函数提供了将查询结果绑定到MQL5结构(但不是类)的能力。在这方面,在对象关系映射(ORM)包装器的框架内,我们将开发一种机制,根据MQL5中特定类型的结构体描述生成SQL查询“CREATE TABLE”。

这需要在编译时以某种方式在通用列表中注册结构体字段的名称和类型,然后,在程序执行阶段,就可以从这个列表生成SQL查询。

在编译阶段会解析几类MQL5实体,可用于识别类型和名称:

  • 继承
  • 模板

首先应该记住,收集到的字段描述与特定结构体的上下文相关,不应混淆,因为程序中可能包含许多不同的结构体,它们可能具有潜在匹配的名称和类型。换句话说,最好为每种类型的结构体在单独的列表中累积信息。模板类型非常适合此目的,其模板参数(S)将是应用程序结构体。我们将这个模板称为DBEntity

c
template<typename S>
struct DBEntity
{
   static string prototype[][3]; // 0 - 类型, 1 - 名称, 2 - 约束
   ...
};
   
template<typename T>
static string DBEntity::prototype[][3];

在模板内部,有一个多维数组prototype,我们将在其中写入字段的描述。为了截取应用字段的类型和名称,需要在DBEntity内部声明另一个模板结构体DBField:这次它的参数T是字段本身的类型。在构造函数中,我们有关于这个类型的信息(typename(T)),并且我们还将字段的名称(以及可选的约束)作为参数获取。

c
template<typename S>
struct DBEntity
{
   ...
   template<typename T>
   struct DBField
   {
      T f;
      DBField(const string name, const string constraints = "")
      {
         const int n = EXPAND(prototype);
         prototype[n][0] = typename(T);
         prototype[n][1] = name;
         prototype[n][2] = constraints;
      }
   };

f字段未被使用,但它是必需的,因为结构体不能为空。

假设我们有一个应用结构体DataDBmetaProgramming.mq5)。

c
struct Data
{
   long id;
   string name;
   datetime timestamp;
   double income;
};

我们可以创建一个继承自DBEntity<DataDB>的类似结构体,但使用基于DBField的替换字段,与原始字段集相同。

c
struct DataDB: public DBEntity<DataDB>
{
   DB_FIELD(long, id);
   DB_FIELD(string, name);
   DB_FIELD(datetime, timestamp);
   DB_FIELD(double, income);
} proto;

通过将结构体的名称代入父模板参数,该结构体为程序提供了关于其自身属性的信息。

请注意,在声明结构体的同时一次性定义proto变量。这是必要的,因为在模板中,只有当在源代码中至少创建一个该类型的对象时,每个特定的参数化类型才会被编译。对我们来说重要的是,这个proto对象的创建发生在程序启动的最开始,即在全局变量初始化的时候。

DB_FIELD标识符下隐藏着一个宏:

c
#define DB_FIELD(T,N) struct T##_##N: DBField<T> { T##_##N() : DBField<T>(#N) { } } \
   _##T##_##N;

以下是它对单个字段的展开方式:

c
   struct Type_Name: DBField<Type>
   {
      Type_Name() : DBField<Type>(Name) { }
   } _Type_Name;

这里不仅定义了结构体,还立即创建了它:实际上,它替换了原始字段。

由于DBField结构体包含一个所需类型的单个f变量,所以DataDataDB的维度和内部二进制表示是相同的。通过运行脚本DBmetaProgramming.mq5可以轻松验证这一点。

c
void OnStart()
{
   PRTF(sizeof(Data));
   PRTF(sizeof(DataDB));
   ArrayPrint(DataDB::prototype);
}

它会在日志中输出:

DBEntity<Data>::DBField<long>::DBField<long>(const string,const string)
long id
DBEntity<Data>::DBField<string>::DBField<string>(const string,const string)
string name
DBEntity<Data>::DBField<datetime>::DBField<datetime>(const string,const string)
datetime timestamp
DBEntity<Data>::DBField<double>::DBField<double>(const string,const string)
double income
sizeof(Data)=36 / ok
sizeof(DataDB)=36 / ok
            [,0]        [,1]        [,2]
[0,] "long"      "id"        ""         
[1,] "string"    "name"      ""         
[2,] "datetime"  "timestamp" ""         
[3,] "double"    "income"    ""

然而,要访问这些字段,需要编写一些不太方便的代码:data._long_id.fdata._string_name.fdata._datetime_timestamp.fdata._double_income.f

我们不会这样做,不仅是因为不方便,还因为这种构建元结构体的方式与将数据绑定到SQL查询的原则不兼容。在接下来的部分中,我们将探索数据库函数,这些函数允许在MQL5结构体中获取表的记录和SQL查询的结果。然而,只允许使用没有继承关系的简单结构体以及对象类型的静态成员。因此,需要稍微改变揭示元信息的原则。

我们将不得不保持结构体的原始类型不变,并且实际上要为数据库重复描述,确保没有差异(拼写错误)。这不是很方便,但目前没有其他办法。

我们将把DBEntityDBField实例的声明移到应用结构体之外。在这种情况下,DB_FIELD宏将接收一个额外的参数(S),需要在其中传递应用结构体的类型(之前是通过在结构体内部声明来隐式获取的)。

c
#define DB_FIELD(S,T,N) \
   struct S##_##T##_##N: DBEntity<S>::DBField<T> \
   { \
      S##_##T##_##N() : DBEntity<S>::DBField<T>(#N) {} \
   }; \
   const S##_##T##_##N _##S##_##T##_##N;

由于表列可能有约束条件,如果有必要,也需要将它们传递给DBField构造函数。为此,让我们添加几个带有适当参数的宏(理论上,一列可以有多个约束,但通常不超过两个)。

c
#define DB_FIELD_C1(S,T,N,C1) \
   struct S##_##T##_##N: DBEntity<S>::DBField<T> \
   {
      S##_##T##_##N() : DBEntity<S>::DBField<T>(#N, C1) {} \
   }; \
   const S##_##T##_##N _##S##_##T##_##N;
   
#define DB_FIELD_C2(S,T,N,C1,C2) \
   struct S##_##T##_##N: DBEntity<S>::DBField<T> \
   { \
      S##_##T##_##N() : DBEntity<S>::DBField<T>(#N, C1 + " " + C2) {} \
   }; \
   const S##_##T##_##N _##S##_##T##_##N;

这三个宏以及后续的开发内容都添加到了头文件DBSQLite.mqh中。

需要注意的是,这种“自制”的对象与表的绑定仅在向数据库中输入数据时才需要,因为从表中读取数据到对象是在MQL5中使用DatabaseReadBind函数实现的。

让我们也改进DBField的实现。MQL5类型与SQL存储类并不完全对应,因此在填充prototype[n][0]元素时需要进行转换。这是通过静态方法affinity完成的。

c
   template<typename T>
   struct DBField
   {
      T f;
      DBField(const string name, const string constraints = "")
      {
         const int n = EXPAND(prototype);
         prototype[n][0] = affinity(typename(T));
         ...
      }
      
      static string affinity(const string type)
      {
         const static string ints[] =
         {
            "bool", "char", "short", "int", "long",
            "uchar", "ushort", "uint", "ulong", "datetime",
            "color", "enum"
         };
         for(int i = 0; i < ArraySize(ints); ++i)
         {
            if(type == ints[i]) return DB_TYPE::INTEGER;
         }
         
         if(type == "float" || type == "double") return DB_TYPE::REAL;
         if(type == "string") return DB_TYPE::TEXT;
         return DB_TYPE::BLOB;
      }
   };

这里使用的SQL通用类型的文本常量放在一个单独的命名空间中:在某些时候,MQL程序的不同地方可能会需要它们,并且必须确保不会出现名称冲突。

c
namespace DB_TYPE
{
   const string INTEGER = "INTEGER";
   const string REAL = "REAL";
   const string TEXT = "TEXT";
   const string BLOB = "BLOB";
   const string NONE = "NONE";
   const string _NULL = "NULL";
}

为了方便起见,可能的约束条件预设也在它们的组中进行了描述(作为提示)。

c
namespace DB_CONSTRAINT
{
   const string PRIMARY_KEY = "PRIMARY KEY";
   const string UNIQUE = "UNIQUE";
   const string NOT_NULL = "NOT NULL";
   const string CHECK = "CHECK (%s)"; // 需要一个表达式
   const string CURRENT_TIME = "CURRENT_TIME";
   const string CURRENT_DATE = "CURRENT_DATE";
   const string CURRENT_TIMESTAMP = "CURRENT_TIMESTAMP";
   const string AUTOINCREMENT = "AUTOINCREMENT";
   const string DEFAULT = "DEFAULT (%s)"; // 需要一个表达式(常量、函数)
}

由于某些约束条件需要参数(参数的位置用常见的%s格式修饰符标记),让我们添加对它们是否存在的检查。这是DBField构造函数的最终形式。

c
   template<typename T>
   struct DBField
   {
      T f;
      DBField(const string name, const string constraints = "")
      {
         const int n = EXPAND(prototype);
         prototype[n][0] = affinity(typename(T));
         prototype[n][1] = name;
         if(StringLen(constraints) > 0       // 避免错误STRING_SMALL_LEN(5035)
            && StringFind(constraints, "%") >= 0)
         {
            Print("Constraint requires an expression (skipped): ", constraints);
         }
         else
         {
            prototype[n][2] = constraints;
         }
      }

由于宏与辅助对象DBEntity<S>DBField<T>的组合填充了原型数组,因此在DBSQlite类内部,就可以实现自动生成用于创建结构体表的SQL查询。

createTable方法是一个模板方法,其模板参数为应用结构体类型,并且包含一个查询模板("CREATE TABLE %s %s (%s);")。它的第一个参数是可选指令IF NOT EXISTS。第二个参数是表的名称,默认情况下,它采用模板参数的类型typename(S),但如果需要,可以使用输入参数name(如果它不为NULL)将其替换为其他名称。最后,括号中的第三个参数是表列的列表:它是由辅助方法columns基于数组DBEntity <S>::prototype形成的。

c
class DBSQLite
{
   ...
   template<typename S>
   bool createTable(const string name = NULL,
      const bool not_exist = false, const string table_constraints = "") const
   {
      const static string query = "CREATE TABLE %s %s (%s);";
      const string fields = columns<S>(table_constraints);
      if(fields == NULL)
      {
         Print("Structure '", typename(S), "' with table fields is not initialized");
         SetUserError(4);
         return false;
      }
      // 如果不使用IF NOT EXISTS,尝试创建已存在的表将会产生错误
      const string sql = StringFormat(query,
         (not_exist ? "IF NOT EXISTS" : ""),
         StringLen(name) ? name : typename(S), fields);
      PRTF(sql);
      return DatabaseExecute(handle, sql);
   }
      
   template<typename S>
   string columns(const string table_constraints = "") const
   {
      static const string continuation = ",\n";
      string result = "";
      const int n = ArrayRange(DBEntity<S>::prototype, 0);
      if(!n) return NULL;
      for(int i = 0; i < n; ++i)
      {
         result += StringFormat("%s%s %s %s",
            i > 0 ? continuation : "",
            DBEntity<S>::prototype[i][1], DBEntity<S>::prototype[i][0],
            DBEntity<S>::prototype[i][2]);
      }
      if(StringLen(table_constraints))
      {
         result += continuation + table_constraints;
      }
      return result;
   }
};

对于每一列,描述由名称、类型和可选的约束组成。此外,还可以传递对表的通用约束(table_constraints)。

在将生成的SQL查询发送到DatabaseExecute函数之前,createTable方法会将查询文本的调试输出发送到日志中(ORM类中的所有此类输出都可以通过替换PRTF宏集中禁用)。

现在,一切准备就绪,可以编写一个测试脚本DBcreateTableFromStruct.mq5,该脚本通过结构体声明在SQLite中创建相应的表。在输入参数中,我们只设置数据库的名称,程序将根据结构体的类型选择表的名称。

c
#include <MQL5Book/DBSQLite.mqh>
   
input string Database = "MQL5Book/DB/Example1";
   
struct Struct
{
   long id;
   string name;
   double income;
   datetime time;
};
   
DB_FIELD_C1(Struct, long, id, DB_CONSTRAINT::PRIMARY_KEY);
DB_FIELD(Struct, string, name);
DB_FIELD(Struct, double, income);
DB_FIELD(Struct, string, time);

在主 `OnStart` 函数中,我们通过调用默认设置的 `createTable` 来创建一个表。如果我们不希望在下次尝试创建该表时收到错误提示,我们需要将 `true` 作为第一个参数传递(`db.createTable<Struct> (true)`)。

```c
void OnStart()
{
   DBSQLite db(Database);
   if(db.isOpen())
   {
      PRTF(db.createTable<Struct>());
      PRTF(db.hasTable(typename(Struct)));
   }
}

hasTable 方法通过表名检查数据库中是否存在该表。我们将在下一节中考虑此方法的实现。现在,让我们运行该脚本。在第一次运行后,表成功创建,并且你可以在日志中看到 SQL 查询(它按照我们在代码中形成的方式分行显示)。

sql=CREATE TABLE  Struct (id INTEGER PRIMARY KEY,
name TEXT ,
income REAL ,
time TEXT ); / ok
db.createTable<Struct>()=true / ok
db.hasTable(typename(Struct))=true / ok

第二次运行将从 DatabaseExecute 调用返回一个错误,因为该表已经存在,hasTable 的结果也进一步表明了这一点。

sql=CREATE TABLE  Struct (id INTEGER PRIMARY KEY,
name TEXT ,
income REAL ,
time TEXT ); / ok
database error, table Struct already exists
db.createTable<Struct>()=false / DATABASE_ERROR(5601)
db.hasTable(typename(Struct))=true / ok

检查数据库中表的存在情况

内置函数 DatabaseTableExists 可通过表名来检查表是否存在。

cpp
bool DatabaseTableExists(int database, const string table)

该函数的参数需指定数据库描述符 database 和表名 table。若表存在,函数调用结果为 true

我们通过添加 hasTable 方法来扩展 DBSQLite 类。

cpp
class DBSQLite
{
   ...
   bool hasTable(const string table) const
   {
      return DatabaseTableExists(handle, table);
   }

脚本 DBcreateTable.mq5 将检查表是否已创建。

cpp
void OnStart()
{
   DBSQLite db(Database);
   if(db.isOpen())
   {
      PRTF(db.execute(StringFormat("CREATE TABLE %s (msg text)", Table)));
      PRTF(db.hasTable(Table));
   }
}

同样,不必担心尝试重新创建表时可能出现的错误,这对表的存在状态没有任何影响。

database error, table table1 already exists
db.execute(StringFormat(CREATE TABLE %s (msg text),Table))=false / DATABASE_ERROR(5601)
db.hasTable(Table)=true / ok

由于我们正在编写通用辅助类 DBSQLite,我们将在其中提供删除表的机制。SQL 为此目的有 DROP 命令。

cpp
class DBSQLite
{
   ...
   bool deleteTable(const string name) const
   {
      const static string query = "DROP TABLE '%s';";
      if(!DatabaseTableExists(handle, name)) return true;
      if(!DatabaseExecute(handle, StringFormat(query, name))) return false;
      return !DatabaseTableExists(handle, name)
         && ResetLastErrorOnCondition(_LastError == DATABASE_NO_MORE_DATA);
   }
   
   static bool ResetLastErrorOnCondition(const bool cond)
   {
      if(cond)
      {
         ResetLastError();
         return true;
      }
      return false;
   }

在执行查询之前,我们检查表是否存在,如果不存在则立即退出。

执行查询后,我们通过再次调用 DatabaseTableExists 来额外检查表是否已被删除。由于表不存在将用 DATABASE_NO_MORE_DATA 错误代码标记,这是此方法的预期结果,我们使用 ResetLastErrorOnCondition 清除错误代码。

利用 SQL 的功能来避免尝试删除不存在的表可能更高效:只需在查询中添加短语 "IF EXISTS"。因此,deleteTable 方法的最终版本得到了简化:

cpp
   bool deleteTable(const string name) const
   {
      const static string query = "DROP TABLE IF EXISTS '%s';";
      return DatabaseExecute(handle, StringFormat(query, name));
   }

你可以尝试编写一个删除表的测试脚本,但要小心不要误删正在使用的表。表会连同所有数据立即被删除,没有确认提示且无法恢复。对于重要项目,请保留数据库备份。

准备绑定查询:DatabasePrepare 函数

在许多情况下,需要在 SQL 查询中嵌入参数。由于 SQL 查询 “本质上” 是一个符合特殊语法的字符串,它可以通过简单地调用 StringFormat 函数或字符串拼接的方式来构建,并在合适的位置添加参数值。我们在创建表的查询中(“CREATE TABLE %s '%s' (%s);”)已经使用过这种技术,但在那个例子中,只有部分参数包含数据(括号内的 %s 会被值列表替换),其余部分表示选项和表名。在本节中,我们将专门关注如何将数据替换到查询中。以原生的 SQL 方式来做这件事很重要,原因有以下几点。

首先,SQL 查询只是作为一个字符串传递给 SQLite 引擎,在引擎中它会被解析成各个组件,检查其正确性,并以某种方式 “编译”(当然,这不是 MQL5 编译器)。然后,编译后的查询由数据库执行。这就是为什么我们给 “本质上” 这个词加上引号。

当需要使用不同的参数执行同一个查询时(例如,向表中插入多条记录;我们正逐渐接近这个任务),为每条记录分别编译和检查查询的效率相当低。更合理的做法是编译一次查询,然后批量执行它,只需简单地替换不同的值即可。

这种编译操作被称为查询准备,由 DatabasePrepare 函数来执行。

准备好的查询还有另一个用途:借助它们,SQLite 引擎可以将查询执行的结果返回给 MQL5 代码(关于这一点,你可以在 “执行准备好的查询” 和 “分别读取查询结果记录字段” 部分找到更多信息)。

最后但同样重要的是,与参数化查询相关的一点是,它们可以保护你的程序免受称为 SQL 注入的潜在黑客攻击。首先,这对于公共网站的数据库至关重要,在这些网站中,用户输入的信息会通过嵌入 SQL 查询的方式记录到数据库中:如果在这种情况下使用简单的格式替换 '%s',用户就能够输入一些长字符串,其中包含额外的 SQL 命令来替代预期的数据,而这些字符串会成为原始 SQL 查询的一部分,从而扭曲查询的含义。但是,如果 SQL 查询是经过编译的,它就不会被输入数据所改变:输入数据始终会被当作数据来处理。

虽然 MQL 程序不是服务器程序,但它仍然可以将从用户那里接收到的信息存储在数据库中。

c
int DatabasePrepare(int database, const string sql, ...)

DatabasePrepare 函数会在指定的数据库中为字符串 sql 中的查询创建一个句柄。该数据库必须事先通过 DatabaseOpen 函数打开。

查询中的参数位置在 sql 字符串中使用片段 '?1'、'?2'、'?3' 等来指定。这些编号表示在未来使用 DatabaseBind 函数为其分配输入值时所使用的参数索引。sql 字符串中的编号不一定要按顺序排列,如果需要在查询的不同位置插入同一个参数,编号可以重复。

注意!在被替换的片段 '?n' 中的索引从 1 开始,而在 DatabaseBind 函数中索引从 0 开始。例如,查询主体中的 '?1' 参数在调用 DatabaseBind 时,索引为 0 的值会赋给它,'?2' 参数则对应索引为 1 的值,以此类推。即使在 '?n' 参数的编号中存在间隔(无论是意外还是有意为之),这种固定的偏移量 1 始终保持不变。

如果你计划严格按顺序绑定所有参数,可以使用一种简略表示法:在每个参数的位置,只需简单地使用符号 '?' 而不加上数字:在这种情况下,参数会自动编号。任何没有数字的参数 '?' 会被赋予比左边已读取的参数(带有显式编号或根据相同原则计算得出)的最大编号大 1 的数字,并且第一个 '?' 会被赋予数字 1,即 '?1'。

因此,查询

sql
SELECT * FROM table WHERE risk > ?1 AND signal = ?2

等同于:

sql
SELECT * FROM table WHERE risk > ? AND signal = ?

如果某些参数是常量,或者准备查询是为了一次性执行以获取结果,那么可以将参数值以逗号分隔的列表形式传递给 DatabasePrepare 函数,而不是使用省略号(与 PrintComment 函数的用法相同)。

查询参数只能用于设置表列中的值(在写入、更改或筛选条件时)。表名、列名、选项以及 SQL 关键字不能通过 '?'/'?n' 参数传递。

DatabasePrepare 函数本身并不执行查询。从该函数返回的句柄随后必须传递给 DatabaseReadDatabaseReadBind 函数调用。这些函数会执行查询,并使结果可供读取(结果可以是一条记录或多条记录)。当然,如果查询中存在参数占位符('?' 或 '?n'),并且在 DatabasePrepare 函数中没有为它们指定值,那么在执行查询之前,需要使用相应的 DatabaseBind 函数来绑定参数和数据。

如果没有为某个参数赋值,在查询执行期间会用 NULL 来替换它。

如果出现错误,DatabasePrepare 函数将返回 INVALID_HANDLE

在探讨了与准备好的查询相关的其他特性之后,我们将在接下来的部分中介绍使用 DatabasePrepare 函数的示例。

已准备查询的删除与重置

由于已准备的查询可以多次执行,例如在循环中针对不同的参数值执行,所以在每次迭代时都需要将查询重置为初始状态。这可以通过 DatabaseReset 函数来完成。但如果已准备的查询只执行一次,调用该函数就没有意义。

c
bool DatabaseReset(int request)

该函数将内部已编译的查询结构重置为初始状态,这类似于调用 DatabasePrepare 函数。然而,DatabaseReset 函数不会重新编译查询,因此速度非常快。

同样重要的是,如果已经进行了数据绑定,该函数不会重置查询中已建立的数据绑定。因此,如果有必要,你可以只更改一个或少数几个参数的值。然后,在调用 DatabaseReset 之后,只需对已更改的参数调用 DatabaseBind 函数即可。

在撰写本书时,MQL5 API 没有提供重置数据绑定的函数,类似于标准 SQLite 发行版中的 sqlite_clear_bindings 函数。

request 参数中,指定之前从 DatabasePrepare 函数获得的有效查询句柄。如果传递的是之前已通过 DatabaseFinalize 函数删除的查询句柄(见下文),将返回一个错误。

该函数返回一个表示成功(true)或错误(false)的指示符。

使用重复查询的一般原理如下列伪代码所示。DatabaseBindDatabaseRead 函数将在后续章节中进行描述,并将被 “封装” 到对象关系映射(ORM)类中。

cpp
struct Data                                       // 结构体示例
{
   long count;
   double value;
   string comment;
};
Data data[];
...                                               // 获取数据数组
int r =
     DatabasePrepare(db, "INSERT... (?, ?, ?)")); // 编译带参数的查询
for(int i = 0; i < ArraySize(data); ++i)          // 数据循环
{
   DatabaseBind(r, 0, data[i].count);             // 将数据绑定到参数
   DatabaseBind(r, 1, data[i].value);
   DatabaseBind(r, 2, data[i].comment);
   DatabaseRead(r);                               // 执行查询
   ...                                            // 分析或保存结果
   DatabaseReset(r);                              // 每次迭代时重置为初始状态
}
DatabaseFinalize(r);

当不再需要已准备的查询时,应该使用 DatabaseFinalize 函数释放它所占用的计算机资源。

c
void DatabaseFinalize(int request)

该函数删除由 DatabasePrepare 创建的具有指定句柄的查询。

如果传递了不正确的描述符,该函数会将 ERR_DATABASE_INVALID_HANDLE 记录到 _LastError 中。

当使用 DatabaseClose 关闭数据库时,为该数据库创建的所有查询句柄都会自动被移除并失效。

让我们在我们的 ORM 层(DBSQLite.mqh)中添加一个新类 DBQuery 来处理已准备的查询。目前,它只包含资源获取即初始化(RAII)概念所固有的初始化和反初始化功能,但我们很快会对其进行扩展。

cpp
class DBQuery
{
protected:
   const string sql;  // 查询语句
   const int db;      // 数据库句柄(构造函数参数)
   const int handle;  // 已准备查询句柄
   
public:
   DBQuery(const int owner, const string s): db(owner), sql(s),
      handle(PRTF(DatabasePrepare(db, sql)))
   {
   }
   
   ~DBQuery()
   {
      DatabaseFinalize(handle);
   }
   
   bool isValid() const
   {
      return handle != INVALID_HANDLE;
   }
   
   virtual bool reset()
   {
      return DatabaseReset(handle);
   }
   ...
};

DBSQLite 类中,我们通过创建 DBQuery 实例在 prepare 方法中启动查询的准备工作。所有查询对象都将以自动指针的形式存储在内部数组 queries 中,这使得调用代码无需显式删除它们。

cpp
class DBSQLite
{
   ...
protected:
   AutoPtr<DBQuery> queries[];
public:
   DBQuery *prepare(const string sql)
   {
      return PUSH(queries, new DBQuery(handle, sql));
   }
   ...
};

将数据绑定到查询参数:DatabaseBind/Array

在通过 DatabasePrepare 函数编译 SQL 查询之后,你可以使用得到的查询句柄将数据绑定到查询参数,这正是 DatabaseBindDatabaseBindArray 函数的作用。这两个函数不仅可以在 DatabasePrepare 中创建查询后立即调用,也可以在使用 DatabaseReset 将查询重置为初始状态后调用(如果该查询在循环中多次执行)。

数据绑定步骤并非总是必需的,因为预编译的查询可能没有参数。通常,这种情况会在查询将数据从 SQL 返回到 MQL5 时发生,因此需要一个查询描述符:如何通过句柄读取查询结果将在关于 DatabaseRead/DatabaseReadBindDatabaseColumn 函数的章节中进行描述。

c
bool DatabaseBind(int request, int index, T value)

DatabaseBind 函数为具有 request 句柄的查询设置 index 参数的值。默认情况下,如果查询中的参数用占位符 ?(不带数字)标记,编号从 0 开始。然而,参数也可以在查询字符串中带编号指定(?1?5?21):在这种情况下,传递给该函数的实际索引必须比字符串中相应的编号小 1。这是因为查询字符串中的编号从 1 开始。

例如,以下查询需要一个参数(索引为 0):

c
int r = DatabasePrepare(db, "SELECT * FROM table WHERE id=?");
DatabaseBind(r, 0, 1234);

如果在查询字符串中使用了 ... id=?10 这样的占位符,则必须使用索引 9 来调用 DatabaseBind 函数。

DatabaseBind 原型中的 value 可以是任何简单类型或字符串。如果一个参数需要映射复合类型数据(结构体)或可以表示为字节数组的任意二进制数据,则应使用 DatabaseBindArray 函数。

如果成功,该函数返回 true。否则,返回 false

c
bool DatabaseBindArray(int request, int index, T &array[])

DatabaseBindArray 函数为具有 request 句柄的查询将 index 参数的值设置为一个简单类型或简单结构体(包括字符串)的数组。此函数允许将 BLOBNULL(在 SQL 中被视为一种单独类型且不等于 0 的值的缺失情况)写入数据库。

现在让我们回到 DBSQLite.mqh 文件中的 DBQuery 类,并添加数据绑定支持。

c
class DBQuery
{
   ...
public:
   template<typename T>
   bool bind(const int index, const T value)
   {
      return PRTF(DatabaseBind(handle, index, value));
   }
   template<typename T>
   bool bindBlob(const int index, const T &value[])
   {
      return PRTF(DatabaseBindArray(handle, index, value));
   }
   
   bool bindNull(const int index)
   {
      static const uchar null[] = {};
      return bindBlob(index, null);
   }
   ...
};

BLOB 适合将任何文件原封不动地传输到数据库中,例如,如果你首先使用 FileLoad 函数将其读入字节数组。

显式绑定空值的必要性可能不是那么明显。当向数据库中插入新记录时,调用程序通常只传递它所知道的字段,并且所有缺失的字段(如果它们在表描述中没有用 NOT NULL 约束标记,或者没有不同的 DEFAULT 值)将由引擎自动设置为 NULL。然而,当使用对象关系映射(ORM)方法时,将整个对象写入数据库会很方便,包括具有唯一主键(PRIMARY KEY)的字段。新对象还没有这个标识符,因为数据库在首次写入对象时会自行添加它,所以将新对象中的这个字段绑定到 NULL 值是很重要的。

执行预准备查询:DatabaseRead/Bind

预准备查询是通过 DatabaseReadDatabaseReadBind 函数来执行的。第一个函数 DatabaseRead 从数据库中提取结果,使得后续能够依次从接收到的每条记录中读取各个字段;第二个函数 DatabaseReadBind 则以结构体的形式完整地提取每条匹配的记录。

  1. DatabaseRead 函数
cpp
bool DatabaseRead(int request)

在首次调用时,在 DatabasePrepareDatabaseReset 之后,DatabaseRead 函数会执行查询,并将内部查询结果指针设置到检索到的第一条记录(如果查询期望返回记录的话)。DatabaseColumn 系列函数可用于读取记录字段的值,也就是查询中指定的列。

在后续调用中,DatabaseRead 函数会跳转到查询结果中的下一条记录,直到到达结果的末尾。

函数成功完成时返回 true。返回 false 表示出现错误(例如,数据库可能被锁定或正忙),以及当正常到达结果末尾时也会返回 false,所以应该分析 _LastError 中的错误代码。特别地,值 ERR_DATABASE_NO_MORE_DATA (5126) 表示结果已结束。

注意!如果 DatabaseRead 用于执行不返回数据的查询,如 INSERTUPDATE 等,若请求成功,该函数会立即返回 false 并设置错误代码 ERR_DATABASE_NO_MORE_DATA

该函数的常见使用模式由以下伪代码说明(不同类型的 DatabaseColumn 函数将在下一节介绍):

cpp
int r = DatabasePrepare(db, "SELECT... WHERE...?",
   param));                            //编译查询(可选带参数)
while(DatabaseRead(r))                 // 查询执行(在第一次迭代时)
{                                      //    并遍历结果记录
   int count;
   DatabaseColumnInteger(r, 0, count); // 从当前记录读取一个字段
   double number;
   DatabaseColumnDouble(r, 1, number); // 从当前记录读取另一个字段
   ...                                 // 记录中的列类型和编号由程序确定
                                       // 处理接收到的 count、number 等的值
}                                      // 当到达结果末尾时循环中断
DatabaseFinalize(r);

注意,由于查询(读取条件数据)实际上只执行一次(在第一次迭代时),所以不需要像记录变化数据时那样调用 DatabaseReset。然而,如果我们想再次运行查询并遍历新的结果,调用 DatabaseReset 将是必要的。

  1. DatabaseReadBind 函数
cpp
bool DatabaseReadBind(int request, void &object)

DatabaseReadBind 函数的工作方式与 DatabaseRead 类似:第一次调用执行 SQL 查询,并且如果成功(结果中有合适的数据),会用第一条记录的字段填充通过引用传递的对象结构体;后续调用会继续移动内部指针遍历查询结果中的记录,用下一条记录的数据填充结构体。

结构体的成员必须仅为数值类型和/或字符串(不允许有数组),它不能从对象类型继承,也不能包含对象类型的静态成员。

对象结构体中的字段数量不应超过查询结果中的列数;否则,会出现错误。可以使用 DatabaseColumnsCount 函数动态获取列数,不过,调用者通常需要根据原始请求提前“知道”预期的数据配置。

如果结构体中的字段数量少于记录中的字段数量,将执行部分读取。其余数据可以使用相应的 DatabaseColumn 函数获取。

假定结构体的字段类型与结果列中的数据类型匹配。否则,会进行自动隐式转换,这可能导致意外结果(例如,将字符串读入数值字段将得到 0)。

在最简单的情况下,当我们为数据库记录计算某个总计值时,例如,通过调用聚合函数如 SUM(column)COUNT(column)AVERAGE(column),查询结果将是一条只有一个字段的单条记录。

sql
SELECT SUM(swap) FROM trades;

由于读取结果与 DatabaseColumn 函数相关,我们将把示例的编写推迟到下一节,在那里会介绍这些函数。

分别读取字段:DatabaseColumn 函数组

通过 DatabaseReadDatabaseReadBind 函数执行查询后,程序就能够遍历根据指定条件选择的记录。在每次迭代中,在 SQLite 引擎的内部结构中,会分配一条特定的记录,其字段(列)可以通过 DatabaseColumn 函数组来访问。

c
int DatabaseColumnsCount(int request)

根据查询描述符,该函数返回查询结果中的字段(列)数量。如果出现错误,它将返回 -1。

甚至在调用 DatabaseRead 函数之前,你就可以通过 DatabasePrepare 创建的查询来确定字段的数量。对于其他 DatabaseColumn 函数,你首先应该调用 DatabaseRead 函数(至少一次)。

利用查询结果中字段的原始编号,程序可以找到字段名称(DatabaseColumnName)、类型(DatabaseColumnType)、大小(DatabaseColumnSize)以及相应类型的值(每种类型都有其对应的函数)。

c
bool DatabaseColumnName(int request, int column, string &name)

该函数将查询结果(request)中由编号(column)指定的列的名称填充到通过引用传递的字符串参数(name)中。

字段编号从 0 开始,且不能超过 DatabaseColumnsCount() - 1 的值。这不仅适用于此函数,也适用于本节中的所有其他函数。

如果成功,该函数返回 true;如果出现错误,则返回 false

c
ENUM_DATABASE_FIELD_TYPE DatabaseColumnType(int request, int column)

DatabaseColumnType 函数返回查询结果当前记录中指定列的值的类型。可能的类型收集在 ENUM_DATABASE_FIELD_TYPE 枚举中。

标识符描述
DATABASE_FIELD_TYPE_INVALID获取类型时出错,错误代码在 _LastError
DATABASE_FIELD_TYPE_INTEGER整数
DATABASE_FIELD_TYPE_FLOAT实数
DATABASE_FIELD_TYPE_TEXT字符串
DATABASE_FIELD_TYPE_BLOB二进制数据
DATABASE_FIELD_TYPE_NULL空值(特殊类型 NULL

关于 SQL 类型及其与 MQL5 类型的对应关系的更多详细信息,已在 “表的结构(模式):数据类型和限制” 部分中进行了描述。

c
int DatabaseColumnSize(int request, int column)

该函数返回请求查询结果的当前记录中具有 column 索引的字段值的大小(以字节为单位)。例如,整数值可以用不同数量的字节表示(我们从 MQL5 类型中了解到这一点,特别是 short/int/long)。

下一组函数允许从记录的相应字段中获取特定类型的值。要从下一条记录中读取值,需要再次调用 DatabaseRead 函数。

c
bool DatabaseColumnText(int request, int column, string &value)
bool DatabaseColumnInteger(int request, int column, int &value)
bool DatabaseColumnLong(int request, int column, long &value)
bool DatabaseColumnDouble(int request, int column, double &value)
bool DatabaseColumnBlob(int request, int column, void &data[])

所有函数在成功时都返回 true,并将字段值放入接收变量 value 中。唯一的特殊情况是 DatabaseColumnBlob 函数,它将任意简单类型或简单结构的数组作为输出变量传递。通过将 uchar[] 数组指定为最通用的选项,你可以读取任何值的字节表示(包括标记为 DATABASE_FIELD_TYPE_BLOB 类型的二进制文件)。

SQLite 引擎不会检查针对某列调用的函数是否与其类型相对应。如果类型无意中或有意地不同,系统将自动隐式地将字段值转换为接收变量的类型。

现在,在熟悉了大多数 Database 函数之后,我们可以完成在 DBSQLite.mqh 文件中一组 SQL 类的开发,并继续进行实际示例的操作。

将数据绑定到查询参数:DatabaseBind/Array

在通过 DatabasePrepare 函数编译 SQL 查询之后,你可以使用得到的查询句柄将数据绑定到查询参数,这正是 DatabaseBindDatabaseBindArray 函数的作用。这两个函数不仅可以在 DatabasePrepare 中创建查询后立即调用,也可以在使用 DatabaseReset 将查询重置为初始状态后调用(如果该查询在循环中多次执行)。

数据绑定步骤并非总是必需的,因为预编译的查询可能没有参数。通常,这种情况会在查询将数据从 SQL 返回到 MQL5 时发生,因此需要一个查询描述符:如何通过句柄读取查询结果将在关于 DatabaseRead/DatabaseReadBindDatabaseColumn 函数的章节中进行描述。

c
bool DatabaseBind(int request, int index, T value)

DatabaseBind 函数为具有 request 句柄的查询设置 index 参数的值。默认情况下,如果查询中的参数用占位符 ?(不带数字)标记,编号从 0 开始。然而,参数也可以在查询字符串中带编号指定(?1?5?21):在这种情况下,传递给该函数的实际索引必须比字符串中相应的编号小 1。这是因为查询字符串中的编号从 1 开始。

例如,以下查询需要一个参数(索引为 0):

c
int r = DatabasePrepare(db, "SELECT * FROM table WHERE id=?");
DatabaseBind(r, 0, 1234);

如果在查询字符串中使用了 ... id=?10 这样的占位符,则必须使用索引 9 来调用 DatabaseBind 函数。

DatabaseBind 原型中的 value 可以是任何简单类型或字符串。如果一个参数需要映射复合类型数据(结构体)或可以表示为字节数组的任意二进制数据,则应使用 DatabaseBindArray 函数。

如果成功,该函数返回 true。否则,返回 false

c
bool DatabaseBindArray(int request, int index, T &array[])

DatabaseBindArray 函数为具有 request 句柄的查询将 index 参数的值设置为一个简单类型或简单结构体(包括字符串)的数组。此函数允许将 BLOBNULL(在 SQL 中被视为一种单独类型且不等于 0 的值的缺失情况)写入数据库。

现在让我们回到 DBSQLite.mqh 文件中的 DBQuery 类,并添加数据绑定支持。

c
class DBQuery
{
   ...
public:
   template<typename T>
   bool bind(const int index, const T value)
   {
      return PRTF(DatabaseBind(handle, index, value));
   }
   template<typename T>
   bool bindBlob(const int index, const T &value[])
   {
      return PRTF(DatabaseBindArray(handle, index, value));
   }
   
   bool bindNull(const int index)
   {
      static const uchar null[] = {};
      return bindBlob(index, null);
   }
   ...
};

BLOB 适合将任何文件原封不动地传输到数据库中,例如,如果你首先使用 FileLoad 函数将其读入字节数组。

显式绑定空值的必要性可能不是那么明显。当向数据库中插入新记录时,调用程序通常只传递它所知道的字段,并且所有缺失的字段(如果它们在表描述中没有用 NOT NULL 约束标记,或者没有不同的 DEFAULT 值)将由引擎自动设置为 NULL。然而,当使用对象关系映射(ORM)方法时,将整个对象写入数据库会很方便,包括具有唯一主键(PRIMARY KEY)的字段。新对象还没有这个标识符,因为数据库在首次写入对象时会自行添加它,所以将新对象中的这个字段绑定到 NULL 值是很重要的。

执行预准备查询:DatabaseRead/Bind

预准备查询是通过 DatabaseReadDatabaseReadBind 函数来执行的。第一个函数 DatabaseRead 从数据库中提取结果,使得后续能够依次从接收到的每条记录中读取各个字段;第二个函数 DatabaseReadBind 则以结构体的形式完整地提取每条匹配的记录。

  1. DatabaseRead 函数
cpp
bool DatabaseRead(int request)

在首次调用时,在 DatabasePrepareDatabaseReset 之后,DatabaseRead 函数会执行查询,并将内部查询结果指针设置到检索到的第一条记录(如果查询期望返回记录的话)。DatabaseColumn 系列函数可用于读取记录字段的值,也就是查询中指定的列。

在后续调用中,DatabaseRead 函数会跳转到查询结果中的下一条记录,直到到达结果的末尾。

函数成功完成时返回 true。返回 false 表示出现错误(例如,数据库可能被锁定或正忙),以及当正常到达结果末尾时也会返回 false,所以应该分析 _LastError 中的错误代码。特别地,值 ERR_DATABASE_NO_MORE_DATA (5126) 表示结果已结束。

注意!如果 DatabaseRead 用于执行不返回数据的查询,如 INSERTUPDATE 等,若请求成功,该函数会立即返回 false 并设置错误代码 ERR_DATABASE_NO_MORE_DATA

该函数的常见使用模式由以下伪代码说明(不同类型的 DatabaseColumn 函数将在下一节介绍):

cpp
int r = DatabasePrepare(db, "SELECT... WHERE...?",
   param));                            //编译查询(可选带参数)
while(DatabaseRead(r))                 // 查询执行(在第一次迭代时)
{                                      //    并遍历结果记录
   int count;
   DatabaseColumnInteger(r, 0, count); // 从当前记录读取一个字段
   double number;
   DatabaseColumnDouble(r, 1, number); // 从当前记录读取另一个字段
   ...                                 // 记录中的列类型和编号由程序确定
                                       // 处理接收到的 count、number 等的值
}                                      // 当到达结果末尾时循环中断
DatabaseFinalize(r);

注意,由于查询(读取条件数据)实际上只执行一次(在第一次迭代时),所以不需要像记录变化数据时那样调用 DatabaseReset。然而,如果我们想再次运行查询并遍历新的结果,调用 DatabaseReset 将是必要的。

  1. DatabaseReadBind 函数
cpp
bool DatabaseReadBind(int request, void &object)

DatabaseReadBind 函数的工作方式与 DatabaseRead 类似:第一次调用执行 SQL 查询,并且如果成功(结果中有合适的数据),会用第一条记录的字段填充通过引用传递的对象结构体;后续调用会继续移动内部指针遍历查询结果中的记录,用下一条记录的数据填充结构体。

结构体的成员必须仅为数值类型和/或字符串(不允许有数组),它不能从对象类型继承,也不能包含对象类型的静态成员。

对象结构体中的字段数量不应超过查询结果中的列数;否则,会出现错误。可以使用 DatabaseColumnsCount 函数动态获取列数,不过,调用者通常需要根据原始请求提前“知道”预期的数据配置。

如果结构体中的字段数量少于记录中的字段数量,将执行部分读取。其余数据可以使用相应的 DatabaseColumn 函数获取。

假定结构体的字段类型与结果列中的数据类型匹配。否则,会进行自动隐式转换,这可能导致意外结果(例如,将字符串读入数值字段将得到 0)。

在最简单的情况下,当我们为数据库记录计算某个总计值时,例如,通过调用聚合函数如 SUM(column)COUNT(column)AVERAGE(column),查询结果将是一条只有一个字段的单条记录。

sql
SELECT SUM(swap) FROM trades;

由于读取结果与 DatabaseColumn 函数相关,我们将把示例的编写推迟到下一节,在那里会介绍这些函数。

分别读取字段:DatabaseColumn 函数组

通过 DatabaseReadDatabaseReadBind 函数执行查询后,程序就能够遍历根据指定条件选择的记录。在每次迭代中,在 SQLite 引擎的内部结构中,会分配一条特定的记录,其字段(列)可以通过 DatabaseColumn 函数组来访问。

c
int DatabaseColumnsCount(int request)

根据查询描述符,该函数返回查询结果中的字段(列)数量。如果出现错误,它将返回 -1。

甚至在调用 DatabaseRead 函数之前,你就可以通过 DatabasePrepare 创建的查询来确定字段的数量。对于其他 DatabaseColumn 函数,你首先应该调用 DatabaseRead 函数(至少一次)。

利用查询结果中字段的原始编号,程序可以找到字段名称(DatabaseColumnName)、类型(DatabaseColumnType)、大小(DatabaseColumnSize)以及相应类型的值(每种类型都有其对应的函数)。

c
bool DatabaseColumnName(int request, int column, string &name)

该函数将查询结果(request)中由编号(column)指定的列的名称填充到通过引用传递的字符串参数(name)中。

字段编号从 0 开始,且不能超过 DatabaseColumnsCount() - 1 的值。这不仅适用于此函数,也适用于本节中的所有其他函数。

如果成功,该函数返回 true;如果出现错误,则返回 false

c
ENUM_DATABASE_FIELD_TYPE DatabaseColumnType(int request, int column)

DatabaseColumnType 函数返回查询结果当前记录中指定列的值的类型。可能的类型收集在 ENUM_DATABASE_FIELD_TYPE 枚举中。

标识符描述
DATABASE_FIELD_TYPE_INVALID获取类型时出错,错误代码在 _LastError
DATABASE_FIELD_TYPE_INTEGER整数
DATABASE_FIELD_TYPE_FLOAT实数
DATABASE_FIELD_TYPE_TEXT字符串
DATABASE_FIELD_TYPE_BLOB二进制数据
DATABASE_FIELD_TYPE_NULL空值(特殊类型 NULL

关于 SQL 类型及其与 MQL5 类型的对应关系的更多详细信息,已在 “表的结构(模式):数据类型和限制” 部分中进行了描述。

c
int DatabaseColumnSize(int request, int column)

该函数返回请求查询结果的当前记录中具有 column 索引的字段值的大小(以字节为单位)。例如,整数值可以用不同数量的字节表示(我们从 MQL5 类型中了解到这一点,特别是 short/int/long)。

下一组函数允许从记录的相应字段中获取特定类型的值。要从下一条记录中读取值,需要再次调用 DatabaseRead 函数。

c
bool DatabaseColumnText(int request, int column, string &value)
bool DatabaseColumnInteger(int request, int column, int &value)
bool DatabaseColumnLong(int request, int column, long &value)
bool DatabaseColumnDouble(int request, int column, double &value)
bool DatabaseColumnBlob(int request, int column, void &data[])

所有函数在成功时都返回 true,并将字段值放入接收变量 value 中。唯一的特殊情况是 DatabaseColumnBlob 函数,它将任意简单类型或简单结构的数组作为输出变量传递。通过将 uchar[] 数组指定为最通用的选项,你可以读取任何值的字节表示(包括标记为 DATABASE_FIELD_TYPE_BLOB 类型的二进制文件)。

SQLite 引擎不会检查针对某列调用的函数是否与其类型相对应。如果类型无意中或有意地不同,系统将自动隐式地将字段值转换为接收变量的类型。

现在,在熟悉了大多数 Database 函数之后,我们可以完成在 DBSQLite.mqh 文件中一组 SQL 类的开发,并继续进行实际示例的操作。

SQLite中通过ORM对象进行CRUD操作的示例

我们已经学习了在数据库中实现信息完整生命周期所需的所有功能,即CRUD(创建、读取、更新、删除)。但在进行实践之前,我们需要完成ORM(对象关系映射)层的工作。

从前面的几个部分已经清楚,与数据库交互的工作单元是一条记录:它可以是数据库表中的一条记录,也可以是查询结果中的一个元素。为了在ORM级别读取单个记录,我们引入DBRow类。每条记录都是由一个SQL查询生成的,因此将查询的句柄传递给构造函数。

我们知道,一条记录可以由多个列组成,通过这些列的数量和类型,我们可以找到DatabaseColumn函数。为了使用DBRow将这些信息暴露给MQL(MetaQuotes Language)程序,我们预留了相关变量:columns(列数)和一个DBRowColumn结构数组(最后一个包含三个字段,用于存储列的名称、类型和大小)。

此外,如果有必要,DBRow对象可以在自身中缓存从数据库中获取的值。为此,使用了MqlParam类型的data数组。由于我们事先不知道特定列中的值是什么类型,所以我们将MqlParam用作一种通用类型,类似于其他编程环境中的Variant类型。

cpp
class DBRow
{
protected:
   const int query; 
   int columns;
   DBRowColumn info[];
   MqlParam data[];
   const bool cache;
   int cursor;
   ...
public:
   DBRow(const int q, const bool c = false):
      query(q), cache(c), columns(0), cursor(-1)
   {
   }
   
   int length() const
   {
      return columns;
   }
   ...
};

cursor变量跟踪查询结果中当前记录的编号。在查询完成之前,cursor等于-1。

虚方法DBread负责执行查询,它调用DatabaseRead

cpp
protected:
   virtual bool DBread()
   {
      return PRTF(DatabaseRead(query));
   }

我们稍后会明白为什么需要一个虚方法。公共方法next使用了DBread,它提供了对结果记录的“滚动”功能,其实现如下:

cpp
public:
   virtual bool next()
   {
      ...
      const bool success = DBread();
      if(success)
      {
         if(cursor == -1)
         {
            columns = DatabaseColumnsCount(query);
            ArrayResize(info, columns);
            if(cache) ArrayResize(data, columns);
            for(int i = 0; i < columns; ++i)
            {
               DatabaseColumnName(query, i, info[i].name);
               info[i].type = DatabaseColumnType(query, i);
               info[i].size = DatabaseColumnSize(query, i);
               if(cache) data[i] = this[i]; // 重载运算符[](int)
            }
         }
         ++cursor;
      }
      return success;
   }

如果是第一次访问查询,我们会分配内存并读取列信息。如果请求了缓存,我们会额外填充data数组。为此,会为每一列调用重载的[]运算符。在该运算符中,根据值的类型,我们调用相应的DatabaseColumn函数,并将得到的值放入MqlParam结构的一个或另一个字段中。

cpp
   virtual MqlParam operator[](const int i = 0) const
   {
      MqlParam param = {};
      if(i < 0 || i >= columns) return param;
      if(ArraySize(data) > 0 && cursor != -1) // 如果有缓存,则从缓存中返回
      {
         return data[i];
      }
      switch(info[i].type)
      {
      case DATABASE_FIELD_TYPE_INTEGER:
         switch(info[i].size)
         {
         case 1:
            param.type = TYPE_CHAR;
            break;
         case 2:
            param.type = TYPE_SHORT;
            break;
         case 4:
            param.type = TYPE_INT;
            break;
         case 8:
         default:
            param.type = TYPE_LONG;
            break;
         }
         DatabaseColumnLong(query, i, param.integer_value);
         break;
      case DATABASE_FIELD_TYPE_FLOAT:
         param.type = info[i].size == 4 ? TYPE_FLOAT : TYPE_DOUBLE;
         DatabaseColumnDouble(query, i, param.double_value);
         break;
      case DATABASE_FIELD_TYPE_TEXT:
         param.type = TYPE_STRING;
         DatabaseColumnText(query, i, param.string_value);
         break;
      case DATABASE_FIELD_TYPE_BLOB: // 仅当我们无法在MqlParam中返回二进制数据时,返回base64编码信息
         {                           // getBlob函数给出二进制字段的确切表示
            uchar blob[];            // 
            DatabaseColumnBlob(query, i, blob);
            uchar key[], text[];
            if(CryptEncode(CRYPT_BASE64, blob, key, text))
            {
               param.string_value = CharArrayToString(text);
            }
         }
         param.type = TYPE_BLOB;
         break;
      case DATABASE_FIELD_TYPE_NULL:
         param.type = TYPE_NULL;
         break;
      }
      return param;
   }

提供getBlob方法是为了从BLOB字段中完整读取二进制数据(如果没有关于内容格式的更具体信息,使用uchar类型S来获取字节数组)。

cpp
   template<typename S>
   int getBlob(const int i, S &object[])
   {
      ...
      return DatabaseColumnBlob(query, i, object);
   }

对于上述方法,执行查询和读取结果的过程可以用以下伪代码表示(它省略了现有的DBSQLiteDBQuery类,但我们很快会将它们整合在一起):

cpp
int query = ...
DBRow *row = new DBRow(query);
while(row.next())
{
   for(int i = 0; i < row.length(); ++i)
   {
      StructPrint(row[i]); // 将第i列作为MqlParam结构打印
   }
}

每次都显式地编写遍历列的循环并不优雅,因此该类提供了一个方法来获取记录的所有字段的值。

cpp
   void readAll(MqlParam &params[]) const
   {
      ArrayResize(params, columns);
      for(int i = 0; i < columns; ++i)
      {
         params[i] = this[i];
      }
   }

此外,为了方便起见,该类还重载了[]运算符和getBlob方法,以便通过字段名称而不是索引来读取字段。例如:

cpp
class DBRow
{
   ...
public:
   int name2index(const string name) const
   {
      for(int i = 0; i < columns; ++i)
      {
         if(name == info[i].name) return i;
      }
      Print("Wrong column name: ", name);
      SetUserError(3);
      return -1;
   }
   
   MqlParam operator[](const string name) const
   {
      const int i = name2index(name);
      if(i != -1) return this[i]; // 重载operator()[int]
      static MqlParam param = {};
      return param;
   }
   ...
};

这样就可以访问选定的列。

cpp
int query = ...
DBRow *row = new DBRow(query);
for(int i = 1; row.next(); )
{
   Print(i++, " ", row["trades"], " ", row["profit"], " ", row["drawdown"]);
}

但是,仍然将记录的元素作为MqlParam数组单独获取,不能称之为真正的面向对象方法。更理想的做法是将整个数据库表记录读取到一个对象(应用程序结构)中。回想一下,MQL5 API提供了一个合适的函数:DatabaseReadBind。这就是我们描述派生类DBRow并覆盖其虚方法DBRead的优势所在。

DBRowStruct类是一个模板类,它期望S作为参数,SDatabaseReadBind中允许绑定的简单结构之一。

cpp
template<typename S>
class DBRowStruct: public DBRow
{
protected:
   S object;
   
   virtual bool DBread() override
   {
      // 注意:不允许使用继承的结构和嵌套结构;
      // 结构字段的数量不应超过表/查询中的列数
      return PRTF(DatabaseReadBind(query, object));
   }
   
public:
   DBRowStruct(const int q, const bool c = false): DBRow(q, c)
   {
   }
   
   S get() const
   {
      return object;
   }
};

有了派生类,我们几乎可以无缝地从基类中获取对象。

cpp
int query = ...
DBRowStruct<MyStruct> *row = new DBRowStruct<MyStruct>(query);
MyStruct structs[];
while(row.next())
{
   PUSH(structs, row.get());
}

现在是时候将伪代码转换为可工作的代码了,方法是将DBRow/DBRowStructDBQuery链接起来。在DBQuery中,我们添加一个指向DBRow对象的自动指针,该对象将包含查询结果中当前记录的数据(如果执行了查询)。使用自动指针使调用代码无需担心释放DBRow对象:它们要么在DBQuery被删除时删除,要么在需要时由于查询重启而重新创建时被删除。DBRowDBRowStruct对象的初始化由模板方法start完成。

cpp
class DBQuery
{
protected:
   ...
   AutoPtr<DBRow> row;    // 当前记录
public:
   DBQuery(const int owner, const string s): db(owner), sql(s),
      handle(PRTF(DatabasePrepare(db, sql)))
   {
      row = NULL;
   }
   
   template<typename S>
   DBRow *start()
   {
      DatabaseReset(handle);
      row = typename(S) == "DBValue" ? new DBRow(handle) : new DBRowStruct<S>(handle);
      return row[];
   }

DBValue类型是一个虚拟结构,仅用于指示程序创建基础的DBRow对象,同时不违反包含DatabaseReadBind调用的代码行的可编译性。

有了start方法,上述所有伪代码片段都可以工作了,这要归功于以下对请求的准备:

cpp
DBSQLite db("MQL5Book/DB/Example1");                            // 打开数据库
DBQuery *query = db.prepare("PRAGMA table_xinfo('Struct')");    // 准备请求
DBRowStruct<DBTableColumn> *row = query.start<DBTableColumn>(); // 获取对象游标 
DBTableColumn columns[];                                        // 接收对象数组
while(row.next())             // 只要查询结果中有记录就循环
{
   PUSH(columns, row.get());  // 从当前记录中获取一个对象
}
ArrayPrint(columns);

这个示例从数据库中读取关于特定表配置的元信息(我们在“执行不绑定MQL5数据的查询”部分的示例DBcreateTableFromStruct.mq5中创建了这个表):每一列都由一个包含几个字段(SQLite标准)的单独记录描述,这些字段在DBTableColumn结构中进行了形式化。

cpp
struct DBTableColumn
{
   int cid;              // 标识符(序号)
   string name;          // 名称
   string type;          // 类型
   bool not_null;        // 属性NOT NULL(是/否)
   string default_value; // 默认值
   bool primary_key;     // PRIMARY KEY标志(是/否)
};

为了避免用户每次都编写将结果记录转换为结构对象的循环,DBQuery类提供了一个模板方法readAll,它用查询结果中的信息填充一个引用的结构数组。类似的readAll方法会填充一个指向DBRow对象的指针数组(这更适合接收来自不同表的列的合成查询的结果)。

在CRUD操作的四重奏中,DBRowStruct::get方法负责“R”(读取)操作。为了使对象的读取功能更加完整,我们将支持通过对象的标识符从数据库中恢复单个对象。

SQLite数据库中的绝大多数表都有一个主键rowid(除非开发人员出于某种原因在表的描述中使用了“WITHOUT ROWID”选项),因此新的read方法将把键值作为参数。默认情况下,表的名称被假定为与接收结构的类型相同,但可以通过table参数更改为其他名称。考虑到这样的请求是一次性的,并且应该返回一条记录,将read方法直接放在DBSQLite类中并在内部管理短生命周期的DBQueryDBRowStruct<S>对象是有意义的。

cpp
class DBSQLite
{
   ...
public:
   template<typename S>
   bool read(const long rowid, S &s, const string table = NULL,
      const string column = "rowid")
   {
      const static string query = "SELECT * FROM '%s' WHERE %s=%ld;";
      const string sql = StringFormat(query,
         StringLen(table) ? table : typename(S), column, rowid);
      PRTF(sql);
      DBQuery q(handle, sql);
      if(!q.isValid()) return false;
      DBRowStruct<S> *r = q.start<S>();
      if(r.next())
      {
         s = r.get();
         return true;
      }
      return false;
   }
};

主要工作由SQL查询"SELECT * FROM '%s' WHERE %s=%ld;"完成,该查询通过匹配rowid键从指定表中返回包含所有字段的一条记录。

现在可以像这样从数据库中创建一个特定的对象(假定我们感兴趣的标识符必须存储在某个地方):

cpp
   DBSQLite db("MQL5Book/DB/Example1");
   long rowid = ... // 填入标识符
   Struct s; 
   if(db.read(rowid, s))
      StructPrint(s);

最后,在一些需要最大查询灵活性的复杂情况下(例如,几个表的组合,通常是带有JOINSELECT语句,或者嵌套查询),尽管这违反了ORM原则,我们仍然不得不允许使用显式的SQL命令来获取选择结果。DBSQLite::prepare方法提供了这种可能性,我们已经在准备查询的管理上下文中介绍过该方法。

我们已经考虑了所有主要的读取方式。

然而,我们还没有任何可以从数据库中读取的内容,因为我们跳过了添加记录的步骤。

让我们尝试实现对象创建(C)操作。回想一下,在我们的对象概念中,结构类型半自动地定义数据库表(使用DB_FIELD宏)。例如,Struct结构允许在数据库中创建一个名为“Struct”的表,该表具有一组与结构字段相对应的列。我们在DBSQLite类中提供了一个模板方法createTable来实现这一点。现在,类似地,需要编写一个模板方法insert,它将向该表中添加一条记录。

一个结构对象被传递给该方法,对于该结构类型,必须存在已填充的DBEntity<S>::prototype <S>数组(它由宏填充)。多亏了这个数组,我们可以形成一个参数列表(更准确地说,是它们的替代项'?n'):这是由静态方法qlist完成的。然而,查询的准备工作仍然只是完成了一半。在下面的代码中,我们需要根据对象的属性绑定输入数据。

INSERT命令中添加了"RETURNING rowid"语句,因此当查询成功时,我们期望得到一个只有一个值的单个结果行:新的rowid

cpp
class DBSQLite
{
   ...
public:
   template<typename S>
   long insert(S &object, const string table = NULL)
   {
      const static string query = "INSERT INTO '%s' VALUES(%s) RETURNING rowid;";
      const int n = ArrayRange(DBEntity<S>::prototype, 0);
      const string sql = StringFormat(query,
         StringLen(table) ? table : typename(S), qlist(n));
      PRTF(sql);
      DBQuery q(handle, sql);
      if(!q.isValid()) return 0;
      DBRow *r = q.start<DBValue>();
      if(object.bindAll(q))
      {
         if(r.next()) // 结果应该是一个包含一个新rowid值的单个记录
         {
            return object.rowid(r[0].integer_value);
         }
      }
      return 0;
   }
   
   static string qlist(const int n)
   {
      string result = "?1";
      for(int i = 1; i < n; ++i)
      {
         result += StringFormat(",?%d", (i + 1));
      }
      return result;
   }
};

insert方法的源代码中有一点需要特别注意。为了将值绑定到查询参数,我们调用object.bindAll(q)方法。这意味着在要与数据库集成的应用结构中,你需要实现这样一个方法,为引擎提供所有成员变量。

此外,为了识别对象,假定存在一个带有主键的字段,并且只有该对象“知道”这个字段是什么。因此,该结构有rowid方法,它有双重作用:首先,它将数据库中分配的记录标识符传输给对象;其次,如果标识符之前已经分配过,它允许从对象中获取该标识符。

DBSQLite::update(U - 更新)方法在很多方面与insert类似,因此建议你了解一下它。它的基础是SQL查询"UPDATE '%s' SET (%s)=(%s) WHERE rowid=%ld;",该查询应该传递结构的所有字段(bindAll()对象)和键(rowid()对象)。

最后,我们提到通过对象对记录进行定点删除(D - 删除)是在DBSQLite::remove方法中实现的(delete是MQL5的一个运算符)。

让我们在示例脚本DBfillTableFromStructArray.mq5中展示所有方法,在这个脚本中定义了新的Struct结构。

我们将把几种常用类型的值作为结构的字段。

cpp
struct Struct
{
   long id;
   string name;
   double number;
   datetime timestamp;
   string image;
   ...
};

在字符串字段image中,调用代码将指定图形资源的名称或文件的名称,在绑定到数据库时,相应的二进制数据将作为BLOB(二进制大对象)被复制。随后,当我们从数据库读取数据到Struct对象中时,二进制数据将存储在image字符串中,但当然会有失真(因为字符串会在第一个空字节处截断)。为了准确地从数据库中提取BLOB,你需要调用DBRow::getBlob方法(基于DatabaseColumnBlob)。

创建关于Struct结构字段的元信息由以下宏提供。基于这些宏,一个MQL程序可以自动为Struct对象在数据库中创建一个表,并且可以根据对象的属性启动传递给查询的数据的绑定(这种绑定不应与用于获取查询结果的反向绑定,即DatabaseReadBind混淆)。

cpp
DB_FIELD_C1(Struct, long, id, DB_CONSTRAINT::PRIMARY_KEY);
DB_FIELD(Struct, string, name);
DB_FIELD(Struct, double, number);
DB_FIELD_C1(Struct, datetime, timestamp, DB_CONSTRAINT::CURRENT_TIMESTAMP);
DB_FIELD(Struct, blob, image);

为了填充一个小的结构测试数组,脚本有输入变量:它们指定了三种货币,这些货币的报价将放入number字段中。我们还在脚本中嵌入了两个标准图像,以便测试对BLOB的操作:它们将放入image字段中。timestamp字段将由我们的ORM类自动填充记录的当前插入或修改时间戳。id字段中的主键将由SQLite本身填充。

cpp
#resource "\\Images\\euro.bmp"
#resource "\\Images\\dollar.bmp"
   
input string Database = "MQL5Book/DB/Example2";
input string EURUSD = "EURUSD";
input string USDCNH = "USDCNH";
input string USDJPY = "USDJPY";

由于输入查询变量(即那些'?n')的值最终是使用DatabaseBindDatabaseBindArray函数根据编号进行绑定的,我们的bindAll结构方法应该在编号和它们的字段之间建立对应关系:假定按照声明的顺序进行简单编号。

cpp
struct Struct
{
   ...
   bool bindAll(DBQuery &q) const
   {
      uint pixels[] = {};
      uint w, h;
      if(StringLen(image))                // 加载二进制数据
      {
         if(StringFind(image, "::") == 0) // 这是一个资源
         {
            ResourceReadImage(image, pixels, w, h);
            // 调试/测试示例(不是BMP,没有头部)
            FileSave(StringSubstr(image, 2) + ".raw", pixels);
         }
         else                             // 这是一个文件
         {
            const string res = "::" + image;
            ResourceCreate(res, image);
            ResourceReadImage(res, pixels, w, h);
            ResourceFree(res);
         }
      }
      // 当id = NULL时,数据库将分配一个新的rowid
      return (id == 0 ? q.bindNull(0) : q.bind(0, id))
         && q.bind(1, name)
         && q.bind(2, number)
         // && q.bind(3, timestamp) // 这个字段将自动填充当前时间戳
         && q.bindBlob(4, pixels);
   }
   ...
};

rowid方法非常简单。

cpp
struct Struct
{
   ...
   long rowid(const long setter = 0)
   {
      if(setter) id = setter;
      return id;
   }
};

定义了结构之后,我们描述一个包含4个元素的测试数组。其中只有2个元素附加了图像。所有对象的标识符都为零,因为它们还不在数据库中。

cpp
Struct demo[] =
{
   {0, "dollar", 1.0, 0, "::Images\\dollar.bmp"},
   {0, "euro", SymbolInfoDouble(EURUSD, SYMBOL_ASK), 0, "::Images\\euro.bmp"},
   {0, "yuan", 1.0 / SymbolInfoDouble(USDCNH, SYMBOL_BID), 0, NULL},
   {0, "yen", 1.0 / SymbolInfoDouble(USDJPY, SYMBOL_BID), 0, NULL},
};

在主OnStart函数中,我们创建或打开一个数据库(默认是MQL5Book/DB/Example2.sqlite)。为了以防万一,我们尝试删除“Struct”表,以确保结果的可重复性以及在脚本重复时便于调试,然后我们为Struct结构创建一个表。

cpp
void OnStart()
{
   DBSQLite db(Database);
   if(!PRTF(db.isOpen())) return;
   PRTF(db.deleteTable(typename(Struct)));
   if(!PRTF(db.createTable<Struct>(true))) return;
   ...

我们没有一次添加一个对象,而是使用了一个循环:

cpp
 // -> 这个选项(先搁置)
   for(int i = 0; i < ArraySize(demo); ++i)
   {
      PRTF(db.insert(demo[i])); // 每次调用时获取一个新的rowid
   }

在这个循环中,我们将使用insert方法的另一种实现,它一次接受一个对象数组作为输入,并在单个请求中处理它们,这样更高效(但该方法的总体逻辑与之前考虑的单个对象的insert方法相同)。

cpp
   db.insert(demo);  // 新的rowid将被放置在对象中
   ArrayPrint(demo);
   ...

现在让我们尝试根据一些条件从数据库中选择记录,例如,那些没有分配图像的记录。为此,我们准备一个包装在DBQuery对象中的SQL查询,然后通过两种方式获取其结果:通过绑定到Struct结构或通过通用类DBRow的实例。

cpp
   DBQuery *query = db.prepare(StringFormat("SELECT * FROM %s WHERE image IS NULL",
      typename(Struct)));
   
   // 方法1:Struct结构的应用类型
   Struct result[];
   PRTF(query.readAll(result));
   ArrayPrint(result);
   
   query.reset(); // 重置查询以便再次尝试
   
   // 方法2:带有MqlParam值的通用DBRow记录容器
   DBRow *rows[];
   query.readAll(rows); // 获取带有缓存值的DBRow对象
   for(int i = 0; i < ArraySize(rows); ++i)
   {
      Print(i);
      MqlParam fields[];
      rows[i].readAll(fields);
      ArrayPrint(fields);
   }
   ...

这两种方法应该给出相同的结果,尽管呈现方式不同(见下面的日志)。

接下来,我们的脚本暂停1秒钟,以便我们可以注意到下一次我们将更改的条目的时间戳的变化。

cpp
   Print("Pause...");
   Sleep(1000);
   ...

对于result[]数组中的对象,我们为它们分配位于脚本旁边文件夹中的“yuan.bmp”图像。然后我们更新数据库中的对象。

cpp
   for(int i = 0; i < ArraySize(result); ++i)
   {
      result[i].image = "yuan.bmp";
      db.update(result[i]);
   }
   ...

运行脚本后,你可以在MetaEditor内置的数据库导航器中确认所有四条记录在数据库中都有BLOB,并且前两条记录和后两条记录的时间戳有所不同。

让我们演示二进制数据的提取。我们首先看看BLOB是如何映射到image字符串字段的(二进制数据不适合记录在日志中,我们这样做只是为了演示)。

cpp
   const long id1 = 1;
   Struct s;
   if(db.read(id1, s))
   {
      Print("Length of string with Blob: ", StringLen(s.image));
      Print(s.image);
   }
   ...

然后我们使用getBlob读取整个数据(总长度大于上面的字符串长度)。

cpp
   DBRow *r;
   if(db.read(id1, r, "Struct"))
   {
      uchar bytes[];
      Print("Actual size of Blob: ", r.getBlob("image", bytes));
      FileSave("temp.bmp.raw", bytes); // 不是BMP,没有头部
   }

我们需要得到temp.bmp.raw文件,它与为调试目的在Struct::bindAll方法中创建的MQL5/Files/Images/dollar.bmp.raw相同。因此,很容易验证写入和读取的二进制数据是否完全一致。

请注意,由于我们将资源的二进制内容存储在数据库中,它不是一个BMP源文件:资源会进行颜色归一化,并存储一个没有头部的像素数组以及关于图像的元信息。

在运行时,脚本会生成详细的日志。特别是,数据库和表的创建会用以下行标记。

db.isOpen()=true / ok
db.deleteTable(typename(Struct))=true / ok
sql=CREATE TABLE IF NOT EXISTS Struct (id INTEGER PRIMARY KEY,
name TEXT ,
number REAL ,
timestamp INTEGER CURRENT_TIMESTAMP,
image BLOB ); / ok
db.createTable<Struct>(true)=true / ok

插入对象数组的SQL查询会准备一次,然后在预先绑定不同数据的情况下多次执行(这里只显示一次迭代)。DatabaseBind函数的调用次数与查询中的'?n'变量匹配('?4'会被我们的类自动替换为SQL的STRFTIME('%s')函数调用,以获取当前的UTC时间戳)。

sql=INSERT INTO 'Struct' VALUES(?1,?2,?3,STRFTIME('%s'),?5) RETURNING rowid; / ok
DatabasePrepare(db,sql)=131073 / ok
DatabaseBindArray(handle,index,value)=true / ok
DatabaseBind(handle,index,value)=true / ok
DatabaseBind(handle,index,value)=true / ok
DatabaseBindArray(handle,index,value)=true / ok
DatabaseRead(query)=true / ok
...

接下来,一个已经分配了主键rowid的结构数组会在日志的第一列输出。

    [id]   [name] [number]         [timestamp]               [image]
[0]    1 "dollar"  1.00000 1970.01.01 00:00:00 "::Images\dollar.bmp"
[1]    2 "euro"    1.00402 1970.01.01 00:00:00 "::Images\euro.bmp"  
[2]    3 "yuan"    0.14635 1970.01.01 00:00:00 null                 
[3]    4 "yen"     0.00731 1970.01.01 00:00:00 null

选择没有图像的记录会得到以下结果(我们用不同的方法执行这个查询两次:第一次填充Struct结构数组,第二次是DBRow数组,对于每个字段,我们以MqlParam的形式获取“值”)。

DatabasePrepare(db,sql)=196609 / ok
DatabaseReadBind(query,object)=true / ok
DatabaseReadBind(query,object)=true / ok
DatabaseReadBind(query,object)=false / DATABASE_NO_MORE_DATA(5126)
query.readAll(result)=true / ok
    [id] [name] [number]         [timestamp] [image]
[0]    3 "yuan"  0.14635 2022.08.20 13:14:38 null   
[1]    4 "yen"   0.00731 2022.08.20 13:14:38 null   
DatabaseRead(query)=true / ok
DatabaseRead(query)=true / ok
DatabaseRead(query)=false / DATABASE_NO_MORE_DATA(5126)
0
    [type] [integer_value] [double_value] [string_value]
[0]      4               3        0.00000 null          
[1]     14               0        0.00000 "yuan"        
[2]     13               0        0.14635 null          
[3]     10      1661001278        0.00000 null          
[4]      0               0        0.00000 null          
1
    [type] [integer_value] [double_value] [string_value]
[0]      4               4        0.00000 null          
[1]     14               0        0.00000 "yen"         
[2]     13               0        0.00731 null          
[3]     10      1661001278        0.00000 null          
[4]      0               0        0.00000 null          
...

脚本的第二部分更新了几个找到的没有图像的记录,并为它们添加了BLOB。

Pause...
sql=UPDATE 'Struct' SET (id,name,number,timestamp,image)=
   (?1,?2,?3,STRFTIME('%s'),?5) WHERE rowid=3; / ok
DatabasePrepare(db,sql)=262145 / ok
DatabaseBind(handle,index,value)=true / ok
DatabaseBind(handle,index,value)=true / ok
DatabaseBind(handle,index,value)=true / ok
DatabaseBindArray(handle,index,value)=true / ok
DatabaseRead(handle)=false / DATABASE_NO_MORE_DATA(5126)
sql=UPDATE 'Struct' SET (id,name,number,timestamp,image)=
   (?1,?2,?3,STRFTIME('%s'),?5) WHERE rowid=4; / ok
DatabasePrepare(db,sql)=327681 / ok
DatabaseBind(handle,index,value)=true / ok
DatabaseBind(handle,index,value)=true / ok
DatabaseBind(handle,index,value)=true / ok
DatabaseBindArray(handle,index,value)=true / ok
DatabaseRead(handle)=false / DATABASE_NO_MORE_DATA(5126)
...

最后,当以两种方式获取二进制数据时 —— 一种是不兼容的方式,即通过读取整个DatabaseReadBind对象的image字符串字段(这样做只是为了在日志中可视化字节序列),另一种是兼容的方式,即通过DatabaseReadDatabaseColumnBlob —— 我们得到不同的结果:当然,第二种方法是正确的:BLOB的长度和内容(4096字节)被恢复了。

sql=SELECT * FROM 'Struct' WHERE rowid=1; / ok
DatabasePrepare(db,sql)=393217 / ok
DatabaseReadBind(query,object)=true / ok
Length of string with Blob: 922

ʭ7?ʭ7?ʭ7?ʭ7?ʭ7?ʭ7?ɬ7?ȫ6?ũ6?Ĩ5???5?¦5?Ĩ5?ƪ6?ȫ6?Ȭ7?ɬ7?ɬ7?ʭ7?ʭ7?ʭ7?ʭ7?ʭ7?ʭ7?ʭ7?ʭ7?ʭ7?ʭ7?ʭ7??҉??֒??ٛ...
sql=SELECT * FROM 'Struct' WHERE rowid=1; / ok
DatabasePrepare(db,sql)=458753 / ok
DatabaseRead(query)=true / ok
Actual size of Blob: 4096

在总结我们开发自有对象关系映射(ORM)包装器的中间成果时,我们给出了其各类别的通用方案。

事务

SQLite 支持事务,事务是指在逻辑上相关的一组操作,这些操作要么全部执行,要么完全不执行,这确保了数据库中数据的一致性。

事务这一概念在数据库的语境中有了新的含义,与我们过去用于描述交易事务的含义不同。交易事务指的是对交易账户实体(包括订单、交易和仓位)进行的单独操作。

事务为数据库变更提供了 4 个主要特性:

  • 原子性(不可分割性):事务成功完成后,其中包含的所有变更都会进入数据库;如果出现错误,任何变更都不会进入数据库。
  • 一致性:数据库当前的正确状态只能变更为另一个正确状态(根据应用逻辑,排除中间状态)。
  • 隔离性:在当前连接的事务结束之前,该事务中的变更在其他连接到同一数据库的连接中是不可见的;反之,在当前连接存在未完成的事务时,其他连接的变更在当前连接中也是不可见的。
  • 持久性:成功事务的变更能保证存储在数据库中。

这些特性的英文首字母——Atomic(原子性)、Consistent(一致性)、Isolated(隔离性)和 Durable(持久性)——组成了数据库理论中广为人知的缩写词 ACID。

即使程序的正常运行因系统故障而中断,数据库也将保持其工作状态。

最常见的是,使用银行系统的例子来说明事务的应用。在银行系统中,资金从一个客户的账户转移到另一个客户的账户。这会影响到两条记录,即客户的账户余额:其中一个账户的余额会减少转账金额,而另一个账户的余额会增加相同的金额。如果这些变更中只有一个被应用,就会破坏银行账户的平衡:根据哪个操作失败,转账的金额可能会消失,或者相反,会凭空出现。

我们可以举一个更接近交易实践但基于 “相反” 原则的例子。事实上,MetaTrader 5 中用于记录订单、交易和仓位的系统不是基于事务的。

特别是,正如我们在 “创建专家顾问” 一章中所了解到的,一个已触发的订单(市价单或挂单),如果从活跃订单列表中消失,可能不会立即显示在仓位列表中。因此,为了分析实际结果,有必要在 MQL 程序中实现对交易环境更新(同步)的等待机制。如果记账系统是基于事务的,那么订单的执行、交易在历史记录中的登记以及仓位的出现都将包含在一个事务中,并相互协调。终端的开发者选择了另一种方法:尽可能快速且异步地返回对交易环境的任何修改,而其完整性必须由 MQL 程序来监控。

任何修改数据库的 SQL 命令(实际上,除了 SELECT 之外的所有命令),如果事先没有显式地进行事务处理,都会自动被包装在一个事务中。

MQL5 API 提供了 3 个用于管理事务的函数:DatabaseTransactionBeginDatabaseTransactionCommitDatabaseTransactionRollback。所有函数在成功时返回 true,出现错误时返回 false

c
bool DatabaseTransactionBegin(int database)

DatabaseTransactionBegin 函数使用从 DatabaseOpen 获得的指定描述符,在数据库中开始执行一个事务。

随后对数据库所做的所有变更都会累积在内部事务缓存中,直到调用 DatabaseTransactionCommit 函数时才会进入数据库。

MQL5 中的事务不能嵌套:如果已经开始了一个事务,那么再次调用 DatabaseTransactionBegin 将返回错误标志,并在日志中输出一条消息。

database error, cannot start a transaction within a transaction
DatabaseTransactionBegin(db)=false / DATABASE_ERROR(5601)

相应地,你不能尝试多次完成一个事务。

c
bool DatabaseTransactionCommit(int database)

DatabaseTransactionCommit 函数结束之前在具有指定句柄的数据库中开始的事务,并应用所有累积的变更(保存这些变更)。如果一个 MQL 程序启动了一个事务,但在关闭数据库之前没有应用该事务,所有的变更都将丢失。

如果有必要,程序可以撤销该事务,从而撤销自事务开始以来的所有变更。

c
bool DatabaseTransactionRollback(int database)

DatabaseTransactionRollback 函数对具有数据库句柄的数据库中,之前启动的事务中包含的所有操作执行 “回滚”。

考虑到事务嵌套的限制(我们将在 transaction 变量中计算这一限制),我们来完善 DBSQLite 类中用于处理事务的方法。如果 transaction 为 0,begin 方法会通过调用 DatabaseTransactionBegin 来启动一个事务。所有后续尝试启动事务的操作只会增加计数器的值。在 commit 方法中,我们减少计数器的值,当它达到 0 时,调用 DatabaseTransactionCommit

c
class DBSQLite
{
protected:
   int transaction;
   ...
public:
   bool begin()
   {
      if(transaction > 0)   // already in transaction
      {
         transaction++;     // keep track of the nesting level
         return true; 
      }
      return (bool)(transaction = PRTF(DatabaseTransactionBegin(handle)));
   }
   
   bool commit()
   {
      if(transaction > 0)
      {
         if(--transaction == 0) // outermost transaction
            return PRTF(DatabaseTransactionCommit(handle));
      }
      return false;
   }
   bool rollback()
   {
      if(transaction > 0)
      {
         if(--transaction == 0)
            return PRTF(DatabaseTransactionRollback(handle));
      }
      return false;
   }
};

此外,我们创建 DBTransaction 类,该类允许在代码块(例如函数)内部描述对象,确保在程序退出该代码块时自动启动一个事务,并在之后应用(或取消)该事务。

c
class DBTransaction
{
   DBSQLite *db;
   const bool autocommit;
public:
   DBTransaction(DBSQLite &owner, const bool c = false): db(&owner), autocommit(c)
   {
      if(CheckPointer(db) != POINTER_INVALID)
      {
         db.begin();
      }
   }
   
   ~DBTransaction()
   {
      if(CheckPointer(db) != POINTER_INVALID)
      {
         autocommit ? db.commit() : db.rollback();
      }
   }
   
   bool commit()
   {
      if(CheckPointer(db) != POINTER_INVALID)
      {
         const bool done = db.commit();
         db = NULL;
         return done;
      }
      return false;
   }
};

使用这种对象的策略消除了处理退出代码块(函数)的各种情况的必要性。

c
void DataFunction(DBSQLite &db)
{
   DBTransaction tr(db);
   DBQuery *query = db.prepare("UPDATE..."); // batch changes
   ... // base modification
   if(... /* error1 */) return;             // automatic rollback
   ... // base modification
   if(... /* error2 */) return;             // automatic rollback
   tr.commit();
}

为了使对象在任何阶段都能自动应用变更,在其构造函数的第二个参数中传入 true

c
void DataFunction(DBSQLite &db)
{
   DBTransaction tr(db, true);
   DBQuery *query = db.prepare("UPDATE...");  // batch changes
   ... // base modification
   if(... /* condition1 */) return;           // automatic commit
   ... // base modification
   if(... /* condition2 */) return;           // automatic commit
   ...
}                                             // automatic commit

你可以在循环内部描述 DBTransaction 对象,然后在每次迭代时,都会启动并关闭一个单独的事务。

在 “使用 SQLite 搜索交易策略的示例” 部分将给出事务的演示。

数据库表的导入和导出

MQL5 允许将单个数据库表导出到 CSV 文件或从 CSV 文件导入。但不提供将整个数据库作为包含 SQL 命令的文件进行导出/导入的功能。

函数 DatabaseImport

long DatabaseImport(int database, const string table, const string filename, uint flags,
  const string separator, ulong skip_rows, const string comment_chars)

DatabaseImport 函数将指定文件中的数据导入到表中。前两个参数分别为已打开的数据库描述符和表名。

如果名为 table 的表不存在,将自动创建该表。表中字段的名称和类型将根据文件中包含的数据自动识别。

导入的文件不仅可以是现成的 CSV 文件,也可以是包含 CSV 文件的 ZIP 归档文件。filename 可以包含路径,文件将相对于 MQL5/Files 目录进行搜索。

可按位组合的有效标志在 ENUM_DATABASE_IMPORT_FLAGS 枚举中描述:

  • DATABASE_IMPORT_HEADER — 第一行包含表字段的名称
  • DATABASE_IMPORT_CRLF — 使用 CRLF 字符序列作为换行符
  • DATABASE_IMPORT_APPEND — 向现有表中添加数据
  • DATABASE_IMPORT_QUOTED_STRINGS — 字符串值用双引号括起来
  • DATABASE_IMPORT_COMMON_FOLDER — 终端的公共文件夹

参数 separator 设置 CSV 文件中的分隔符字符。

参数 skip_rows 跳过文件中指定数量的开头行。

参数 comment_chars 包含文件中用作注释标志的字符。以这些字符中的任何一个开头的行将被视为注释,不会被导入。

该函数返回导入的行数,发生错误时返回 -1。

函数 DatabaseExport

long DatabaseExport(int database, const string table_or_sql, const string filename, uint flags, const string separator)

DatabaseExport 函数将一个表或 SQL 查询的结果导出为 CSV 文件。前两个参数指定数据库句柄以及表名或查询文本。

如果导出的是查询结果,那么 SQL 查询必须以 "SELECT" 或 "select" 开头。换句话说,SQL 查询不能更改数据库状态,否则 DatabaseExport 将以错误结束。

根据标志的不同,文件名 filename 可以包含当前终端实例的 MQL5/Files 目录内的路径,或者终端的共享文件夹路径。

flags 参数允许指定控制文件格式和位置的标志组合:

  • DATABASE_EXPORT_HEADER — 输出包含字段名称的字符串
  • DATABASE_EXPORT_INDEX — 显示行号
  • DATABASE_EXPORT_NO_BOM — 不在文件开头插入字节顺序标记 BOM(默认插入 BOM)
  • DATABASE_EXPORT_CRLF — 使用 CRLF 换行(默认使用 LF
  • DATABASE_EXPORT_APPEND — 将数据追加到现有文件的末尾(默认情况下,文件会被覆盖),如果文件不存在,将创建该文件
  • DATABASE_EXPORT_QUOTED_STRINGS — 用双引号输出字符串值
  • DATABASE_EXPORT_COMMON_FOLDER — CSV 文件将在所有终端的公共文件夹 MetaQuotes/Terminal/Common/File 中创建

参数 separator 指定列分隔符字符。如果为 NULL,则将使用制表符 \t 作为分隔符。空字符串 "" 被视为有效分隔符,但生成的 CSV 文件不能作为表读取,它将是一组行。

数据库中的文本字段可以包含换行符(\r\r\n)以及 separator 参数中指定的分隔符字符。在这种情况下,必须在 flags 参数中使用 DATABASE_EXPORT_QUOTED_STRINGS 标志。如果存在此标志,所有输出字符串将用双引号括起来,如果字符串中包含双引号,它将被替换为两个双引号。

该函数返回导出的记录数,发生错误时返回负值。

将表和SQL查询打印到日志中

如果有必要,MQL程序可以使用DatabasePrint函数将表的内容或SQL查询的结果输出到日志中。

c
long DatabasePrint(int database, const string table_or_sql, uint flags)

第一个参数传入数据库句柄,紧接着是表名或查询文本(table_or_sql)。SQL查询必须以“SELECT”或“select”开头,也就是说,它不能改变数据库的状态。否则,DatabasePrint函数将以错误结束。

flags参数指定了一组标志,这些标志决定了输出的格式。

  • DATABASE_PRINT_NO_HEADER — 不显示表的列名(字段名)
  • DATABASE_PRINT_NO_INDEX — 不显示行号
  • DATABASE_PRINT_NO_FRAME — 不显示分隔表头和数据的边框
  • DATABASE_PRINT_STRINGS_RIGHT — 右对齐字符串

如果flags = 0,则会显示列和行,表头和数据由边框分隔,并且行左对齐。

该函数返回显示的记录数,如果发生错误则返回-1。

我们将在下一节中使用这个函数。

遗憾的是,该函数不允许输出带有参数的预编译查询。如果存在参数,则需要在MQL5级别将它们嵌入到查询文本中。

使用SQLite搜索交易策略的示例

让我们尝试使用SQLite来解决实际问题。我们将把带有报价历史数据的结构导入到MqlRates数据库中,并对其进行分析,以便识别模式并搜索潜在的交易策略。当然,任何选定的逻辑也可以在MQL5中实现,但SQL提供了一种不同的方式,在很多情况下,使用SQL的许多有趣的内置函数能更高效地实现。本书的主题是学习MQL5,因此无法深入探讨这项技术,但我们认为它值得算法交易员关注。

将报价历史转换为数据库格式的脚本名为DBquotesImport.mq5。在输入参数中,可以设置数据库名称的前缀以及事务大小(一个事务中的记录数量)。

input string Database = "MQL5Book/DB/Quotes";
input int TransactionSize = 1000;

为了使用我们的对象关系映射(ORM)层将MqlRates结构添加到数据库中,该脚本定义了一个辅助的MqlRatesDB结构,它提供了将结构字段绑定到数据库列的规则。由于我们的脚本仅向数据库写入数据,而不从数据库读取数据,所以不需要使用DatabaseReadBind函数进行绑定,因为该函数会对结构的“简洁性”施加限制。没有这个限制,就可以从MqlRates派生MqlRatesDB结构(并且无需重复字段描述)。

struct MqlRatesDB: public MqlRates
{
   /* 供参考:
   
      datetime time;
      double   open;
      double   high;
      double   low;
      double   close;
      long     tick_volume;
      int      spread;
      long     real_volume;
   */
   
   bool bindAll(DBQuery &q) const
   {
      return q.bind(0, time)
         && q.bind(1, open)
         && q.bind(2, high)
         && q.bind(3, low)
         && q.bind(4, close)
         && q.bind(5, tick_volume)
         && q.bind(6, spread)
         && q.bind(7, real_volume);
   }
   
   long rowid(const long setter = 0)
   {
 // rowid是根据柱线时间由我们自己设置的
      return time;
   }
};
   
DB_FIELD_C1(MqlRatesDB, datetime, time, DB_CONSTRAINT::PRIMARY_KEY);
DB_FIELD(MqlRatesDB, double, open);
DB_FIELD(MqlRatesDB, double, high);
DB_FIELD(MqlRatesDB, double, low);
DB_FIELD(MqlRatesDB, double, close);
DB_FIELD(MqlRatesDB, long, tick_volume);
DB_FIELD(MqlRatesDB, int, spread);
DB_FIELD(MqlRatesDB, long, real_volume);

数据库名称由前缀Database、名称以及脚本运行所在的当前图表的时间框架组成。在数据库中会创建一个名为“MqlRatesDB”的表,其字段配置由DB_FIELD宏指定。请注意,主键不会由数据库生成,而是直接从柱线的时间字段(柱线开盘时间)获取。

void OnStart()
{
   Print("");
   DBSQLite db(Database + _Symbol + PeriodToString());
   if(!PRTF(db.isOpen())) return;
   
   PRTF(db.deleteTable(typename(MqlRatesDB)));
   
   if(!PRTF(db.createTable<MqlRatesDB>(true))) return;
   ...

接下来,使用大小为TransactionSize的柱线包,我们从历史数据中请求柱线并将它们添加到表中。这是辅助函数ReadChunk的工作,只要有数据(该函数返回true)或者用户没有手动停止脚本,就会在循环中调用它。函数代码如下所示。

   int offset = 0;
   while(ReadChunk(db, offset, TransactionSize) && !IsStopped())
   {
      offset += TransactionSize;
   }

在处理过程完成后,我们向数据库请求表中生成的记录数量,并将其输出到日志中。

   DBRow *rows[];
   if(db.prepare(StringFormat("SELECT COUNT(*) FROM %s",
      typename(MqlRatesDB))).readAll(rows))
   {
      Print("Records added: ", rows[0][0].integer_value);
   }
}

ReadChunk函数如下所示。

bool ReadChunk(DBSQLite &db, const int offset, const int size)
{
   MqlRates rates[];
   MqlRatesDB ratesDB[];
   const int n = CopyRates(_Symbol, PERIOD_CURRENT, offset, size, rates);
   if(n > 0)
   {
      DBTransaction tr(db, true);
      Print(rates[0].time);
      ArrayResize(ratesDB, n);
      for(int i = 0; i < n; ++i)
      {
         ratesDB[i] = rates[i];
      }
      
      return db.insert(ratesDB);
   }
   else
   {
      Print("CopyRates failed: ", _LastError, " ", E2S(_LastError));
   }
   return false;
}

它调用内置的CopyRates函数,通过该函数填充报价柱线数组。然后将柱线转移到ratesDB数组中,这样我们只需使用一条语句db.insert(ratesDB)就可以将信息写入数据库(我们已经在MqlRatesDB中规定了如何正确执行此操作)。

在代码块中存在DBTransaction对象(启用了自动“提交”选项)意味着对数组的所有操作都包含在一个事务中。为了显示进度,在处理每个柱线块时,会在日志中显示第一个柱线的标签。

只要CopyRates函数返回数据并且将数据成功插入数据库,OnStart中的循环就会继续,将复制的柱线编号向历史数据深处移动。当到达可用历史数据的末尾或终端设置中设置的柱线限制时,CopyRates将返回错误4401(HISTORY_NOT_FOUND),脚本将退出。

让我们在EURUSD、H1图表上运行该脚本。日志中应该会显示类似以下内容。

   db.isOpen()=true / ok
   db.deleteTable(typename(MqlRatesDB))=true / ok
   db.createTable<MqlRatesDB>(true)=true / ok
   2022.06.29 20:00:00
   2022.05.03 04:00:00
   2022.03.04 10:00:00
   ...
   CopyRates failed: 4401 HISTORY_NOT_FOUND
   Records added: 100000

现在我们有了QuotesEURUSDH1.sqlite数据库,可以在上面进行实验,以测试各种交易假设。你可以在MetaEditor中打开它,以确保数据已正确传输。

让我们基于历史规律检查一个最简单的策略。我们将找出连续两个同向柱线的统计数据,并按日内时间和星期几进行分类。如果在某些日内时间和星期几的组合上存在明显优势,那么将来可以将其视为按照第一个柱线方向进入市场的信号。

首先,我们设计一个SQL查询,请求特定时间段的报价,并计算每个柱线的价格变动,即相邻开盘价之间的差值。

由于柱线的时间是以秒数存储的(按照MQL5中datetime的标准,同时也是SQL的“Unix纪元”标准),为了便于阅读,最好将其显示转换为字符串,因此让我们从基于DATETIME函数的datetime字段开始SELECT查询:

SELECT
   DATETIME(time, 'unixepoch') as datetime, open, ...

此字段不会参与分析,在此处仅为用户提供。之后,显示价格以供参考,以便我们可以通过调试打印检查价格增量的计算。

由于我们计划在必要时从整个文件中选择特定时间段,因此条件将需要“原始形式”的时间字段,并且也应将其添加到查询中。此外,根据计划的报价分析,我们需要从柱线标签中分离出日内时间以及星期几(它们的编号与MQL5中采用的编号一致,0表示星期日)。我们分别将查询的最后两列称为intraday和day,并且使用TIME和STRFTIME函数来获取它们。

SELECT
   DATETIME(time, 'unixepoch') as datetime, open,
   time,
   TIME(time, 'unixepoch') AS intraday,
   STRFTIME('%w', time, 'unixepoch') AS day, ...

为了在SQL中计算价格增量,可以使用LAG函数。它返回指定列的值,并带有指定的行数偏移量。例如,LAG(X, 1)表示获取上一条记录中的X值,第二个参数1表示偏移量默认为1,即可以省略该参数以得到等效的表达式LAG(X)。要获取下一条记录的值,可以调用LAG(X, -1)。在任何情况下,使用LAG时都需要一个额外的语法结构来指定记录的排序顺序,最简单的形式是OVER(ORDER BY column)。

因此,为了获取两个相邻柱线开盘价之间的价格增量,我们编写如下代码:

   ...
   (LAG(open,-1) OVER (ORDER BY time) - open) AS delta, ...

此列具有预测性,因为它着眼于未来。

我们可以通过将增量相乘来揭示两个同向形成的柱线:正值表示连续的上涨或下跌:

   ...

   (LAG(open,-1) OVER (ORDER BY time) - open) * (open - LAG(open) OVER (ORDER BY time))

      AS product, ...

选择这个指标是因为它在计算中最简单易用:对于实际的交易系统,可以选择更复杂的标准。

为了评估系统在回测中产生的利润,需要将前一个柱线的方向(作为未来走势的指标)与下一个柱线的价格增量相乘。方向在direction列中计算(使用SIGN函数),仅供参考。estimate列中的利润估计值是前一个走势方向与下一个柱线增量(delta)的乘积:如果方向保持不变,我们将得到一个正值(以点数为单位)。

   ...

   SIGN(open - LAG(open) OVER (ORDER BY time)) AS direction,

   (LAG(open,-1) OVER (ORDER BY time) - open) * SIGN(open - LAG(open) OVER (ORDER BY time))

      AS estimate ...

在SQL命令的表达式中,不能使用在同一命令中定义的AS别名。这就是为什么我们不能将estimate确定为delta * direction,而必须显式重复乘积的计算。然而,我们要记住,对于编程分析来说,delta和direction列并非必需,在此处添加它们只是为了向用户可视化表格。

在SQL命令的末尾,我们指定从中进行选择的表,以及回测日期范围的过滤条件:两个参数“from”和“to”。

...
FROM MqlRatesDB
WHERE (time >= ?1 AND time < ?2)

可选地,我们可以添加一个约束LIMIT?3(并输入一个较小的值,例如10),这样一开始对查询结果的可视化验证就不会迫使你浏览数以万计的记录。

你可以使用DatabasePrint函数检查SQL命令的操作,然而,遗憾的是,该函数不允许处理带有参数的预准备查询。因此,我们将不得不使用StringFormat将SQL参数预准备“?n”替换为查询字符串格式化,并在其中替换参数值。或者,完全可以避免使用DatabasePrint,并通过数组DBRow逐行独立地将结果输出到日志中。

因此,请求的最终片段将变为:

   ...
   WHERE (time >= %ld AND time < %ld)
   ORDER BY time LIMIT %d;

需要注意的是,此查询中的datetime值将以“机器”格式从MQL5传入,即自1970年开始的秒数。如果我们想在MetaEditor中调试相同的SQL查询,那么使用日期文字(字符串)编写日期范围条件会更方便,如下所示:

   WHERE (time >= STRFTIME('%s', '2015-01-01') AND time < STRFTIME('%s', '2021-01-01'))

同样,我们在这里需要使用STRFTIME函数(SQL中的“%s”修饰符将指定的日期字符串转换为“Unix纪元”标签;“%s”与MQL5格式字符串相似只是巧合)。

将设计好的SQL查询保存在一个单独的文本文件DBQuotesIntradayLag.sql中,并将其作为资源连接到同名的测试脚本DBQuotesIntradayLag.mq5中。

#resource "DBQuotesIntradayLag.sql" as string sql1

该脚本的第一个参数允许你在数据库名称中设置一个前缀,在相同的图表上使用相同的交易品种和时间框架运行DBquotesImport.mq5后,该数据库应该已经存在。后续的输入参数用于设置日期范围和调试打印到日志的长度限制。

input string Database = "MQL5Book/DB/Quotes";
input datetime SubsetStart = D'2022.01.01';
input datetime SubsetStop = D'2023.01.01';
input int Limit = 10;

报价表在之前的脚本中已经预先知晓。

const string Table = "MqlRatesDB";

在OnStart函数中,我们打开数据库并确保报价表可用。

void OnStart()
{
   Print("");
   DBSQLite db(Database + _Symbol + PeriodToString());
   if(!PRTF(db.isOpen())) return;
   if(!PRTF(db.hasTable(Table))) return;
   ...

接下来,我们在SQL查询字符串中替换参数。我们不仅要注意将SQL参数“?n”替换为格式序列,还要先将百分号“%”翻倍,因为否则StringFormat函数会将它们视为自己的命令,而不会在SQL中传递它们。

   string sqlrep = sql1;
   StringReplace(sqlrep, "%", "%%");
   StringReplace(sqlrep, "?1", "%ld");
   StringReplace(sqlrep, "?2", "%ld");
   StringReplace(sqlrep, "?3", "%d");
   
   const string sqlfmt = StringFormat(sqlrep, SubsetStart, SubsetStop, Limit);
   Print(sqlfmt);

所有这些操作只是为了在DatabasePrint函数的上下文中执行请求。在分析脚本的工作版本中,我们将读取查询结果并以编程方式进行分析,绕过格式化和调用DatabasePrint。

最后,让我们执行SQL查询并将带有结果的表输出到日志中。

   DatabasePrint(db.getHandle(), sqlfmt, 0);
}

这是我们在2022年初针对EURUSD、H1的10根柱线所看到的内容。

db.isOpen()=true / ok

db.hasTable(Table)=true / ok

      SELECT

         DATETIME(time, 'unixepoch') as datetime,

         open,

         time,

         TIME(time, 'unixepoch') AS intraday,

         STRFTIME('%w', time, 'unixepoch') AS day,

         (LAG(open,-1) OVER (ORDER BY time) - open) AS delta,

         SIGN(open - LAG(open) OVER (ORDER BY time)) AS direction,

         (LAG(open,-1) OVER (ORDER BY time) - open) * (open - LAG(open) OVER (ORDER BY time))

            AS product,

         (LAG(open,-1) OVER (ORDER BY time) - open) * SIGN(open - LAG(open) OVER (ORDER BY time))

            AS estimate

      FROM MqlRatesDB

      WHERE (time >= 1640995200 AND time < 1672531200)

      ORDER BY time LIMIT 10;

 #| datetime               open       time intraday day       delta dir       product      estimate

--+------------------------------------------------------------------------------------------------

 1| 2022-01-03 00:00:00 1.13693 1641168000 00:00:00 1  0.0003200098                                

 2| 2022-01-03 01:00:00 1.13725 1641171600 01:00:00 1  2.999999e-05  1  9.5999478e-09  2.999999e-05 

 3| 2022-01-03 02:00:00 1.13728 1641175200 02:00:00 1  -0.001060006  1 -3.1799748e-08  -0.001060006 

 4| 2022-01-03 03:00:00 1.13622 1641178800 03:00:00 1 -0.0003400007 -1  3.6040028e-07  0.0003400007 

 8| 2022-01-03 07:00:00 1.13406 1641193200 07:00:00 1 -0.0002600149 -1  2.0020098e-07  0.0002600149 

 9| 2022-01-03 08:00:00  1.1338 1641196800 08:00:00 1   0.000510001 -1 -1.3260079e-07  -0.000510001 

10| 2022-01-03 09:00:00 1.13431 1641200400 09:00:00 1  0.0004800036  1  2.4480023e-07  0.0004800036 

...

很容易确认柱线的日内时间分配正确,星期几也正确显示为1,代表星期一。你还可以检查delta增量。第一行的product和estimate值为空,因为计算它们需要缺失的上一行数据。

让我们通过对具有相同日内时间(intraday)和星期几(day)组合的记录进行分组,并为每个这样的组合计算一个表征交易成功程度的特定目标指标,来使我们的SQL查询更加复杂。我们将平均单元格大小乘积除以相同乘积的标准差作为这样一个指标。相邻柱线价格增量的平均乘积越大,预期利润就越高,并且这些乘积的分布范围越小,预测就越稳定。在SQL查询中,该指标的名称为objective。

除了目标指标外,我们还将计算利润估计值(backtest_profit)和利润系数(backtest_PF)。我们将利润估计为在日内时间和星期几的背景下,所有柱线的价格增量(estimate)之和(开盘柱线的大小作为价格增量,相当于每根柱线未来的利润点数)。利润系数传统上是正增量与负增量的商。

SELECT

  AVG(product) / STDDEV(product) AS objective,

  SUM(estimate) AS backtest_profit,

  SUM(CASE WHEN estimate >= 0 THEN estimate ELSE 0 END) /

     SUM(CASE WHEN estimate < 0 THEN -estimate ELSE 0 END) AS backtest_PF,

  intraday, day

FROM

(

  SELECT

     time,

     TIME(time, 'unixepoch') AS intraday,

     STRFTIME('%w', time, 'unixepoch') AS day,

     (LAG(open,-1) OVER (ORDER BY time) - open) AS delta,

     SIGN(open - LAG(open) OVER (ORDER BY time)) AS direction,

     (LAG(open,-1) OVER (ORDER BY time) - open) * (open - LAG(open) OVER (ORDER BY time))

        AS product,

     (LAG(open,-1) OVER (ORDER BY time) - open) * SIGN(open - LAG(open) OVER (ORDER BY time))

        AS estimate

  FROM MqlRatesDB

  WHERE (time >= STRFTIME('%s', '2015-01-01') AND time < STRFTIME('%s', '2021-01-01'))

)

GROUP BY intraday, day

ORDER BY objective DESC


第一个SQL查询变成了嵌套查询,我们现在通过外部SQL查询从其中累积数据。按所有日内时间和星期几的组合进行分组,通过GROUP BY intraday, day实现了“额外的”分组。此外,我们添加了按目标指标排序(ORDER BY objective DESC),以便最佳选项显示在表格的顶部。

在嵌套查询中,我们删除了LIMIT参数,因为分组的数量变得可以接受,远少于分析的柱线数量。所以,对于H1时间框架,我们得到120个选项(24×5)。

扩展后的查询放置在文本文件DBQuotesIntradayLagGroup.sql中,该文件又作为资源连接到同名的测试脚本DBQuotesIntradayLagGroup.mq5中。它的源代码与之前的脚本差别不大,所以我们直接展示在默认日期范围(从2015年初到2021年初,不包括2021年和2022年)下运行它的结果。

db.isOpen()=true / ok

db.hasTable(Table)=true / ok

SELECT

  AVG(product) / STDDEV(product) AS objective,

  SUM(estimate) AS backtest_profit,

  SUM(CASE WHEN estimate >= 0 THEN estimate ELSE 0 END) /

     SUM(CASE WHEN estimate < 0 THEN -estimate ELSE 0 END) AS backtest_PF,

  intraday, day

FROM

(

  SELECT

     ...

  FROM MqlRatesDB

  WHERE (time >= 1420070400 AND time < 1609459200)

)

GROUP BY intraday, day

ORDER BY objective DESC

#| objective backtest_profit backtest_PF intraday day

---+---------------------------------------------------------------------------

1| 0.16713214428916 0.073200000000001 1.46040631486258 16:00:00 5

2| 0.118128291843983 0.0433099999999995 1.33678071539657 20:00:00 3

3| 0.103701251751617 0.00929999999999853 1.14148790506616 05:00:00 2

4| 0.102930330078208 0.0164399999999973 1.1932071923845 08:00:00 4

5| 0.089531492651001 0.0064300000000006 1.10167615433271 07:00:00 2

6| 0.0827628326995007 -8.99999999970369e-05 0.999601152226913 17:00:00 4

7| 0.0823433025146974 0.0159700000000012 1.21665988332657 21:00:00 1

8| 0.0767938336191962 0.00522999999999874 1.04226945769012 13:00:00 1

9| 0.0657741522256548 0.0162299999999986 1.09699976093712 15:00:00 2

10| 0.0635243373432768 0.00932000000000044 1.08294766820933 22:00:00 3

...

110| -0.0814131025461459 -0.0189100000000015 0.820605255668329 21:00:00 5

111| -0.0899571263478305 -0.0321900000000028 0.721250432975386 22:00:00 4

112| -0.0909772560603298 -0.0226100000000016 0.851161872161138 19:00:00 4

113| -0.0961794181717023 -0.00846999999999931 0.936377976414036 12:00:00 5

114| -0.108868074018582 -0.0246099999999998 0.634920634920637 00:00:00 5

115| -0.109368419185336 -0.0250700000000013 0.744496534855268 08:00:00 2

116| -0.121893581607986 -0.0234599999999998 0.610945273631843 00:00:00 3

117| -0.135416609546408 -0.0898899999999971 0.343437294573087 00:00:00 1

118| -0.142128458003631 -0.0255200000000018 0.681835182645536 06:00:00 4

119| -0.142196924506816 -0.0205700000000004 0.629769618430515 00:00:00 2

120| -0.15200009633513 -0.0301499999999988 0.708864426419475 02:00:00 1


因此,分析表明,基于前一根柱线,周五16点的H1柱线是延续趋势的最佳候选者。其次是周三20点的柱线,以此类推。

然而,最好在向前的时间段内检查找到的设置。

为此,我们不仅可以在“过去”的日期范围内(在我们的测试中截至2021年)执行当前的SQL查询,还可以在“未来”(从2021年初开始)再执行一次。两个查询的结果应该通过我们的分组(intraday, day)进行连接(JOIN)。然后,在保持按目标指标排序的同时,我们将在相邻列中看到相同的日内时间和星期几组合的利润和利润系数,以及它们下降了多少。

以下是最终的SQL查询(简略版):

SELECT * FROM

(

SELECT

  AVG(product) / STDDEV(product) AS objective,

  SUM(estimate) AS backtest_profit,

  SUM(CASE WHEN estimate >= 0 THEN estimate ELSE 0 END) / 

     SUM(CASE WHEN estimate < 0 THEN -estimate ELSE 0 END) AS backtest_PF,

  intraday, day

FROM

(

  SELECT ...

  FROM MqlRatesDB

  WHERE (time >= STRFTIME('%s', '2015-01-01') AND time < STRFTIME('%s', '2021-01-01'))

)

GROUP BY intraday, day

) backtest

JOIN

(

SELECT

  SUM(estimate) AS forward_profit,

  SUM(CASE WHEN estimate >= 0 THEN estimate ELSE 0 END) /

     SUM(CASE WHEN estimate < 0 THEN -estimate ELSE 0 END) AS forward_PF,

  intraday, day

FROM

(

  SELECT ...

  FROM MqlRatesDB

  WHERE (time >= STRFTIME('%s', '2021-01-01'))

)

GROUP BY intraday, day

) forward

USING(intraday, day)

ORDER BY objective DESC


请求的完整文本在文件DBQuotesIntradayBackAndForward.sql中提供。它在脚本DBQuotesIntradayBackAndForward.mq5中作为资源连接。

使用默认设置运行脚本,我们得到以下指标(带有缩写):

#| objective backtest_profit backtest_PF intraday day forward_profit forward_PF

--+------------------------------------------------------------------------------------------------

1| 0.16713214428916 0.073200000001 1.46040631486 16:00:00 5 0.004920000048 1.12852664576

2| 0.118128291843983 0.0433099999995 1.33678071539 20:00:00 3 0.007880000055 1.277856135

3| 0.103701251751617 0.00929999999853 1.14148790506 05:00:00 2 0.002210000082 1.12149532710

4| 0.102930330078208 0.0164399999973 1.1932071923 08:00:00 4 0.001409999969 1.07253086419

5| 0.089531492651001 0.0064300000006 1.10167615433 07:00:00 2 -0.009119999869 0.561749159058

6| 0.0827628326995007 -8.99999999970e-05 0.999601152226 17:00:00 4 0.009070000091 1.18809622563

7| 0.0823433025146974 0.0159700000012 1.21665988332 21:00:00 1 0.00250999999 1.12131464475

8| 0.0767938336191962 0.00522999999874 1.04226945769012 13:00:00 1 -0.008490000055 0.753913043478

9| 0.0657741522256548 0.0162299999986 1.09699976093712 15:00:00 2 0.01423999997 1.34979120609

10| 0.0635243373432768 0.00932000000044 1.08294766820933 22:00:00 3 -0.00456999993 0.828967065868

...

所以,采用所找到的最佳交易时间安排的交易系统在“未来”时间段内仍能实现盈利,尽管盈利规模不如回测时那么大。

当然,所考虑的这个例子只是交易系统的一个特定情形。例如,我们还可以去找出在哪些日内时间和星期几的组合下,相邻柱线间的反转策略能够奏效,或者完全基于其他原则(如对交易信号的报价分析、日历分析、交易信号组合分析等等)来进行研究。

关键在于,SQLite引擎提供了许多便捷的工具,而这些工具若要在MQL5中实现的话,就需要自行编写代码。说实话,学习SQL是需要花费时间的。交易平台让我们能够选择两种技术的最优组合,以实现高效编程。