MySQL-速成课-全-

MySQL 速成课(全)

原文:zh.annas-archive.org/md5/db5133e150ced008a45ca662e9b77435

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在 1980 年代中期,我获得了我的第一份软件开发工作,这让我接触到了关系数据库管理系统RDBMS),它是一个用于存储和检索数据库数据的系统。这个概念自 1970 年以来就存在,当时 E.F. Codd 发表了他的著名论文,介绍了关系模型。术语关系指的是数据以行和列的网格形式存储,也就是通常所说的表格。

在我刚开始时,商业数据库系统并不普遍。事实上,我并不认识其他在使用数据库的人。我使用的 RDBMS 并不完美,没有图形界面,命令行界面经常无缘无故崩溃。由于万维网尚未发明,我无法通过网站寻求帮助,只能重新启动系统,抱着希望开始新的尝试。

尽管如此,这个想法还是相当不错的。我将大量数据存储在我根据要存储信息的性质创建的表格中。我定义了表格的列,从文件中将数据加载到表格中,并使用结构化查询语言(SQL)对数据进行查询,这是一种与数据库互动的语言,允许我快速添加、更改和删除多行数据。我可以利用这项技术管理整个公司的数据!

如今,关系数据库管理系统无处不在,感谢它们比我在 80 年代使用的那些老旧系统更加稳定和先进。SQL 也得到了极大的改进。本书的重点是 MySQL,它自 1995 年创建以来,已经成为全球最受欢迎的开源 RDBMS。

关于本书

本书将教你使用 MySQL 的社区版服务器(也称为社区版),它是免费的,并且具有大多数人所需的功能。MySQL 还有付费版本,包括企业版,提供额外的功能和能力。所有版本都能在各种操作系统上运行,如 Linux、Windows、macOS,甚至是云端,并且拥有一套强大的功能和工具。

在本书中,你将探索 MySQL 开发中最有用的部分,并了解我多年来积累的经验。我们将涵盖如何编写 SQL 语句;创建表格、函数、触发器和视图;以及如何确保数据的完整性。在最后三章中,你将通过实际项目看到如何在现实世界中使用 MySQL。

本书分为五个部分:

第一部分:入门

第一章:安装 MySQL 和工具 介绍了如何下载 MySQL,并提供了一些在不同操作系统上安装的技巧。你还将安装两个访问 MySQL 的工具:MySQL Workbench 和 MySQL 命令行客户端。

第二章:创建数据库和表定义了数据库和表,并展示了如何创建它们。你还将为表添加约束,以强制执行有关它们将允许的数据的规则,并看到如何通过索引加速数据检索。

第二部分:从 MySQL 数据库选择数据

第三章:SQL 简介介绍了如何查询数据库表以选择你希望显示的信息。你将对结果进行排序,给 SQL 代码添加注释,并处理空值。

第四章:MySQL 数据类型讨论了你可以用来定义表中列的数据类型。你将学习如何定义用于存储字符串、整数、日期等的列。

第五章:连接数据库表总结了你可以一次从两个表中选择数据的不同方式,涵盖了主要的连接类型,以及如何为列和表创建别名。

第六章:执行复杂的多表连接展示了如何连接多个表,并使用临时表、公共表表达式、派生表和子查询。

第七章:比较值引导你进行 SQL 中的值比较。例如,你将学习如何检查一个值是否等于另一个值、是否大于另一个值,或者是否在一系列值范围内。

第八章:调用内置 MySQL 函数解释了什么是函数,如何调用函数,以及最有用的函数是什么。你将了解涉及数学、日期和字符串的函数,并使用聚合函数处理一组值。

第九章:插入、更新和删除数据描述了如何在表中添加、更改和删除数据。

第三部分:数据库对象

第十章:创建视图探讨了数据库视图,或基于你创建的查询的虚拟表。

第十一章:创建函数和存储过程展示了如何编写可重用的存储过程。

第十二章:创建触发器解释了如何编写在数据发生变化时自动执行的数据库触发器。

第十三章:创建事件展示了如何设置基于预定计划运行的功能。

第四部分:高级主题

第十四章:技巧与窍门讨论了如何避免一些常见问题,支持现有系统,并将数据从文件加载到表中。

第十五章:从编程语言中调用 MySQL 探讨了如何从 PHP、Python 和 Java 程序中调用 MySQL。

第五部分:项目

第十六章:构建天气数据库展示了如何使用 cron 和 Bash 等技术,将天气数据加载到运输公司的数据库中。

第十七章:使用触发器跟踪选民数据变化引导你通过构建选举数据库、使用数据库触发器来防止数据错误,并跟踪用户对数据的更改。

第十八章:使用视图保护薪资数据展示了如何使用视图来暴露或隐藏特定用户的敏感数据。

每一章都包括“自己尝试”练习,帮助你掌握书中讲解的概念。

本书适合谁阅读?

本书适合任何对 MySQL 感兴趣的人,包括 MySQL 和数据库的新手、希望复习的开发人员,甚至是从其他数据库系统转到 MySQL 的经验丰富的软件开发人员。

由于本书侧重于 MySQL 的开发而非管理,MySQL 数据库管理员(DBA)可能需要另寻他处。虽然我偶尔会涉及到与 DBA 相关的话题(例如授予表权限),但我不会深入探讨服务器设置、存储容量、备份、恢复或大多数其他与 DBA 相关的问题。

我设计本书是为 MySQL 初学者准备的,但如果您希望在自己的 MySQL 环境中尝试这些练习,第一章将引导您完成 MySQL 的下载和安装过程。

MySQL 中的 SQL 与其他数据库系统中的 SQL

学习 SQL 是使用 MySQL 的一个重要部分。SQL 允许您从数据库中存储、修改和删除数据,以及创建和删除表、查询数据等。

除了 MySQL 之外,其他关系型数据库管理系统,如 Oracle、Microsoft SQL Server 和 PostgreSQL,也使用 SQL。从理论上讲,这些系统使用的 SQL 是根据美国国家标准协会(ANSI)规范进行标准化的。然而,在实践中,不同的数据库系统之间确实存在一些差异。

每个数据库系统都有自己的 SQL 扩展。例如,Oracle 提供了一种称为过程性语言/SQL(PL/SQL)的 SQL 扩展。Microsoft SQL Server 提供了 Transact-SQL(T-SQL)。PostgreSQL 提供了过程性语言/PostgreSQL(PL/pgSQL)。MySQL 没有给其扩展起一个花哨的名字,它只是简单地叫做 MySQL 存储程序语言。这些 SQL 扩展使用不同的语法。

数据库系统创建了这些扩展,因为 SQL 是一种非过程性语言,这意味着它非常适合从数据库中检索和存储数据,但它并不是像 Java 或 Python 这样的过程性编程语言,无法让我们使用 if...then 逻辑或 while 循环等。数据库的过程性扩展则增加了这些功能。

因此,尽管您从本书中学到的大部分 SQL 知识可以转移到其他数据库系统中,但如果您希望在 MySQL 以外的数据库系统上运行查询,某些语法可能需要调整。

使用在线资源

本书包含了许多示例脚本,您可以在github.com/ricksilva/mysql_cc找到。这些脚本按照chapter_X.sql的命名约定,其中X是章节号。第十五章和第十六章有额外的脚本,位于名为chapter_15chapter_16的文件夹中。

每个脚本都会创建与对应章节中展示的 MySQL 数据库和表格。该脚本还包含示例代码和习题的答案。我建议你自己尝试这些习题,但如果卡住了或者想检查答案,随时可以使用这个资源。

你可以浏览脚本并根据需要复制命令。从 GitHub 上,将命令粘贴到你的环境中,使用类似 MySQL Workbench 或 MySQL 命令行客户端的工具(这些工具在第一章中有讨论)。或者,你可以将脚本下载到你的计算机上。为此,进入 GitHub 仓库并点击绿色的Code按钮。选择Download ZIP选项,将脚本作为 ZIP 文件下载。

有关 MySQL 以及可用工具的更多信息,请访问dev.mysql.com/doc/。MySQL 参考手册特别有用。MySQL Workbench 的文档可以在dev.mysql.com/doc/workbench/en/找到,关于 MySQL 命令行的文档请查阅dev.mysql.com/doc/refman/8.0/en/mysql.html

MySQL 是一个非常棒的数据库系统,值得学习。让我们开始吧!

第一部分

入门

在本书的这一部分,你将安装 MySQL 及其访问工具。然后,你将通过创建第一个数据库,开始熟悉这些工具。

在第一章中,你将会在电脑上安装 MySQL、MySQL Workbench 以及 MySQL 命令行客户端。

在第二章中,你将创建自己的 MySQL 数据库以及一个数据库表。

第一章:安装 MySQL 及工具

要开始使用数据库,你将安装 MySQL 的免费版本,称为MySQL 社区版服务器(也叫做MySQL 社区版),以及两个实用工具:MySQL Workbench 和 MySQL 命令行客户端。这些软件可以从 MySQL 官网免费下载。你将使用这些工具在本书中的项目和练习中进行工作。

MySQL 架构

MySQL 使用客户端/服务器架构,如图 1-1 所示。

图 1-1:客户端/服务器架构

该架构的服务器端托管并管理客户端需要访问的资源或服务。这意味着,在实际的生产环境中,服务器软件(MySQL 社区版)将运行在一个专用计算机上,该计算机上托管着 MySQL 数据库。用于访问数据库的工具,MySQL Workbench 和 MySQL 命令行客户端,将驻留在用户的计算机上。

由于你正在为学习目的设置开发环境,你将会在同一台计算机上安装 MySQL 客户端工具和 MySQL 社区版服务器软件。换句话说,你的计算机将同时作为客户端和服务器。

安装 MySQL

安装 MySQL 的说明可以在dev.mysql.com找到。点击MySQL 文档,在 MySQL 服务器标题下,点击MySQL 参考手册,然后选择最新版本。你将会进入该版本的参考手册。在左侧菜单中,点击安装和升级 MySQL。在目录中找到你的操作系统,按照说明下载并安装 MySQL 社区版服务器。

安装 MySQL 的方法有很多种——例如,从 ZIP 压缩包、源代码或 MySQL 安装程序进行安装。安装步骤会根据你的操作系统和你要使用的 MySQL 产品有所不同,因此,最好的安装资源始终是 MySQL 官方网站。不过,我会提供一些建议:

  • 当你安装 MySQL 时,它会创建一个名为root的数据库用户,并要求你选择一个密码。不要丢失这个密码;你以后会用到它。

  • 通常,如果有 MySQL 安装程序可用,我发现使用安装程序程序更为方便。

  • 如果你使用的是 Windows,你将会有两个不同的安装程序选项:一个是网络安装程序,另一个是完整的捆绑安装程序。然而,哪个是哪个并不明显,如图 1-2 所示。

    图 1-2:为 Windows 选择网络安装程序

    网络安装程序文件更小,其文件名包含web字样,如图所示。我建议选择这个选项,因为它允许你选择要安装的 MySQL 产品,并从网络下载它们。完整的捆绑安装程序包含所有 MySQL 产品,这通常并不需要。

    截至本文撰写时,两个安装程序在此网页上显示为 32 位。这指的是安装应用程序,而非 MySQL 本身。任意一个安装程序都可以安装 64 位二进制文件。实际上,在 Windows 上,MySQL 仅适用于 64 位操作系统。

  • 如果你愿意,也可以在不创建账户的情况下下载 MySQL。在图 1-3 所示的网页中,选择屏幕底部的不,谢谢,直接开始下载

图 1-3:在不创建账户的情况下下载 MySQL

接下来,你的下一步是下载 MySQL Workbench,这是一个用于访问 MySQL 数据库的图形化工具。使用此工具,你可以探索数据库、对数据库运行 SQL 语句,并查看返回的数据。要下载 MySQL Workbench,请访问dev.mysql.com/doc/workbench/en/。这将直接带你到 MySQL Workbench 的参考手册。点击左侧菜单中的安装,选择你的操作系统,并按照指示进行操作。

当你在计算机上安装 MySQL Community Server 或 MySQL Workbench 时,MySQL 命令行客户端应该会自动安装。此客户端允许你通过计算机的命令行界面(也叫控制台命令提示符终端)连接到 MySQL 数据库。你可以使用此工具对 MySQL 数据库执行一个或多个保存在脚本文件中的 SQL 语句。当你不需要通过图形化用户界面查看结果时,MySQL 命令行客户端非常有用。

在 MySQL 中,你将使用这三款 MySQL 产品来完成大部分工作,包括本书中的练习。

现在,既然你的计算机已经安装了 MySQL,你可以开始创建数据库了!

摘要

在这一章中,你从官方网站安装了 MySQL、MySQL Workbench 和 MySQL 命令行客户端。你还找到了 MySQL Server 和 MySQL Workbench 的参考手册,其中包含了大量有用的信息。如果你遇到问题、有疑问,或者想了解更多内容,我建议使用这些手册。

在下一章,你将学习如何查看和创建 MySQL 数据库和表。

第二章:创建数据库和表格

在本章中,你将使用 MySQL Workbench 查看和创建 MySQL 数据库。接下来,你将学习如何创建表格来存储数据。你将定义表格的名称及其列,并指定列可以包含的数据类型。掌握这些基础知识后,你将通过 MySQL 的两个有用功能——约束和索引——来优化你的表格。

使用 MySQL Workbench

正如你在第一章中学到的,MySQL Workbench 是一个可视化工具,你可以用它来输入和运行 SQL 命令,并查看它们的结果。在这里,我们将介绍如何使用 MySQL Workbench 查看数据库的基础知识。

你将通过双击 MySQL Workbench 图标来启动它。该工具看起来像图 2-1 所示。

图 2-1:使用 MySQL Workbench 查看数据库

在右上角面板中,输入 show databases; 命令。确保包括分号,它表示语句的结束。然后点击闪电图标,图中高亮显示了图 2-1,以执行该命令。结果,显示可用 MySQL 数据库的列表,将出现在结果网格面板中(你的结果会与我的不同):

Database
--------
information_schema
location
music
mysql
performance_schema
restaurant
solar_system
subway
sys

该列表中的一些数据库是系统数据库,这些数据库在安装 MySQL 时自动创建——例如 information_schemamysqlperformance_schema——而其他的则是我创建的数据库。你创建的任何数据库都应该出现在此列表中。

你也可以通过左侧的导航面板浏览数据库。点击面板底部的 Schemas 标签,显示数据库列表,然后点击右箭头 (▶) 调查数据库的内容。请注意,默认情况下,导航面板不会显示 MySQL 安装时自动创建的系统数据库。

现在你已经看到了如何查看 MySQL 中的数据库列表,是时候尝试创建你自己的数据库了。

创建新数据库

要创建新数据库,可以使用 create database 命令,并为你想要创建的数据库指定一个名称:

create database circus;

create database finance;

create database music;

数据库的名称应该描述其中存储的数据类型。例如,名为 circus 的数据库可能包含关于小丑、走钢丝演员和高空秋千的表格。finance 数据库可能包含应收账款、收入和现金流的表格。有关乐队、歌曲和专辑的数据可能会存储在 music 数据库中。

要删除数据库,请使用 drop database 命令:

drop database circus;

drop database finance;

drop database music;

这些命令将删除你刚创建的三个数据库、这些数据库中的所有表格,以及这些表格中的所有数据。

当然,你还没有真正创建任何表格。现在,你将开始创建表格。

创建新表格

在这个示例中,你将创建一个新表格来存储全球人口数据,并指定该表格可以包含的数据类型:

create database land;

use land;

create table continent
(
   continent_id      int,
   continent_name    varchar(20),
   population        bigint
);

首先,你使用之前看到的create database命令创建一个名为land的数据库。在下一行,use命令告诉 MySQL 使用land数据库来执行随后的 SQL 语句。这确保了你创建的新表将在land数据库中创建。

接下来,你使用create table命令,并为表命名为continent。在括号内,你在continent表中创建了三列——continent_idcontinent_namepopulation——并为每一列选择了一个 MySQL 数据类型,控制该列允许的数据类型。我们来详细讨论一下这个过程。

你将continent_id列定义为int,使其接受整数(数字)数据。每个大洲将在这一列中拥有自己独特的 ID 号码(1、2、3 等)。然后,你将continent_name列定义为varchar(20),使其接受最多 20 个字符的字符数据。最后,你将population列定义为bigint,以接受大整数,因为整个大洲的人口可能是一个非常大的数字。

当你运行这个create table语句时,MySQL 会创建一个空的表。该表具有表名并且定义了列,但尚未包含任何行。你可以随时添加、删除和修改表中的行。

然而,如果你尝试添加一行数据,而这些数据与某一列的数据类型不匹配,MySQL 将拒绝整个行。例如,由于continent_id列被定义为int,MySQL 不允许该列存储像Continent #1A这样的值,因为这些值包含字母。MySQL 也不会允许你在continent_name列中存储像The Continent of Atlantis这样的值,因为这个值的字符数超过了 20 个。

约束

当你创建自己的数据库表时,MySQL 允许你为它们包含的数据设置约束,或者说规则。一旦定义了约束,MySQL 将强制执行这些规则。

约束有助于维护数据完整性;也就是说,它们帮助保持数据库中数据的准确性和一致性。例如,你可能想在continent表上添加一个约束,以确保该表中的某一列不能有两个相同的值。

MySQL 中可用的约束有primary keyforeign keynot nulluniquecheckdefault

主键

在表中识别主键是数据库设计中的一个重要部分。主键由一列或多列组成,用于唯一标识表中的行。当你创建一个数据库表时,你需要确定哪些列应该组成主键,因为这些信息将帮助你稍后检索数据。如果你将来自多个表的数据结合起来,你需要知道从每个表中预期返回多少行,并且如何连接这些表。你不希望在结果集中出现重复或缺失的行。

考虑这个包含customer_idfirst_namelast_nameaddress列的customer表:

customer_id   first_name   last_name   address
-----------   ----------   ---------   ---------------------------
     1        Bob          Smith       12 Dreary Lane
     2        Sally        Jones       76 Boulevard Meugler
     3        Karen        Bellyacher  354 Main Street

要决定表的主键应该是什么,你需要识别出哪个列能够唯一标识表中的每一行。对于这个表,主键应该是customer_id,因为每个customer_id仅对应表中的一行。

无论未来可能向表中添加多少行,都不会有两行具有相同的customer_id。其他列则不能保证这一点。多个人可能拥有相同的名字、姓氏或地址。

主键可以由多个列组成,但即便是first_namelast_nameaddress这三列的组合,也不能保证唯一标识每一行。例如,住在 12 Dreary Lane 的 Bob Smith 可能和同名的儿子住在一起。

要将customer_id列指定为主键,在创建customer表时使用primary key语法,如示例 2-1 所示:

create table customer
(
    customer_id     int,
    first_name      varchar(50),
    last_name       varchar(50),
    address         varchar(100),
    **primary key (customer_id)**
);

示例 2-1:创建主键

在这里,你将customer_id定义为接受整数值的列,并作为表的主键。

customer_id设置为主键对你有三方面的好处。首先,它防止了重复的客户 ID 被插入到表中。如果某个使用你数据库的人尝试在customer_id3的情况下再次插入该 ID,MySQL 会报错并拒绝插入该行。

其次,将customer_id设置为主键可以防止用户为customer_id列插入空值(即缺失或未知值)。当你将某列定义为主键时,它会被指定为一个特殊列,其值不能为 null。(稍后在本章你会了解更多关于 null 值的内容。)

这两个好处属于数据完整性类别。一旦你定义了主键,就可以确保表中的所有行都有唯一的customer_id,且没有customer_id为 null 的情况。MySQL 将强制执行这一约束,有助于保持数据库中数据的高质量。

创建主键的第三个优点是它会促使 MySQL 创建一个索引。索引将有助于加速从表中选择数据时的 SQL 查询性能。我们将在本章稍后的“索引”部分详细讲解索引。

如果一个表没有明显的主键,通常可以添加一个新列作为主键(就像这里展示的customer_id列)。为了性能考虑,最好将主键值保持尽可能简短。

现在让我们看看由多个列组成的主键,这种主键称为复合主键。示例 2-2 中展示的high_temperature表存储了每个城市及其按年份划分的最高温度。

city                      year   high_temperature
-----------------------   ----   ----------------
Death Valley, CA          2020   130
International Falls, MN   2020   78
New York, NY              2020   96
Death Valley, CA          2021   128
International Falls, MN   2021   77
New York, NY              2021   98

示例 2-2:创建多个主键列

对于这个表格,主键应该由cityyear两列组成,因为表格中同一城市和年份应该只有一行数据。例如,目前有一行记录显示 2021 年死亡谷的最高气温为 128,所以当你将cityyear定义为主键时,MySQL 将防止用户为 2021 年死亡谷添加第二行数据。

要将cityyear设置为该表的主键,使用 MySQL 的primary key语法,并包含两个列名:

create table high_temperature
(
    city              varchar(50),
    year              int,
    high_temperature  int,
    **primary key (city, year)**
);

city列被定义为最多容纳 50 个字符,而yearhigh_temperature列被定义为容纳整数。然后,主键被定义为cityyear两列。

MySQL 并不要求你为所创建的表定义主键,但为了数据完整性和性能的考虑,你应该定义主键。如果你无法确定新表的主键应该是什么,这可能意味着你需要重新考虑你的表设计。

每个表格最多只能有一个主键。

外键

外键是表中的一个(或多个)列,这些列与另一个表的主键列匹配。定义外键在两个表之间建立了关系,以便你能够获取一个包含两个表数据的结果集。

你在清单 2-1 中看到过,使用primary key语法可以在customer表中创建主键。你将使用类似的语法在complaint表中创建外键:

create table complaint
    (
    complaint_id  int,
    customer_id   int,
    complaint     varchar(200),
    primary key (complaint_id),
    **foreign key (customer_id) references customer(customer_id)**
    );

在这个例子中,首先你创建complaint表格,定义其列和数据类型,并指定complaint_id为主键。接着,使用foreign key语法可以将customer_id列定义为外键。通过references语法,你指定complaint表的customer_id列引用customer表的customer_id列(稍后你将了解这意味着什么)。

这里是customer表格:

customer_id   first_name   last_name   address
-----------   ----------   ---------   ---------------------------
     1        Bob          Smith       12 Dreary Lane
     2        Sally        Jones       76 Boulevard Meugler
     3        Karen        Bellyacher  354 Main Street

这是complaint表的数据显示:

complaint_id  customer_id  complaint
------------  -----------  -------------------------------
       1            3      I want to speak to your manager

外键让你能看到complaint表中customer_id 3指向的是customer表中的哪一条记录;在这种情况下,customer_id 3指向 Karen Bellyacher。这个结构,如图 2-2 所示,允许你跟踪哪些客户做出了哪些投诉。

图 2-2:主键和外键

customer表中,customer_id列被定义为主键(标记为 PK)。在complaint表中,customer_id列被定义为外键(FK),因为它将用于将complaint表连接到customer表。

事情变得有趣了。因为你定义了外键,MySQL 不允许你在 complaint 表中添加新行,除非它是针对一个有效的客户——即,除非 customer 表中有一行 customer_idcomplaint 表中的 customer_id 相关联。例如,如果你尝试为 customer_id4 的客户在 complaint 表中添加一行,MySQL 会报错。为一个不存在的客户在 complaint 表中添加行没有意义,因此 MySQL 会阻止该行的添加,以保持数据完整性。

此外,现在你已经定义了外键,MySQL 将不允许你从 customer 表中删除 customer_id 3。删除此 ID 会导致 complaint 表中出现一个 customer_id 3 的行,而该行将不再与 customer 表中的任何行对应。限制数据删除是参照完整性的一部分。

每个表只能有一个主键,但一个表可以有多个外键(见 图 2-3)。

图 2-3:一个表只能有一个主键,但可以有多个外键。

图 2-3 显示了一个名为 dog 的表格示例,该表有三个外键,每个外键都指向不同表的主键。在 dog 表中,owner_id 是用来引用 owner 表的外键,breed_id 是用来引用 breed 表的外键,veterinarian_id 是用来引用 veterinarian 表的外键。

与主键一样,当你创建外键时,MySQL 会自动创建一个索引,这将加速对表的访问。稍后将详细说明。

not null

空值(null)表示空或未定义的值。它与零、空字符字符串或空格字符不同。

允许列中有空值在某些情况下是合适的,但有时候允许缺少关键信息可能导致数据库丢失必要的数据。看看这个名为 contact 的表,它包含了联系信息:

contact_id    name          city       phone      email_address
-----------   ----------    ---------  -------    -----------------
     1        Steve Chen    Beijing    123-3123   steve@schen21.org
     2        Joan Field    New York   321-4321   jfield@jfny99.com
     3        Bill Bashful  Lincoln    **null**       bb@shyguy77.edu

contact_id 3phone 列值为 null,因为 Bill Bashful 没有电话。允许 contact 表中的 phone 列为空是合理的,因为某些联系人的电话号码可能无法提供或不适用。

另一方面,name 列不应允许空值。最好不要允许以下行被添加到 contact 表中:

contact_id    name          city       phone      email_address
-----------   ----------    ---------  -------    ---------------
     3        null          Lincoln    null       bb@shyguy77.edu

除非你知道联系人的姓名,否则保存联系人信息没有太大意义,因此你可以为 name 列添加 not null 约束,以防止这种情况发生。

按如下方式创建 contact 表:

create table contact
(
    contact_id     int,
    name           varchar(50) **not null**,
    city           varchar(50),
    phone          varchar(20),
    email_address  varchar(50),
    primary key(contact_id)
);

当你定义name列时使用not null语法,可以防止存储null值,从而保持数据的完整性。如果你尝试添加一个name为 null 的行,MySQL 将显示错误信息并拒绝该行。

对于定义为表主键的列,例如本例中的contact_id列,指定not null并不是必须的。MySQL 会自动防止主键列中的 null 值。

unique

如果你想防止列中出现重复的值,可以在列定义中添加unique约束。让我们回到前面的contact表:

create table contact
(
    contact_id     int,
    name           varchar(50)  not null,
    city           varchar(50),
    phone          varchar(20),
    email_address  varchar(50)  **unique**,
    primary key(contact_id)
);

在这里,你通过在email_address列上使用unique语法,防止输入重复的电子邮件地址。现在 MySQL 将不再允许表中有两个联系人拥有相同的电子邮件地址。

check

你可以使用check约束来确保某个列包含特定的值或特定范围的值。例如,让我们重新查看来自 Listing 2-2 的high_temperature表:

create table high_temperature
(
    city              varchar(50),
    year              int,
    high_temperature  int,
    **constraint check (year between 1880 and 2200),**
 **constraint check (high_temperature < 200),**
    primary key (city, year)
);

在本例中,你在year列上添加了check约束,确保输入表中的年份在 1880 年到 2200 年之间。1880 年之前没有准确的温度跟踪数据,而且你的数据库在 2200 年之后可能不会再使用。尝试添加一个超出该范围的年份很可能是一个错误,因此该约束会防止这种情况发生。

你还在high_temperature列上添加了check约束,将温度值限制在 200 度以下,因为超过这个温度的值很可能是数据错误。

default

最后,你可以为列添加default约束,这样如果没有提供值,将使用默认值。看看以下的job表:

create table job
(
    job_id     int,
    job_desc   varchar(100),
    shift      varchar(50) **default '9-5'**,
    primary key (job_id)
);

在本例中,你为存储工作时间表数据的shift列添加了default约束。默认班次是9-5,意味着如果某行没有包含班次数据,9-5将写入该列。如果提供了shift的值,则不会使用默认值。

你已经看到不同的约束如何帮助你提高和保持表中数据的完整性。接下来,让我们看看另一个 MySQL 特性,它同样能为你的表提供性能上的好处:索引。

索引

MySQL 允许你在表上创建索引,以加速检索数据的过程;在某些情况下,例如在定义了主键或外键的表中,MySQL 会自动创建索引。就像书本后面的索引可以帮助你找到信息,而无需扫描每一页,索引帮助 MySQL 在表中找到数据,而无需读取每一行。

假设你创建了一个product表,如下所示:

create table product
(
    product_id      int,
    product_name    varchar(100),
    supplier_id     int
);

如果你想使检索供应商信息的过程更加高效,以下是创建索引的语法:

create index product_supplier_index on product(supplier_id);

在这个例子中,你在 product 表的 supplier_id 列上创建了一个名为 product_supplier_index 的索引。现在,当用户使用 supplier_id 列从 product 表中检索数据时,索引应该能使检索更快速。

一旦你创建了索引,就不需要通过名称引用它——MySQL 会在后台使用它。新的索引不会改变你使用表的方式;它只是加速了对表的访问。

尽管添加索引可以显著提高性能,但为每一列添加索引并没有意义。维护索引会有性能成本,而创建没有被使用的索引实际上可能会降低性能。

当你创建表时,MySQL 会自动创建你所需的大多数索引。对于已经定义为主键、外键或具有 unique 约束的列,你无需创建索引,因为 MySQL 会自动为这些列创建索引。

让我们来看一下如何从 Figure 2-3 中创建 dog 表:

use pet;

create table dog
(
    dog_id            int,
    dog_name          varchar(50),
    owner_id          int,
    breed_id          int,
 veterinarian_id   int,
    primary key (dog_id),
    foreign key (owner_id) references owner(owner_id),
    foreign key (breed_id) references breed(breed_id),
    foreign key (veterinarian_id) references veterinarian(veterinarian_id)
);

表的主键是 dog_id,外键是 owner_idbreed_idveterinarian_id。注意,你没有使用 create index 命令创建任何索引。MySQL 已经自动为标记为主键和外键的列创建了索引。你可以使用 show indexes 命令来确认这一点:

show indexes from dog;

结果如 Figure 2-4 所示。

图 2-4:MySQL 为 dog 表自动创建的索引

你可以在 Column_name 列中看到,MySQL 已经为该表自动创建了你所需要的所有索引。

删除和修改表

删除一个表,移除该表及其所有数据,请使用 drop table 语法:

drop table product;

在这里,你告诉 MySQL 删除你在前一节创建的 product 表。

要对表进行更改,请使用 alter table 命令。你可以添加列、删除列、更改列的数据类型、重命名列、重命名表、添加或删除约束以及进行其他更改。

尝试修改 Listing 2-1 中的 customer 表:

alter table customer add column zip varchar(50);
alter table customer drop column address;
alter table customer rename column zip to zip_code;
alter table customer rename to valued_customer;

在这里,你通过四种方式修改了 customer 表:添加了一个名为 zip 的列,用于存储邮政编码;删除了 address 列;将 zip 列重命名为 zip_code,使其更具描述性;将表名从 customer 改为 valued_customer

总结

在这一章中,你学习了如何使用 MySQL Workbench 执行命令并查看数据库。你创建了自己的数据库表,并学会了如何通过使用索引和添加约束来优化它们。

在下一章,即本书第二部分的开始部分,你将学习如何使用 SQL 从 MySQL 表中检索数据,按顺序显示数据,格式化 SQL 语句,并在 SQL 中使用注释。

第二部分

从 MySQL 数据库中选择数据

到目前为止,你已经创建了用于存储数据的 MySQL 数据库和表格。在第二部分,你将从这些表格中检索数据。

在第三章中,你将从 MySQL 表中选择数据。

在第四章中,你将更深入地了解 MySQL 的数据类型。

在第五章中,你将使用连接从多个表中选择数据。

在第六章中,你将深入探讨涉及多个表的复杂连接。

在第七章中,你将学习更多关于在 MySQL 中比较值的内容。

在第八章中,你将学习 MySQL 的内置函数,并在 SQL 语句中调用它们。

第三章:SQL 简介

要从 MySQL 数据库中选择数据,你将使用结构化查询语言SQL)。SQL 是查询和管理像 MySQL 这样的关系数据库管理系统(RDBMS)中的数据的标准语言。

SQL 命令可以分为数据定义语言DDL)语句和数据操作语言DML)语句。到目前为止,你一直在使用 DDL 命令,如 create databasecreate tabledrop table定义你的数据库和表格。

DML 命令则用于操作现有数据库和表中的数据。在本章中,你将使用 DML select 命令从表中检索数据。你还将学习如何为 MySQL 指定排序顺序,以及如何处理表列中的空值。

从表中查询数据

查询是从数据库表或一组表中请求信息。要指定你希望从表中检索的信息,使用 select 命令,如 列表 3-1 中所示。

**select** continent_id,
       continent_name,
       population
from   continent;

列表 3-1: 使用 select 显示 continent 表中的数据

这里你正在查询 continent 表(由 from 关键字指示),该表包含每个洲的名称和人口信息。通过使用 select 命令,你指定了要从 continent_idcontinent_namepopulation 列返回数据。这被称为 select 语句。

列表 3-2 显示了执行 select 语句的结果。

continent_id  continent_name  population
------------  --------------  ----------
      1       Asia            4641054775
      2       Africa          1340598147
      3       Europe           747636026
      4       North America    592072212
      5       South America    430759766
      6       Australia         43111704
      7       Antarctica               0

列表 3-2: 执行 select 语句的结果

查询返回了所有七大洲的列表,显示了每个洲的 ID、名称和人口。

为了只显示一个洲的数据——例如亚洲,你可以在之前的代码末尾添加一个 where 子句:

select continent_id,
       continent_name,
       population
from   continent
**where  continent_name = 'Asia';**

where 子句通过对 select 语句应用条件来过滤结果。此查询查找表中 continent_name 列的值等于 Asia 的唯一一行,并显示以下结果:

continent_id  continent_name  population
------------  --------------  ----------
      1       Asia            4641054775

现在将 select 语句更改为仅选择 population 列:

select **population**
from   continent
where  continent_name = 'Asia';

查询现在返回一列(population)和一行(Asia):

population
----------
4641054775

continent_idcontinent_name 的值未出现在你的结果集中,因为你在 SQL 查询中没有选择它们。

使用通配符字符

SQL 中的星号通配符字符(*)允许你选择表中的所有列,而不必在查询中输入所有列名:

**select ***
from   continent;

此查询返回 continent 表中的所有三列。结果与 列表 3-1 相同,其中你单独列出了三列名称。

排序行

当你从数据库查询数据时,通常希望按特定顺序查看结果。为此,在 SQL 查询中添加 order by 子句:

select continent_id,
       continent_name,
 population
from   continent
**order by continent_name;**

在这里,你选择了continent表中的所有列,并根据continent_name列中的值按字母顺序排列结果。

结果如下:

continent_id  continent_name  population
------------  --------------  ----------
      2       Africa          1340598147
      7       Antarctica               0
      1       Asia            4641054775
      6       Australia         43111704
      3       Europe           747636026
      4       North America    592072212
      5       South America    430759766

添加order by continent_name将按字母顺序列出结果,而不管continent_idpopulation列的值是什么。MySQL 按字母顺序排序行,因为continent_name被定义为存储字母数字字符的列。

MySQL 也可以对整数数据类型的列进行排序。你可以通过ascdesc关键字指定结果是按升序(从低到高)还是降序(从高到低)排序:

select continent_id,
       continent_name,
       population
from   continent;
**order by population desc;**

在这个例子中,你让 MySQL 根据population排序,并按降序(desc)排列值。

结果如下:

continent_id  continent_name  population
------------  --------------  ----------
      1       Asia            4641054775
      2       Africa          1340598147
      3       Europe           747636026
      4       North America    592072212
      5       South America    430759766
      6       Australia         43111704
      7       Antarctica               0

查询返回所有七行数据,因为你没有使用where子句来过滤结果。现在数据按population列的降序排列,而不是按continent_name列的字母顺序排列。

SQL 代码格式化

到目前为止,你看到的 SQL 格式很好,容易阅读:

select continent_id,
       continent_name,
       population
from   continent;

注意列名和表名是如何垂直对齐的。像这样以整洁、可维护的格式编写 SQL 语句是个好主意,但 MySQL 也允许你以不太规范的方式编写 SQL 语句。例如,你可以将示例 3-1 中的代码写成一行:

select continent_id, continent_name, population from continent;

或者你也可以将selectfrom语句分开写,如下所示:

select continent_id, continent_name, population
from continent;

两种选项返回与示例 3-1 相同的结果,尽管你的 SQL 可能对于其他人来说稍微难以理解。

可读的代码对于代码库的可维护性非常重要,即使 MySQL 会正常运行不太可读的代码。虽然可能会有诱惑,只是让代码运行起来然后继续做下一个任务,但编写代码仅仅是你的工作的一部分。花时间让代码更易读,你的未来自己(或将来会维护代码的人)会感谢你。

让我们来看一下你可能会遇到的其他 SQL 代码约定。

大写关键字

一些开发者使用大写字母书写 MySQL 关键字。例如,他们可能会像这样将示例 3-1 中的selectfrom写成大写:

SELECT continent_id,
       continent_name,
       population
FROM   continent;

类似地,一些开发者可能会将create table语句中的多个词组写成大写:

CREATE TABLE dog
(
    dog_id            int,
    dog_name          varchar(50) UNIQUE,
    owner_id          int,
    breed_id          int,
    veterinarian_id   int,
    PRIMARY KEY (dog_id),
    FOREIGN KEY (owner_id) REFERENCES owner(owner_id),
    FOREIGN KEY (breed_id) REFERENCES breed(breed_id),
    FOREIGN KEY (veterinarian_id) REFERENCES veterinarian(veterinarian_id)
);

在这里,create tableuniqueprimary keyforeign keyreferences都已被大写化以提高可读性。一些 MySQL 开发者也会将数据类型intvarchar大写。如果你觉得使用大写字母对关键字有帮助,可以随意这样做。

如果你正在处理现有的代码库,最好保持一致,并遵循已有的编码风格。如果你在一家公司工作,且公司有正式的编码风格规范,你应当遵循这些规范。否则,选择最适合你的方式。无论如何,你都会得到相同的结果。

反引号

如果你维护其他开发者编写的 SQL 代码,你可能会遇到使用反引号(`)的 SQL 语句:

select `continent_id`,
       `continent_name`,
       `population`
from   `continent`;

这个查询选择了continent表中的所有列,并将列名和表名用反引号括起来。在这个例子中,即使没有反引号,语句也可以正常运行。

反引号允许你绕过 MySQL 在命名表和列时的一些规则。例如,你可能注意到,当列名包含多个单词时,我使用了下划线连接这些单词,而不是空格,比如continent_id。然而,如果你将列名用反引号括起来,你就不需要使用下划线了;你可以将列命名为continent id,而不是continent_id

通常,如果你将一个表或列命名为select,你会收到一个错误信息,因为select是 MySQL 的保留字;也就是说,它在 SQL 中有一个专门的含义。然而,如果你将select用反引号括起来,查询将不会报错:

select * from `select`;

在这个select * from语句中,你正在选择select表中的所有列。

尽管 MySQL 会运行像这样的代码,但我建议避免使用反引号,因为没有它们你的代码会更易于维护且更易于输入。未来,其他需要更改此查询的开发者可能会被名为select的表或表名中带有空格的名称所困惑。你的目标应该始终是编写简单且结构良好的代码。

代码注释

注释是你可以添加到代码中的解释性文本,以帮助理解代码。它们能帮助你或其他开发者在未来维护代码。通常,注释用于阐明复杂的 SQL 语句,或者指出表或数据中异常的部分。

要添加单行注释,请使用两个连字符后跟一个空格(--)。这种语法告诉 MySQL 该行的其余部分是注释。

这个 SQL 查询在顶部包含了一条单行注释:

**-- This SQL statement shows the highest-populated continents at the top**
select continent_id,
       continent_name,
       population
from   continent
order by population desc;

你可以使用相同的语法在 SQL 语句的末尾添加注释:

select continent_id,
       continent_name, **-- Continent names are displayed in English**
       population
from   continent
order by population desc;

在这段代码中,continent_name列的注释让开发者知道列中的名称是用英语显示的。

要添加多行注释,请在注释的开头使用/*,在结尾使用*/

**/***
**This query retrieves data for all the continents in the world.**
**The population of each continent is updated in this table yearly.**
***/**
select ***** from continent;

这个两行的注释解释了查询并说明了表的更新频率。

内联注释的语法类似:

select 3.14 **/* The value of pi */** * 81;

内联注释有一些特殊用途。例如,如果你维护由他人编写的代码,你可能会注意到看起来像是神秘的内联注释:

select **/*+ no_index(employee idx1) */**
       employee_name
from   employee;

第一行中的/*+ no_index(employee idx1) */是一个优化器提示,它使用了带加号的内联注释语法/*

当你运行查询时,MySQL 的查询优化器会尝试确定执行查询的最快方法。例如,如果employee表上有索引,使用索引来访问数据会更快,还是因为表中行数太少,使用索引反而会变得更慢?

查询优化器通常能很好地生成查询计划,比较它们,然后执行最快的计划。但有时你可能希望给出自己的指示——提示——来指定执行查询的最有效方法。

前面的示例中的提示告诉优化器不要在employee表上使用idx1索引。

查询优化是一个庞大的话题,我们仅仅触及了表面,但如果你遇到/*+ . . . */语法,只需要知道它允许你向 MySQL 提供提示。

正如你所看到的,一个恰当的位置、描述性的注释将节省时间并减少烦恼。对你为什么使用某种方法的简短解释,可以避免其他开发者重复研究相同的问题,或者在你自己维护代码时帮助你回忆起相关内容。然而,要避免添加显而易见的注释;如果某个注释不会让 SQL 更加易懂,就不应该添加它。同时,随着代码的更新,也要更新注释。不再相关且未更新的注释没有任何作用,可能会让其他开发者或将来的你产生困惑。

空值

如第二章中所讨论的,null表示缺失或未知的值。MySQL 有特殊的语法,包括is nullis not null,用于处理数据中的 null 值。

假设有一个名为unemployed的表,它有两列:region_idunemployed。每一行表示一个地区,告诉你该地区有多少人失业。使用select * from查看完整表格,如下所示:

select *
from   unemployed;

结果如下:

region_id   unemployed
---------   ----------
    1          2218457
    2           137455
    3             null

区域 1 和区域 2 已报告其失业人数,但区域 3 尚未报告,因此区域 3 的unemployed列被设置为null值。你不会想在这里使用0,因为那样意味着区域 3 没有失业的人。

要仅显示那些unemployed值为null的地区的行,可以在where子句中使用is null

select *
from   unemployed
**where  unemployed is null;**

结果是:

region   unemployed
------   ----------
   3           null

另一方面,如果你想要排除那些unemployed值为null的行,只查看已经报告的数据,可以在where子句中将is null替换为is not null,如下面所示:

select *
from   unemployed
where  unemployed **is not null;**

结果如下:

region   unemployed
------   ----------
   1        2218457
   2         137455

使用此语法与 null 值结合,可以帮助你筛选表格数据,从而让 MySQL 仅返回最有意义的结果。

总结

在本章中,你学习了如何使用select语句和通配符来从表格中检索数据,并且你看到 MySQL 可以按照你指定的顺序返回结果。你还学习了如何格式化代码以提高可读性和清晰度,包括在 SQL 语句中添加注释以便于代码的维护。最后,你还了解了如何处理数据中的 null 值。

第四章讲述的是 MySQL 数据类型。到目前为止,你创建的表主要使用int来接受整数数据,或者使用varchar来接受字符数据。接下来,你将学习更多关于 MySQL 数据类型的内容,包括数值型和字符型数据类型,以及日期类型和非常大的值类型。

第四章:MySQL 数据类型

在本章中,你将了解所有可用的 MySQL 数据类型。你已经看到intvarchar可以用于整数和字符数据,但 MySQL 也有用于存储日期、时间甚至二进制数据的数据类型。你将探索如何选择最适合列的数据类型以及每种类型的优缺点。

创建表时,你根据列中将存储的数据类型来定义每一列的数据类型。例如,对于存储名称的列,你不会使用仅允许数字的数据类型。你还可能考虑列需要容纳的值的范围。如果某列需要存储像 3.1415 这样的值,你应选择允许小数点后四位的数值数据类型。最后,如果多种数据类型都可以处理该列需要存储的值,你应选择占用最少存储空间的数据类型。

假设你想创建一个名为solar_eclipse的表,包含关于日全食的数据,包括日全食的日期、发生时间、类型及其亮度。你的原始数据可能如下所示:表 4-1。

表 4-1:太阳日全食数据

日全食日期 日全食最大时间 日全食类型 日全食亮度
2022-04-30 20:42:36 部分 0.640
2022-10-25 11:01:20 部分 0.862
2023-04-20 04:17:56 混合型 1.013

为了将这些数据存储到 MySQL 数据库中,你将创建一个包含四列的表:

create table solar_eclipse
(
    eclipse_date                date,
    time_of_greatest_eclipse    time,
    eclipse_type                varchar(10),
    magnitude                   decimal(4,3)
);

在此表中,四列的每一列都定义了不同的数据类型。由于eclipse_date列将存储日期,因此使用date数据类型。time数据类型专门用于存储时间数据,应用于time_of_greatest_eclipse列。

对于eclipse_type列,使用varchar数据类型,因为你需要存储变长的字符数据。你预计这些值不会很长,因此使用varchar(10)将最大字符数设置为 10。

对于magnitude列,使用decimal数据类型,并指定值的总位数为四位,小数点后有三位。

让我们更深入地了解这些以及其他一些数据类型,并探讨在何种情况下使用每种数据类型是合适的。

字符串数据类型

字符串是一组字符,包括字母、数字、空白字符(如空格和制表符)以及符号(如标点符号)。对于仅包含数字的值,你应该使用数值数据类型而非字符串数据类型。你会使用字符串数据类型来存储类似I love MySQL 8.0!的值,而对于类似8.0的值则使用数值数据类型。

本节将探讨 MySQL 的字符串数据类型。

char

char 数据类型用于 固定长度 字符串——即存储确切数量字符的字符串。为了在 country_code 表中定义一个列来存储三字符国家代码,比如 USAGBRJPN,使用 char(3),如下所示:

create table country_code
(
    country_code    **char(3)**
);

在定义 char 数据类型的列时,你在括号内指定字符串的长度。如果省略括号,char 数据类型默认为一个字符,尽管在你只需要一个字符的情况下,明确指定 char(1) 比仅仅写 char 更清晰。

字符串的长度不能超过括号内定义的长度。如果你尝试将 JAPAN 插入 country_code 列,MySQL 会拒绝该值,因为该列已定义为最多存储三个字符。然而,MySQL 会允许你插入少于三个字符的字符串,比如 JP;它只会在 JP 的末尾添加一个空格并将值保存在该列中。

你可以定义一个最多包含 255 个字符的 char 数据类型。如果你尝试定义一个 char(256) 类型的列,你会收到错误信息,因为它超出了 char 的范围。

varchar

varchar 数据类型,你之前见过,是用于 可变长度 字符串,或者可以存储 最多 指定数量字符的字符串。当你需要存储字符串,但不确定它们的具体长度时,它非常有用。例如,创建一个 interesting_people 表,然后定义一个名为 interesting_name 的列来存储各种名字,你需要能够容纳像 Jet Li 这样的短名字,也要能存储像 Hubert Blaine Wolfeschlegelsteinhausenbergerdorff 这样的长名字:

create table interesting_people
(
    interesting_name    **varchar(100)**
);

在括号中,你为 interesting_name 列定义了一个字符限制 100,因为你预计数据库中的任何名字都不会超过 100 个字符。

varchar 能接受的字符数取决于你的 MySQL 配置。你的数据库管理员(DBA)可以帮助你,或者你可以使用这个快速技巧来确定你的最大值。编写一个包含极长 varchar 最大值的 create table 语句:

create table test_varchar_size
(
    huge_column **varchar(999999999)**
);

create table 语句会失败,给出类似这样的错误信息:

Error Code: 1074\. Column length too big for column 'huge_column'
(max = 16383);
use BLOB or TEXT instead

表没有创建,因为 varchar 定义过大,但错误信息告诉你在这个环境中,varchar 可以接受的最大字符数是 16,383,或者 varchar(16383)

varchar 数据类型主要用于存储小字符串。当你存储超过 5,000 个字符时,我建议使用 text 数据类型(稍后我们会介绍它)。

enum

enum 数据类型,缩写为 枚举,允许你创建一个你想在字符串列中允许的值的列表。下面是如何创建一个名为 student 的表,并定义一个 student_class 列,该列只接受以下值之一——Freshman(大一)、Sophomore(大二)、Junior(大三)或 Senior(大四):

create table student
    (
    student_id     int,
    student_class  **enum('Freshman','Sophomore','Junior','Senior')**
    );

如果你尝试向列中添加不在允许值列表中的值,它将被拒绝。你只能向 student_class 列添加允许的一个值;学生不能同时是新生和二年级生。

set

set 数据类型与 enum 数据类型相似,但 set 允许你选择多个值。在以下的 create table 语句中,你为名为 interpreter 的表中的 language_spoken 列定义了一个语言列表:

create table interpreter
    (
    interpreter_id     int,
    language_spoken    **set('English','German','French','Spanish')**
    );

set 数据类型允许你将集合中的任何语言或所有语言添加到 language_spoken 列中,因为某人可能会说一种或多种这些语言。然而,如果你尝试向该列添加任何列表中没有的值,这些值将被拒绝。

tinytext, text, mediumtext 和 longtext

MySQL 包括四种文本数据类型,用于存储可变长度的字符串:

tinytext 存储最多 255 个字符
`text` 存储最多 65,535 个字符,约 64KB
mediumtext 存储最多 16,777,215 个字符,约 16MB
longtext 存储最多 4,294,967,295 个字符,约 4GB

以下 create table 语句创建了一个名为 book 的表,该表包含四个列。最后三个列 author_biobook_proposalentire_book 都使用了不同大小的文本数据类型:

create table book
    (
    book_id            int,
    author_bio         tinytext,
    book_proposal      text,
    entire_book        mediumtext
    );

你使用 tinytext 数据类型为 author_bio 列,因为你预计不会有超过 255 个字符的作者简介。这也强制用户确保他们的简介少于 255 个字符。你为 book_proposal 列选择了 text 数据类型,因为你不预期任何书籍提案会超过 64KB。最后,你选择了 mediumtext 数据类型为 entire_book 列,这样可以将书籍的大小限制在 16MB 内。

二进制数据类型

MySQL 提供了用于存储 二进制 数据或原始字节格式数据的类型,这些数据不可人类直接读取。

tinyblob, blob, mediumblob 和 longblob

二进制大对象(BLOB) 是一个可变长度的字节字符串。你可以使用 BLOB 来存储二进制数据,如图像、PDF 文件和视频。BLOB 数据类型的大小与文本数据类型相同。虽然 tinytext 最多可以存储 255 个字符,但 tinyblob 最多只能存储 255 字节。

tinyblob 存储最多 255 字节
`blob` 存储最多 65,535 字节,约 64KB
mediumblob 存储最多 16,777,215 字节,约 16MB
longblob 存储最多 4,294,967,295 字节,约 4GB

binary

binary 数据类型用于定长的二进制数据。它类似于 char 数据类型,不同之处在于它用于存储二进制数据字符串,而不是字符字符串。你可以通过括号内指定字节字符串的大小,像这样:

create table encryption
    (
    key_id          int,
    encryption_key  **binary(50)**
    );

对于 encryption 表中的名为 encryption_key 的列,你将字节字符串的最大大小设置为 50 字节。

varbinary

varbinary数据类型用于存储可变长度的二进制数据。你可以在括号内指定字节字符串的最大大小:

create table signature
    (
    signature_id    int,
    signature       varbinary(400)
    );

在这里,你创建了一个名为signature(同名表)的列,最大大小为 400 字节。

bit

其中一个不常用的数据类型是bit,用于存储位值。你可以指定要存储多少位,最多可以存储 64 位。定义bit(15)可以存储最多 15 位。

数值数据类型

MySQL 提供了不同大小的数字数据类型。使用哪种数字类型也取决于你要存储的数字是否包含小数点。

tinyint、smallint、mediumint、int 和 bigint

整数是没有小数或分数的整数值。整数值可以是正数、负数或零。MySQL 包括以下整数数据类型:

tinyint 存储从-128 到 127 的整数值,或占用 1 字节存储空间
smallint 存储从-32,768 到 32,767 的整数值,或占用 2 字节存储空间
mediumint 存储从-8,388,608 到 8,388,607 的整数值,或占用 3 字节存储空间
int 存储从-2,147,483,648 到 2,147,483,647 的整数值,或占用 4 字节存储空间
bigint 存储从-9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 的整数值,或占用 8 字节存储空间

如何确定哪种整数类型适合你的数据?查看 Listing 4-1 中的planet_stat表。

create table planet_stat
(
    planet            varchar(20),
    miles_from_earth  bigint,
    diameter_km       mediumint
);

Listing 4-1:创建行星统计信息表

该表包含有关行星的统计信息,其中使用varchar(20)存储行星的名称,使用bigint存储行星与地球的距离(以英里为单位),使用mediumint存储行星的直径(以千米为单位)。

从结果来看,你可以看到海王星距离地球的距离是 2,703,959,966 英里。在这种情况下,bigint是该列的合适选择,因为int无法容纳这个数值。

planet   miles_from_earth  diameter_km
-------  ----------------  -----------
Mars             48678219         6792
Jupiter         390674712       142984
Saturn          792248279       120536
Uranus         1692662533        51118
Neptune        2703959966        49528

考虑到int占用 4 字节存储空间,而bigint占用 8 字节,若使用bigint来存储本应使用int就能足够的列,会浪费更多的磁盘空间。在小表中,若使用int来代替smallintmediumint,不会引起问题。但是如果你的表有 2000 万行,花时间正确设置列的大小就显得非常重要——那些多余的字节会累积起来。

一个提高空间效率的技巧是将整数数据类型定义为unsigned。默认情况下,整数数据类型允许你存储负数和正数。如果你不需要负数,可以使用unsigned来防止负值,并增加正数的范围。例如,tinyint数据类型默认的取值范围是-128 到 127,但如果指定unsigned,取值范围会变成 0 到 255。

如果你将 smallint 指定为 unsigned,你的范围将变为 0 到 65,535。指定 mediumint 数据类型会给你 0 到 16,777,215 的范围,而指定 int 会将范围变为 0 到 4,294,967,295。

在 清单 4-1 中,你将 miles_from_earth 列定义为 bigint,但如果你利用更大的 unsigned 上限值,你可以将这些值存入 int 数据类型。你可以放心地使用 unsigned,因为这个列永远不需要存储负数——没有行星会离地球小于零英里:

create table planet_stat
(
    planet            varchar(20),
    miles_from_earth  **int unsigned, -- Now using int unsigned, not bigint**
    diameter_km       mediumint
);

通过将列定义为 unsigned,你可以使用更紧凑的 int 类型,并节省磁盘空间。

布尔值

布尔值只有两种状态:true 或 false;开或关;1 或 0。技术上,MySQL 并没有一个专门的数据类型来存储布尔值;它们在 MySQL 中作为 tinyint(1) 存储。你可以使用同义词 bool 来创建列以存储布尔值。当你将列定义为 bool 时,后台实际创建的是一个 tinyint(1) 列。

这个名为 food 的表有两个布尔列,organic_flaggluten_free_flag,用来告诉你某种食物是否是有机的或无麸质的:

create table food
(
    food              varchar(30),
    organic_flag      bool,
    gluten_free_flag  bool
);

通常做法是给包含布尔值的列添加后缀 _flag,例如 organic_flag,因为将值设置为 truefalse 可以类比于分别升起或放下旗帜。

要查看表的结构,你可以使用 describedesc 命令。图 4-1 显示了在 MySQL Workbench 中运行 desc food; 的结果。

图 4-1:在 MySQL Workbench 中描述 food

你可以看到,尽管 organic_flaggluten_free_flag 列是使用 bool 同义词创建的,但实际用于创建这些列的数据类型是 tinyint(1)

十进制数据类型

对于包含小数点的数字,MySQL 提供了 decimalfloatdouble 数据类型。decimal 存储精确值,而 floatdouble 存储近似值。因此,如果你存储的值可以被 decimalfloatdouble 同样处理,我建议使用 decimal 数据类型。

decimal

  1. decimal 数据类型允许你定义精度和比例。精度 是你可以存储的总数字个数,比例 是小数点后的数字个数。decimal 数据类型通常用于具有两位小数的货币值。

    例如,如果你将 price 列定义为 decimal(5,2),你可以存储介于 -999.99 和 999.99 之间的值。精度 5 表示你可以存储五个总数字,比例 2 表示你可以存储小数点后的两个数字。

    以下是 decimal 类型的可用同义词:numeric(5,2)dec(5,2)fixed(5,2)。这些都是等效的,都会创建一个 decimal(5,2) 数据类型。

float

  1. float 数据类型用于存储具有浮动小数点的数字数据。与 decimal 数据类型不同,后者的精度是固定的,浮动小数点数字的精度和小数点的位置是不固定的—小数点可以在数字中 浮动float 数据类型可以表示数字 1.234、12.34 或 123.4。

double

  1. double 数据类型是 double precision 的缩写,也允许你存储一个具有未定义小数位的数字,该数字的某个地方有小数点。double 数据类型类似于 float,但 double 可以更精确地存储数字。在 MySQL 中,存储一个 float 使用 4 字节,而存储一个 double 使用 8 字节。对于具有多个数字的浮动小数,建议使用 double 数据类型。

日期和时间数据类型

对于日期和时间,MySQL 提供了 datetimedatetimetimestampyear 数据类型。

date

  1. date 数据类型以 YYYY-MM-DD 格式存储日期(分别表示年、月、日)。

time

  1. time 数据类型以 hh:mm:ss 格式存储时间,表示小时、分钟和秒。

datetime

  1. datetime 数据类型用于存储日期和时间的组合,格式为 YYYY-MM-DD hh:mm:ss

timestamp

  1. timestamp 数据类型也存储日期和时间的组合,格式为 YYYY-MM-DD hh:mm:ss,不过 timestamp 存储的是 当前 的日期和时间,而 datetime 设计用于其他日期和时间值。

    timestamp 接受的日期范围较小;日期必须介于 1970 年至 2038 年之间。datetime 数据类型接受更广泛的日期范围,从公元 1000 年到 9999 年。你应该仅在需要记录当前日期和时间值时使用 timestamp,例如保存某行数据更新时间的日期和时间。

year

  1. year 数据类型以 YYYY 格式存储年份。

JSON 数据类型

JavaScript 对象表示法 (JSON) 是一种流行的格式,用于在计算机之间传输数据。MySQL 提供了 json 数据类型,允许你在数据库中存储和检索整个 JSON 文档。MySQL 会检查 JSON 文档是否包含有效的 JSON 格式,然后才允许将其保存在 json 列中。

一个简单的 JSON 文档可能如下所示:

{
   "department":"Marketing",
   "city":"Detroit",
   "managers":[
      {
         "name":"Tom McBride",
         "age":29
      },
      {
         "name":"Jill Hatfield",
 "age":25
      }
   ]
}

JSON 文档包含键值对。在这个示例中,department 是键,Marketing 是值。这些键值对与表中的行和列无关;相反,整个 JSON 文档可以保存在一个具有 json 数据类型的列中。稍后,你可以使用 MySQL 查询从 JSON 文档中提取属性。

空间数据类型

MySQL 提供了表示地理位置数据或 地理数据 的数据类型。这类数据有助于回答类似“我在哪个城市?”或“离我位置 5 英里内有多少家中餐馆?”的问题。

geometry 存储任何地理类型的位置信息,包括 pointlinestringpolygon 类型
point 表示一个特定的经纬度位置,例如你当前的位置
linestring 表示点与点之间的曲线,例如高速公路的位置
polygon 表示边界,例如国家或城市的边界
multipoint 存储一组无序的point类型集合
multilinestring 存储一组linestring类型集合
emultipolygon 存储一组polygon类型集合
geometrycollection 存储一组geometry类型集合

总结

在本章中,你了解了可用的 MySQL 数据类型及其使用场景。在下一章中,你将学习如何通过不同的 MySQL 连接类型从多个表中检索数据,并将这些数据显示在单一的结果集中。

第五章:连接数据库表

一条 SQL 查询走进酒吧,走向两个表,问道:“我能加入你们吗?”

—历史上最糟糕的数据库笑话

现在你已经学会了如何使用 SQL 从表中选择和过滤数据,接下来你将学习如何连接数据库表。连接表意味着从多个表中选择数据,并将它们合并到一个结果集中。MySQL 提供了不同类型连接的语法,比如内连接和外连接。在本章中,你将学习如何使用每种类型。

从多个表中选择数据

你想从数据库中检索的数据通常会存储在多个表中,且你需要将它们作为一个数据集返回,以便一次性查看所有数据。

我们来看一个例子。这个表,叫做subway_system,包含了世界上每个地铁系统的数据:

subway_system              city              country_code
------------------------   ----------------  ------------
Buenos Aires Underground   Buenos Aires      AR
Sydney Metro               Sydney            AU
Vienna U-Bahn              Vienna            AT
Montreal Metro             Montreal          CA
Shanghai Metro             Shanghai          CN
London Underground         London            GB
MBTA                       Boston            US
Chicago L                  Chicago           US
BART                       San Francisco     US
Washington Metro           Washington, D.C.  US
Caracas Metro              Caracas           VE
`--snip--`

前两列,subway_systemcity,分别包含地铁的名称和它所在的城市。第三列,country_code,存储了两位字符的 ISO 国家代码。AR代表阿根廷,CN代表中国,等等。

第二个表,叫做country,有两列,country_codecountry

country_code   country
------------   -----------
AR             Argentina
AT             Austria
AU             Australia
BD             Bangladesh
BE             Belgium
`--snip--`

假设你想获取地铁系统的列表,并包括完整的城市和国家名称。这些数据分布在两个表中,因此你需要将它们连接起来,以便得到你想要的结果集。每个表都有相同的country_code列,所以你将使用它作为连接,编写一个 SQL 查询来连接这两个表(见 Listing 5-1)。

select subway_system.subway_system,
       subway_system.city,
       country.country
from   subway_system
inner join country
on     subway_system.country_code = country.country_code;

Listing 5-1:连接subway_systemcountry

country表中,country_code列是主键。在subway_system表中,country_code列是外键。回想一下,主键唯一标识表中的每一行,而外键用于与另一个表的主键进行连接。你使用=(等号)符号来指定要连接subway_systemcountry表中country_code列的所有相等值。

由于在这个查询中你从两个表中选择数据,因此每次引用列时最好指定该列所在的表,尤其是当两个表中有相同列名时。这样做有两个原因。首先,它能使 SQL 语句更易于维护,因为在 SQL 查询中,哪个列来自哪个表会立刻显而易见。其次,因为两个表都有一个名为country_code的列,如果不指定表名,MySQL 就不知道你要使用哪个列,并会返回错误信息。为避免这种情况,在select语句中,键入表名、一个点号,再加上列名。例如,在 Listing 5-1 中,subway_system.city指的是subway_system表中的city列。

当你运行这个查询时,它会返回所有地铁系统,并从country表中获取对应的国家名称:

subway_system               city                country
------------------------    ----------------    --------------
Buenos Aires Underground    Buenos Aires        Argentina
Sydney Metro                Sydney              Australia
Vienna U-Bahn               Vienna              Austria
Montreal Metro              Montreal            Canada
Shanghai Metro              Shanghai            China
London Underground          London              United Kingdom
MBTA                        Boston              United States
Chicago L                   Chicago             United States
BART                        San Francisco       United States
Washington Metro            Washington, D.C.    United States
Caracas Metro               Caracas             Venezuela
`--snip--`

请注意,country_code 列没有出现在结果连接中。这是因为您在查询中只选择了 subway_systemcitycountry 列。

表别名

为了节省编写 SQL 的时间,您可以为表名声明别名。表别名是表的短暂名称。以下查询返回与列表 5-1 相同的结果集:

select  **s**.subway_system,
        **s**.city,
        **c**.country
from    subway_system **s**
inner join country **c**
on      **s**.country_code = **c**.country_code;

您声明 ssubway_system 表的别名,ccountry 表的别名。然后,在查询的其他部分引用列名时,您可以输入 sc 来代替完整的表名。请记住,表别名仅对当前查询有效。

您还可以使用 as 来定义表别名:

select  s.subway_system,
        s.city,
        c.country
from    subway_system **as** s
inner join country **as** c
on      s.country_code = c.country_code;

无论是否使用 as,查询返回的结果是相同的,但不使用它可以减少输入量。

连接类型

MySQL 有多种不同类型的连接,每种连接都有自己的语法,概述如下表 5-1。

表 5-1: MySQL 连接类型

连接类型 描述 语法
内连接 返回两个表中有匹配值的行。 inner join join

| 外连接 | 返回一个表中的所有行和第二个表中匹配的行。左连接返回左表中的所有行,右连接返回右表中的所有行。 | left outer join left join

right outer join

right join |

自然连接 基于两个表中相同的列名返回行。 natural join
交叉连接 将一个表中的所有行与另一个表中的所有行匹配,并返回笛卡尔积。 cross join

让我们更深入地了解每种连接类型。

内连接

内连接是最常用的连接类型。在内连接中,只有两个表中都有匹配的数据时,才能检索到数据。

您在列表 5-1 中对 subway_systemcountry 表执行了内连接。返回的列表中没有孟加拉国和比利时的行。这些国家不在 subway_system 表中,因为它们没有地铁;因此,两个表中没有匹配的数据。

请注意,当您在查询中指定inner join时,inner这个词是可选的,因为这是默认的连接类型。以下查询执行内连接,并产生与列表 5-1 相同的结果:

select  s.subway_system,
        s.city,
 c.country
from    subway_system s
**join**    country c
on      s.country_code = c.country_code;

您可能会遇到使用 inner join 的 MySQL 查询,也有使用 join 的查询。如果您有现有的代码库或书面标准,最好遵循其中概述的做法。如果没有,我建议为清晰起见,包含 inner 这个词。

外连接

外连接显示一个表中的所有行以及第二个表中任何匹配的行。在列表 5-2 中,您选择所有国家,并显示这些国家的地铁系统(如果有的话)。

select  c.country,
        s.city,
        s.subway_system
from    subway_system s **right outer join** country c
on      s.country_code = c.country_code;

列表 5-2:执行右外连接

在这个查询中,subway_system表被认为是左表,因为它位于outer join语法的左侧,而country表是右表。由于这是一个外连接,即使在subway_system表中没有匹配的行,这个查询仍然会返回country表中的所有行。因此,所有国家都会出现在结果集中,无论它们是否拥有地铁系统:

country                 city            subway_system
--------------------    ------------    ------------------------
United Arab Emirates    Dubai           Dubai Metro
Afghanistan             null            null
Albania                 null            null
Armenia                 Yerevan         Yerevan Metro
Angola                  null            null
Antarctica              null            null
Argentina               Buenos Aires    Buenos Aires Underground
`--snip--`

对于没有与subway_system表中的匹配行的国家,citysubway_system列将显示为 null 值。

与内连接一样,outer这个词是可选的;使用left joinright join将产生与其较长的等价语句相同的结果。

以下外连接返回的结果与列表 5-2 中的相同,但使用了left outer join语法:

select  c.country,
        s.city,
        s.subway_system
from    country c  **left outer join**  subway_system s
on      s.country_code = c.country_code;

在这个查询中,表的顺序与列表 5-2 中的顺序不同。subway_system表现在被列为最后一个表,成为右表。语法country c left outer join subway_system s等价于列表 5-2 中的subway_system s right outer join country c。无论使用哪种连接方式,只要表的顺序正确,就没有问题。

自然连接

MySQL 中的自然连接会在两个表有相同名称的列时自动连接它们。以下是基于两个表中都存在的列自动连接的语法:

select  *
from    subway_system s
**natural join** country c;

使用自然连接时,你可以避免内连接所需的额外语法。在列表 5-2 中,你需要包含on s.country_code = c.country_code来基于它们共同的country_code列连接表,但使用自然连接时,这个操作是自动完成的。这个查询的结果如下:

country_code    subway_system               city                country
------------    ------------------------    ------------        --------------
AR              Buenos Aires Underground    Buenos Aires        Argentina
AU              Sydney Metro                Sydney              Australia
AT              Vienna U-Bahn               Vienna              Austria
CA              Montreal Metro              Montreal            Canada
CN              Shanghai Metro              Shanghai            China
GB              London Underground          London              United Kingdom
US              MBTA                        Boston              United States
US              Chicago L                   Chicago             United States
US              BART                        San Francisco       United States
US              Washington Metro            Washington, D.C.    United States
VE              Caracas Metro               Caracas             Venezuela
`--snip--`

请注意,你使用select *通配符选择了所有表中的列。另外,尽管两个表都有country_code列,但 MySQL 的自然连接足够智能,仅在结果集中显示该列一次。

笛卡尔连接

MySQL 的cross join语法可以用来获取两个表的笛卡尔积。笛卡尔积是一个列出每一行与第二个表中每一行匹配的结果。例如,假设有一个餐厅的数据库,其中有两个表:main_dishside_dish。每个表有三行和一列。

main_dish表如下所示:

main_item
---------
steak
chicken
ham

side_dish表看起来像这样:

side_item
----------
french fries
rice
potato chips

这两个表的笛卡尔积将是所有主菜和配菜的可能组合的列表,可以使用cross join语法来检索:

select     m.main_item,
           s.side_item
from       main_dish m
**cross join** side_dish s;

这个查询与之前看到的查询不同,它没有基于列来连接表。没有使用主键或外键。以下是该查询的结果:

main_item   side_item
---------   ----------
ham         french fries
chicken     french fries
steak       french fries
ham         rice
chicken     rice
steak       rice
ham         potato chips
chicken     potato chips
steak       potato chips

由于main_dish表有三行,side_dish表也有三行,因此可能的组合总数为九个。

自连接

有时,将表与其自身连接是有益的,这称为自连接。与之前使用的特殊语法不同,您通过将相同的表名列出两次,并使用两个不同的表别名来执行自连接。

例如,以下表格名为music_preference,列出了音乐迷及其喜欢的音乐类型:

music_fan   favorite_genre
---------   --------------
Bob         Reggae
Earl        Bluegrass
Ella        Jazz
Peter       Reggae
Benny       Jazz
Bunny       Reggae
Sierra      Bluegrass
Billie      Jazz

为了将喜欢相同音乐类型的音乐迷配对,您将music_preference表与其自身连接,如列表 5-3 所示。

select a.music_fan,
       b.music_fan
from   **music_preference** **a**
inner join **music_preference** **b**
on (a.favorite_genre = b.favorite_genre)
where  a.music_fan **!=** b.music_fan
order by a.music_fan;

列表 5-3:music_preference表的自连接

music_preference表在查询中列出了两次,一次作为表a,一次作为表b。然后,MySQL 会将表a和表b连接起来,仿佛它们是不同的表。

在这个查询中,您在where子句中使用!=(不等于)语法,确保表amusic_fan列的值与表bmusic_fan列的值不同。(请记住在第三章中,您可以在select语句中使用where子句,通过应用某些条件来筛选结果。)这样,音乐迷就不会与自己配对了。

列表 5-3 产生如下结果集:

music_fan  music_fan
---------  ---------
Benny      Ella
Benny      Billie
Billie     Ella
Billie     Benny
Bob        Peter
Bob        Bunny
Bunny      Bob
Bunny      Peter
Earl       Sierra
Ella       Benny
Ella       Billie
Peter      Bob
Peter      Bunny
Sierra     Earl

现在,音乐迷可以在他们的名字旁边的右侧列中找到其他喜欢相同音乐类型的粉丝。

联接语法的变体

MySQL 允许您以不同的方式编写 SQL 查询来完成相同的结果。了解不同的语法是一个好主意,因为您可能需要修改由其他人编写的代码,而这些人可能不会像您一样编写 SQL 查询。

括号

在连接列时,您可以选择使用括号,或者也可以不使用。这是一个不使用括号的查询:

select  s.subway_system,
        s.city,
        c.country
from    subway_system as s
inner join country as c
on      s.country_code = c.country_code;

这与下面的查询是等价的,它的作用是:

select  s.subway_system,
        s.city,
        c.country
from    subway_system as s
inner join country as c
on      **(**s.country_code = c.country_code**)**;

这两个查询返回相同的结果。

传统的内部连接

这个使用 SQL 旧语法编写的查询,相当于列表 5-1:

select  s.subway_system,
        s.city,
        c.country
from    subway_system as s,
        country as c
where   s.country_code = c.country_code;

这段代码没有使用join这个词;相反,它在from语句中列出了由逗号分隔的表名。

在编写查询时,使用列表 5-1 中显示的较新语法,但请记住,MySQL 仍然支持这种较旧的样式,您今天可能会在某些遗留代码中看到它的使用。

列别名

您在本章中早些时候阅读过表别名;现在您将为列创建别名。

在世界的一些地方,例如法国,地铁系统被称为地铁。让我们从subway_system表中选择法国城市的地铁系统,并使用列别名将标题显示为metro

select  s.subway_system **as metro**,
        s.city,
        c.country
from    subway_system as s
inner join country as c
on      s.country_code = c.country_code
where   c.country_code = 'FR';

与表别名一样,您可以在 SQL 查询中使用as关键字,也可以省略它。无论哪种方式,查询的结果如下,现在subway_system列的标题已更改为metro

metro           city        country
-----           --------    -------
Lille Metro     Lille       France
Lyon Metro      Lyon        France
Marseille Metro Marseille   France
Paris Metro     Paris       France
Rennes Metro    Rennes      France
Toulouse Metro  Toulouse    France

在创建表时,尽量为列标题命名具有描述性的名称,以便查询结果一目了然。在列名不够清晰的情况下,您可以使用列别名。

在不同数据库中连接表

有时多个数据库中会有相同名称的表,因此您需要告诉 MySQL 使用哪个数据库。可以通过几种不同的方式来做到这一点。

在这个查询中,use 命令(在第二章中介绍)告诉 MySQL 使用指定的数据库来执行接下来的 SQL 语句:

**use subway;**

select * from subway_system;

在第一行中,use 命令将当前数据库设置为 subway。然后,当您在下一行选择 subway_system 表的所有行时,MySQL 会知道从 subway 数据库中的 subway_system 表中提取数据。

这是第二种在 select 语句中指定数据库名称的方法:

select * from **subway.subway_system**;

在这个语法中,表名之前加上数据库名和一个句点。subway.subway_system 语法告诉 MySQL,您想从 subway 数据库中的 subway_system 表中选择数据。

这两种选项产生相同的结果集:

subway_system       city                        country_code
-----------------   -------------------------   ------------
Buenos Aires        Underground Buenos Aires    AR
Sydney Metro        Sydney                      AU
Vienna U-Bahn       Vienna                      AT
Montreal Metro      Montreal                    CA
Shanghai Metro      Shanghai                    CN
London Underground  London                      GB
`--snip--`

指定数据库和表名使您能够连接位于同一 MySQL 服务器上不同数据库中的表,如下所示:

select  s.subway_system,
        s.city,
        c.country
from    subway.subway_system as s
inner join location.country as c
on      s.country_code = c.country_code;

这个查询将位于 location 数据库中的 country 表与位于 subway 数据库中的 subway_system 表连接起来。

总结

在本章中,您学习了如何从两个表中选择数据,并使用 MySQL 提供的各种连接将数据显示在一个结果集中。在第六章,您将通过执行涉及多个表的更复杂连接来扩展这些知识。

第六章:使用多个表进行复杂连接

在第五章中,你学会了如何连接两个表并将数据显示在一个结果集中。在这一章,你将学习如何进行多个表的复杂连接,了解关联表,了解如何合并或限制查询结果。然后,你将探索将查询结果临时保存为类似表格的格式的不同方法,包括临时表、派生表和公共表表达式(CTEs)。最后,你将学习如何使用子查询,子查询允许你在一个查询中嵌套另一个查询,以获得更精细的结果。

使用两种连接类型编写一个查询

连接三个或更多的表比连接两个表更加复杂,因为你可能在同一个查询中使用不同类型的连接(例如内连接和外连接)。例如,图 6-1 展示了police数据库中的三个表,包含关于犯罪的信息,包括嫌疑人和位置。

图 6-1:police数据库中的三个表

location表包含犯罪发生地点的信息:

location_id  location_name
-----------  -------------------------
    1        Corner of Main and Elm
    2        Family Donut Shop
    3        House of Vegan Restaurant

crime表包含犯罪描述信息:

crime_id  location_id  suspect_id  crime_name
--------  -----------  ----------  -------------------------------------
    1          1           1       Jaywalking
    2          2           2       Larceny: Donut
    3          3          null     Receiving Salad Under False Pretenses

suspect表包含关于嫌疑人的信息:

suspect_id  suspect_name
----------  ---------------
     1      Eileen Sideways
     2      Hugo Hefty

假设你想编写一个查询,将所有三个表连接起来,获取犯罪列表、犯罪发生地点以及嫌疑人姓名。police数据库的设计保证了crime表中的每个犯罪案件在location表中都有一个匹配的地点。然而,suspect表中可能没有匹配的嫌疑人,因为警方并未为每个案件确定嫌疑人。

你将对crimelocation表进行内连接,因为你知道它们一定会匹配。但是由于并非每个犯罪案件都有嫌疑人,因此你将在crime表和suspect表之间进行外连接。你的查询可能是这样的:

select c.crime_name,
       l.location_name,
       s.suspect_name
from   crime c
❶ join   location l
  on   c.location_id = l.location_id
❷ left join suspect s
  on   c.suspect_id = s.suspect_id;

在这个示例中,你将crime表别名为clocation表别名为lsuspect表别名为s。你使用join语法进行crimelocation表的内连接❶,使用left join语法进行crimesuspect表的外连接❷。

在这个上下文中使用左连接可能会引起一些困惑。当你在第五章中使用只有两个表的left join时,很容易理解哪个是左表,哪个是右表,因为只有两种可能性。但是现在你连接了三个表,应该怎么理解呢?

要理解多个表的连接,假设 MySQL 在执行查询时构建临时表。MySQL 首先连接前两个表crimelocation,连接的结果成为左表。然后,MySQL 在crime/location合并表和suspect表之间执行left join

你使用了连接进行外连接,因为你希望所有的犯罪和地点都显示出来,无论右侧的suspect表中是否有匹配项。此查询的结果如下:

crime_name                             location_name           suspect_name
-------------------------------------  ----------------------  ---------------
Jaywalking                             Corner of Main and Elm  Eileen Sideways
Larceny: Donut                         Family Donut Shop       Hugo Hefty
Receiving Salad Under False Pretenses  Green Vegan Restaurant  null

最后一宗犯罪的嫌疑人成功逃脱,因此最后一行的suspect_name值为null。如果你使用了内连接,查询就不会返回最后一行,因为内连接只会返回有匹配的行。

你可以利用外连接返回的null值。假设你想编写一个查询,仅显示嫌疑人未知的犯罪。你可以在查询中指定仅显示suspect_namenull的行:

select c.crime_name,
       l.location_name,
       s.suspect_name
from   crime c
join   location l
  on   c.location_id = l.location_id
left join suspect s
  on   c.suspect_id = s.suspect_id
where  **s.suspect_name is null**;

此查询的结果为:

crime_name                             location_name           suspect_name
-------------------------------------  ----------------------  ---------------
Receiving Salad Under False Pretenses  Green Vegan Restaurant  null

在查询的最后一行添加where子句后,结果只显示了那些在suspect表中没有匹配行的记录,这将列表限制为嫌疑人未知的犯罪。

连接多个表

MySQL 允许在连接中使用最多 61 个表,尽管你很少需要编写包含这么多表的查询。如果你发现自己连接了超过 10 个表,这通常是数据库结构需要重设计的信号,以简化查询编写。

wine数据库有六个表,你可以用它们来帮助规划参观酒厂的行程。让我们逐一查看这六个表。

country表存储酒厂所在的国家:

country_id  country_name
----------  ------------
    1       France
    2       Spain
    3       USA

region表存储这些国家中酒厂所在的地区:

region_id  region_name         country_id
---------  -----------         ----------
    1      Napa Valley             3
    2      Walla Walla Valley      3
    3      Texas Hill              3

viticultural_area表存储酒厂所在的葡萄酒种植子区域:

viticultural_area_id  viticultural_area_name  region_id
--------------------  ----------------------  ---------
          1           Atlas Peak                  1
          2           Calistoga                   1
          3           Wild Horse Valley           1

wine_type表存储关于可用葡萄酒种类的信息:

wine_type_id  wine_type_name
------------  ------------------
       1      Chardonnay
       2      Cabernet Sauvignon
       3      Merlot

winery表存储关于酒厂的信息:

winery_id  winery_name            viticultural_area_id  offering_tours_flag
---------  ---------------------  --------------------  -------------------
    1      Silva Vineyards                   1                    0
    2      Chateau Traileur Parc             2                    1
    3      Winosaur Estate                   3                    1

portfolio表存储酒厂酒品组合的信息——也就是酒厂提供哪些酒:

winery_id  wine_type_id  in_season_flag
---------  ------------  --------------
    1            1             1
    1            2             1
    1            3             0
    2            1             1
    2            2             1
    2            3             1
    3            1             1
    3            2             1
    3            3             1

例如,winery_id1(Silva Vineyards)的酒厂提供wine_type_id1(Chardonnay)的酒,这款酒在季节中(其in_season_flag—一个布尔值—为1,表示是真的)。

列表 6-1 展示了一个查询,通过连接所有六个表,找出在美国有季节性梅洛酒并提供酒厂参观的酒厂。

select c.country_name,
       r.region_name,
       v.viticultural_area_name,
       w.winery_name
from   country c
join   region r
  on   c.country_id = r.country_id
 and   c.country_name = 'USA'
join   viticultural_area v
  on   r.region_id = v.region_id
join   winery w
  on   v.viticultural_area_id = w.viticultural_area_id
 and   w.offering_tours_flag is true
join   portfolio p
  on   w.winery_id = p.winery_id
 and   p.in_season_flag is true
join   wine_type t
  on   p.wine_type_id = t.wine_type_id
 and   t.wine_type_name = 'Merlot';

列表 6-1:查询列出季节性梅洛酒的美国酒厂

虽然这个查询比你平时用的要长,但你已经见过大部分的语法。在查询中,你为每个表名创建了表别名(countryregionviticultural_areawineryportfoliowine_type)。当在查询中引用列时,你会在列名之前加上表别名和一个点。例如,你会在offering_tours_flag列之前加上w,因为它在winery表中,所以结果是w.offering_tours_flag。(记住在第四章中提到的最佳实践,应该为包含布尔值(如truefalse)的列添加_flag后缀,这就是offering_tours列的情况,因为酒庄要么提供游览,要么不提供。)最后,你使用join执行内连接,因为在连接这些表时应该有匹配的值。

与我们之前的查询不同,这个查询包含了表之间的连接,其中需要满足多个条件。例如,当你连接countryregion表时,需要满足两个条件:

  • country表中的country_id列的值必须与region表中的country_id列的值匹配。

  • country表中的country_name列必须等于USA

你使用on关键字处理了第一个条件:

from   country c
join   region r
  **on**   c.country_id = r.country_id

然后你使用了and关键字来指定第二个条件:

 **and**   c.country_name = 'USA'

你可以添加更多的and语句来指定你需要的多个连接条件。

查询结果如下所示:清单 6-1:

country_name   region_name     viticultural_area_name   winery_name
------------   -------------   ----------------------   ---------------------
    USA        Napa Valley     Calistoga                Chateau Traileur Parc
    USA        Napa Valley     Wild Horse Valley        Winosaur Estate

关联表

在清单 6-1 中,大部分表格都是直观的:winery表存储酒庄列表,region表存储地区列表,country表存储国家,viticultural_area表存储葡萄种植区(葡萄种植子区域)。

然而,portfolio表略有不同。记住,它存储的是关于哪些酒在每个酒庄的酒单中的信息。这里再次展示:

winery_id  wine_type_id  in_season_flag
---------  ------------  --------------
    1            1             1
    1            2             1
 1            3             0
    2            1             1
    2            2             1
    2            3             1
    3            1             1
    3            2             1
    3            3             1

它的winery_id列是winery表的主键,wine_type_id列是wine_type表的主键。这使得portfolio成为一个关联表,因为它通过引用主键,将存储在其他表中的行相互关联,如图 6-2 所示。

图 6-2:portfolio表是一个关联表。

portfolio 表表示 多对多关系,因为一个酒庄可以生产多种葡萄酒类型,而一种葡萄酒类型可以在多个酒庄生产。例如,酒庄 1(Silva Vineyards)提供多种葡萄酒类型:1(Chardonnay)、2(Cabernet Sauvignon)和 3(Merlot)。葡萄酒类型 1(Chardonnay)由多个酒庄提供:1(Silva Vineyards)、2(Chateau Traileur Parc)和 3(Winosaur Estate)。portfolio 表包含了每个 winery_idwine_type_id 之间的关系,告诉我们哪些酒庄提供哪些葡萄酒类型。作为附加内容,它还包含了 in_season_flag 列,正如你所见,它跟踪该葡萄酒是否在该酒庄的当季生产。

接下来,我们将探讨不同的方式来处理查询返回的数据。我们将从一些简单的选项开始,管理结果集中的数据,然后在本章后半部分介绍一些更复杂的方法。

管理结果集中的数据

有时你需要控制查询返回的数据在结果集中如何显示。例如,你可能希望缩小结果范围或合并多个 select 语句的结果。SQL 提供了关键字来实现这些功能。

limit 关键字

limit 关键字允许你限制结果集中显示的行数。例如,假设有一个名为 best_wine_contest 的表格,记录了一个葡萄酒品鉴比赛的结果,品鉴师为自己最喜欢的葡萄酒投票。如果你查询该表并使用 order byplace 列排序,你将看到排名最好的葡萄酒排在最前面:

select *
from   best_wine_contest
order by place;

结果是:

wine_name     place
------------  -----
Riesling        1
Pinot Grigio    2
Zinfandel       3
Malbec          4
Verdejo         5

如果你只想查看前 3 名的葡萄酒,可以使用 limit 3

select *
from   best_wine_contest
order by place
limit 3;

现在结果是:

wine_name     place
------------  -----
Riesling        1
Pinot Grigio    2
Zinfandel       3

limit 关键字将结果限制为三行。若要查看仅获胜的葡萄酒,你可以使用 limit 1

union 关键字

union 关键字将多个 select 语句的结果合并成一个结果集。例如,以下查询从两个不同的表 wine_typebest_wine_contest 中选择所有葡萄酒类型,并将它们显示在一个列表中:

select wine_type_name from wine_type
union
select wine_name from best_wine_contest;

结果是:

wine_type_name
------------------
Chardonnay
Cabernet Sauvignon
Merlot
Riesling
Pinot Grigio
Zinfandel
Malbec
Verdejo

wine_type 表有一个名为 wine_type_name 的列,其中包括 Chardonnay、Cabernet Sauvignon 和 Merlot。best_wine_contest 表有一个名为 wine_name 的列,其中包括 Riesling、Pinot Grigio、Zinfandel、Malbec 和 Verdejo。使用 union 可以将所有葡萄酒一并显示在一个结果集中。

你只能在每个 select 语句具有相同列数时使用 union。在这个例子中,union 是可行的,因为你在每个 select 语句中都只指定了一个列。结果集中的列名通常来自第一个 select 语句。

union 关键字将从结果集中移除重复值。例如,如果 wine_typebest_wine_contest 表中都有 Merlot,使用 union 将只列出一次 Merlot 的独特酒种。如果你希望看到包含重复值的列表,可以使用 union all

select wine_type_name from wine_type
union all
select wine_name from best_wine_contest;

结果如下:

wine_type_name
------------------
Chardonnay
Cabernet Sauvignon
Merlot
Riesling
Pinot Grigio
Zinfandel
Malbec
Verdejo
Merlot

现在你可以看到 Merlot 被列出了两次。

接下来,你将更深入地探讨如何通过创建类似表格格式的临时结果集来使查询更加高效。

临时表

MySQL 允许你创建临时表——即仅在当前会话中存在并在会话结束时自动删除的临时结果集。例如,你可以使用像 MySQL Workbench 这样的工具创建临时表,然后在该工具中查询该表。然而,如果你关闭并重新打开 MySQL Workbench,临时表将会消失。在一个会话中,你可以多次重用临时表。

你可以像创建常规表一样定义临时表,只是你使用 create temporary table 而不是 create table 语法:

create temporary table wp1
(
    winery_name            varchar(100),
    viticultural_area_id   int
)

wp1 临时表会根据你指定的列名和数据类型创建,但不会包含任何行。

要基于查询结果创建临时表,只需在查询前添加相同的 create temporary table 语法,如 Listing 6-2 所示,结果的临时表将包含从查询中选取的行数据。

 create temporary table winery_portfolio
 select w.winery_name,
        w.viticultural_area_id
 from   winery w
 join   portfolio p
❶  on   w.winery_id = p.winery_id
❷ and   w.offering_tours_flag is true
  and   p.in_season_flag is true
 join   wine_type t
❸  on   p.wine_type_id = t.wine_type_id
❹ and   t.wine_type_name = 'Merlot';

Listing 6-2:创建临时表

在这里,你创建了一个名为 winery_portfolio 的临时表,用来存储来自 Listing 6-1 和 Figure 6-2 的查询结果,该查询将 wineryportfoliowine_type 表连接起来。wineryportfolio 表是基于两个条件连接的:

  • 表中的 winery_id 列的值匹配 ❶。

  • 酒庄提供参观服务。为此,你需要检查 winery 表中的 offering_tours_flag 是否设置为 true ❷。

这些结果根据两个条件与 wine_type 表连接:

  • 表中的 wine_type_id 列的值匹配 ❸。

  • wine_type 表中的 wine_type_nameMerlot ❹。

一旦你创建了临时表,你可以像查询永久表一样查询其内容,通过从中选择数据:

select * from winery_portfolio;

结果如下:

winery_name            viticultural_area_id
---------------------  --------------------
Chateau Traileur Parc          2
Winosaur Estate                3

现在你可以写第二个查询,从 winery_portfolio 临时表中选择数据,并将其与来自 Listing 6-1 的其他三个表连接:

select c.country_name,
       r.region_name,
       v.viticultural_area_name,
 w.winery_name
from   country c
join   region r
  on   c.country_id = r.country_id
 and   c.country_name = 'USA'
join   viticultural_area v
  on   r.region_id = v.region_id
join   **winery_portfolio** w
  on   v.viticultural_area_id = w.viticultural_area_id;

在这里,你将 winery_portfolio 临时表与原始查询中其他三个表(来自 Listing 6-1)连接:countryregionviticultural_area。通过这种方式,你将一个大型的六表查询简化为将三个表的数据隔离到临时表中,然后再将该临时表与另外三个表连接。这条查询返回的结果与 Listing 6-1 相同。

公共表表达式

公共表表达式(CTE),是 MySQL 8.0 版本中引入的一项功能,它是一个临时结果集,你可以为其命名,并像查询表一样从中选择数据。CTE 只能在一个查询的生命周期内使用(与临时表不同,临时表可以在整个会话中使用)。清单 6-3 展示了如何使用 CTE 简化清单 6-1 中的查询:

❶ with winery_portfolio_cte as
(
    select w.winery_name,
           w.viticultural_area_id
    from   winery w
 join   portfolio p
      on   w.winery_id = p.winery_id
     and   w.offering_tours_flag is true
     and   p.in_season_flag is true
    join   wine_type t
      on   p.wine_type_id = t.wine_type_id
     and   t.wine_type_name = 'Merlot'
)
❷ select c.country_name,
       r.region_name,
       v.viticultural_area_name,
       wp.winery_name
from   country c
join   region r
  on   c.country_id = r.country_id
 and   c.country_name = 'USA'
join   viticultural_area v
  on   r.region_id = v.region_id
❸ join   winery_portfolio_cte wp
  on   v.viticultural_area_id = wp.viticultural_area_id;

清单 6-3:命名并查询一个 CTE

首先,你使用with关键字为 CTE 命名;在这里,你为查询结果定义了winery_portfolio_cte这个名称,查询结果位于括号之间 ❶。然后你添加另一个查询 ❷,该查询将winery_portfolio_cte作为表进行连接 ❸。结果与清单 6-1 中的查询相同。

CTE 和临时表都将查询的结果临时保存在类似表的格式中。然而,虽然临时表可以在一个会话中多次使用(即在多个查询中),但 CTE 只能在定义它的查询生命周期内使用。在运行清单 6-3 后,尝试运行另一个查询从winery_portfolio_cte中选择数据:

select * from winery_portfolio_cte;

你会遇到一个错误:

Error Code: 1146\. Table 'wine.winery_portfolio_cte' doesn't exist

MySQL 正在寻找一个名为winery_portfolio_cte,所以它无法找到你的 CTE 也就不足为奇了。此外,CTE 只在查询期间存在,因此它不再可用。

递归公共表表达式

递归是一种当对象引用自身时使用的技术。当我想到递归时,我会想到俄罗斯套娃。你打开最大的一个娃娃,发现里面有一个更小的娃娃;然后你打开那个娃娃,发现里面有一个更小的娃娃;依此类推,直到你找到最小的娃娃。换句话说,要查看所有的娃娃,你从最大的娃娃开始,然后依次打开每个更小的娃娃,直到找到一个不包含其他娃娃的娃娃。

递归在数据以层次结构或一系列值的形式组织时非常有用,在这种情况下,你需要知道前一个值才能得出当前值。

递归 CTE 引用自身。递归 CTE 包含两个select语句,二者之间用union语句连接。看看这个名为borg_scale_cte的递归 CTE,它包含了 6 到 20 之间的一系列数字:

❶ with recursive borg_scale_cte as
(
  ❷ select    6 as current_count
    union
  ❸ select    current_count + 1
    from      borg_scale_cte
  ❹ where     current_count < 20
)
select * from borg_scale_cte;

首先,你定义 CTE 为recursive并命名为borg_scale_cte ❶。然后,第一个select语句返回包含数字6的第一行 ❷。第二个select语句返回所有其他包含720之间的值的行。它不断地将current_count列的值加1,并选择得到的数字 ❸,只要current_count小于20 ❹。

在最后一行,你使用通配符字符*来选择 CTE 中的所有值,返回的结果是:

current_count
-------------
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20

你还可以像使用表一样使用递归 CTE,并将其与其他表连接,例如。

派生表

派生表是创建仅供查询使用的结果表的替代方案。创建派生表的 SQL 放在括号内:

select   wot.winery_name,
         t.wine_type_name
from     portfolio p
join wine_type t
on       p.wine_type_id = t.wine_type_id
join (
     select *
     from   winery
     where  offering_tours_flag is true
     ) wot
On       p.winery_id = wot.winery_id;

括号中的查询生成一个别名为wot(即提供旅游的酒厂)的派生表。你可以像对待其他表一样将wotportfoliowine_type表连接,并从中选择列。与 CTE 类似,派生表仅在查询执行期间可用。

选择使用派生表而不是 CTE 通常是风格问题。一些开发者更喜欢使用 CTE,因为他们认为 CTE 更具可读性。然而,如果你需要使用递归,就必须使用 CTE。

子查询

子查询(或内查询)是嵌套在另一个查询中的查询。子查询用于返回主查询将要使用的数据。当查询中包含子查询时,MySQL 首先执行子查询,从数据库中选择结果值,然后将其返回给外部查询。例如,这条 SQL 语句使用子查询从wine数据库中返回美国所有葡萄酒种植区的列表:

❶ select region_name
from   region
where  country_id =
(
  ❷ select country_id
    from   country
    where  country_name = 'USA'
);

这个查询的结果如下:

region_name
------------------
Napa Valley
Walla Walla Valley
Texas Hill

这个查询有两个部分:外部查询❶和子查询❷。尝试单独运行子查询,而不带外部查询:

 select country_id
    from   country
    where  country_name = 'USA';

结果显示,返回的美国country_id3

country_id
----------
     3

在你的查询中,3从子查询传递到外部查询,这使得整个 SQL 语句的结果为:

select region_name
from   region
where  country_id = 3;

这会返回country_id 3(即美国)地区的列表:

region_name
------------------
Napa Valley
Walla Walla Valley
Texas Hill

返回多行的子查询

子查询可以返回多行数据。以下是与之前相同的查询,这次包含了所有国家,而不仅仅是美国:

select region_name
from   region
❶ where  country_id =
(
    select country_id
    from   country
❷ --  where  country_name = 'USA' -  line commented out
);

现在,注释掉了指定只获取美国地区的子查询行❷,因此所有国家的country_id都会被返回。当你运行这个查询时,MySQL 返回一个错误,而不是地区列表:

Error Code: 1242\. Subquery returns more than 1 row

问题在于外部查询期望只返回一行数据,因为你使用了=语法❶。然而,子查询返回了三行数据:美国的country_id3,法国为1,西班牙为2。你应该只在子查询不可能返回多行数据时使用=

这是一个常见的错误,你应该注意。有很多开发者写了一个查询,当他们测试时运行正常,但突然有一天它开始出现子查询返回多于一行的错误。查询本身没有变化(不像这个例子中,某行已被注释掉),但是数据库中的数据发生了变化。例如,可能往表中添加了新行,导致开发者的子查询现在返回多行,而以前返回的是一行。

如果你想编写一个可以从子查询返回多行的查询,可以使用in关键字替代=

select region_name
from   region
where  country_id **in**
(
    select country_id
    from   country
--  where  country_name = 'USA' -  line commented out
);

现在你已将 = 替换为 in,外部查询可以接受子查询返回的多行结果,而不会出错,并且你将得到所有国家的地区列表。

关联子查询

在关联子查询中,子查询中的表列与外部查询中的表列进行连接。

让我们看看 pay 数据库中的两个表:best_paidemployeebest_paid 表显示,销售部门的最高薪资是 $200,000,制造部门的最高薪资是 $80,000:

department      salary
----------      ------
Sales           200000
Manufacturing    80000

employee 表存储了员工列表、他们的部门和薪资:

employee_name   department      salary
--------------  --------------  ------
Wanda Wealthy   Sales           200000
Paul Poor       Sales            12000
Mike Mediocre   Sales            70000
Betty Builder   Manufacturing    80000
Sean Soldering  Manufacturing    80000
Ann Assembly    Manufacturing    65000

你可以使用关联子查询来查找每个部门薪水最高的员工:

select employee_name,
       salary
from   employee e
where  salary =
       (
       select  b.salary
       from    best_paid b
       where   b.department = e.department
       );

在外部查询中,你从 employee 表中选择员工和薪资。在子查询中,你将外部查询的结果与 best_paid 表连接,以确定该员工是否在其部门中拥有最高薪资。

结果是:

employee_name   salary
--------------  ------
Wanda Wealthy   200000
Betty Builder    80000
Sean Soldering   80000

结果显示,Wanda 是销售部门薪水最高的员工,而 Betty 和 Sean 在制造部门并列最高薪资。

总结

在这一章中,你编写了使用多个表的复杂 SQL 语句。你了解了如何限制或合并结果行,并探索了多种方式来编写查询,将结果集当作表来使用。

在下一章中,你将比较查询中的值;例如,你将检查一个值是否大于另一个值,比较不同数据类型的值,并检查某个值是否匹配某个模式。

第七章:比较值

本章讨论了在 MySQL 中比较值。你将练习检查值是否相等,某个值是否大于或小于另一个值,值是否在特定范围内,或者是否匹配模式。你还将学习如何检查查询中至少满足一个条件。

在多种场景下,比较值非常有用。例如,你可能想要检查员工是否工作了 40 小时或更多,航班状态是否未取消,或者度假目的地的平均温度是否在 70 到 95 华氏度之间。

比较运算符

你可以使用 MySQL 的比较运算符(见表 7-1)来比较查询中的值。

表 7-1:MySQL 比较运算符

符号或关键字 描述
= 相等
!=, <> 不等于
> 大于
>= 大于或等于
< 小于
<= 小于或等于
is null 空值
is not null 非空值
in 匹配列表中的值
not in 不匹配列表中的值
between 在范围内
not between 不在范围内
like 匹配模式
not like 不匹配模式

这些运算符让你能够将数据库中的值与其他值进行比较。如果某些数据符合你使用这些比较运算符定义的标准,你可以选择将其选出来。我们将深入讨论它们,并以不同的数据库为例。

相等

等号运算符,在第五章中介绍过,可以检查值是否相等以实现特定的结果。例如,这里你使用=与第六章中的wine数据库表:

select  *
from    country
where   **country_id = 3**;

该查询从country表中选择所有country_id等于3的国家。

在以下查询中,你使用=与字符串而不是数字进行比较:

select  *
from    wine_type
where   **wine_type_name = 'Merlot'**;

该查询从wine_type表中选择所有名称为 Merlot 的葡萄酒,即wine_type_name等于Merlot

以下查询类似于你在第五章中学习如何连接两个表时看到的内容。这里你使用=来比较来自两个表的具有共同列名的值:

select  c.country_name
from    country c
join    region r
  on    **c.country_id = r.country_id**;

该查询连接了regioncountry表中country_id列的所有相等值。

在这些例子中,=语法检查运算符左侧的值是否与右侧的值相同。你还可以将=与返回一行的子查询一起使用:

select *
from   region
where  **country_id =**
**(**
 **select country_id**
 **from   country**
 **where  country_name = 'USA'**
**);**

通过这种方式使用=,你在外部查询中检查region表的country_id列是否与整个子查询的结果匹配。

不相等

不等于使用 <>!= 符号表示,其中 < 符号表示 小于> 符号表示 大于(所以 <> 意味着小于或大于),而 ! 符号表示 (所以 != 意味着不等于)。!=<> 操作符执行相同的操作,因此使用哪种语法都可以。

不等于操作符对于排除某些数据非常有用。例如,也许你是一个班卓琴演奏者,正在寻找志同道合的音乐人组建乐队。因为你弹奏班卓琴,你可以从你想查看的乐器列表中排除它:

select  *
from    musical_instrument
where   **instrument != 'banjo'**;

在这里,你在 musical_instrument 表上使用了不等于操作符,排除了班卓琴在返回的乐器列表中。

假设你正在计划一场婚礼,并且在 2024 年 2 月 11 日有一个先前的安排,所以你需要排除这个日期:

select  *
from    possible_wedding_date
where   **wedding_date <> '2024-02-11'**;

现在你已经从 possible_wedding_date 表中排除了 2024 年 2 月 11 日作为潜在婚礼日期。

大于

大于操作符检查左侧的值是否大于右侧的值。它使用 > 符号表示。假设你正在寻找那些 salary 大于 100,000 美元且 start_date 在 2024 年 1 月 20 日之后的工作,你可以使用以下查询从 job 表中选择符合这些条件的工作:

select  *
from    job
where   **salary > 100000**
and     **start_date > '2024-01-20'**;

在这个查询中,只有满足两个条件的工作才会被返回。

大于或等于

大于或等于使用 >= 符号表示。例如,你可以编辑之前的查询,选择所有 salary 为 100,000 美元或更高且 start_date 为 2024 年 1 月 20 日或之后的工作:

select  *
from    job
where   **salary >= 100000**
and     **start_date >= '2024-01-20'**;

>>= 之间的区别在于,>= 会将列出的值包含在其结果中。在前面的示例中,>= 会返回 salary恰好 100,000 美元的工作,但 > 不会返回此类工作。

小于

小于使用 < 符号表示。例如,要查看所有在晚上 10 点之前开始的比赛,你可以执行以下查询:

select *
from   team_schedule
where  **game_time < '22:00'**;

在 MySQL 中,时间是以军用格式表示的,使用 24 小时制。

小于或等于

小于或等于 使用 <= 符号表示。你可以扩展之前的查询,选择所有 game_time 为晚上 10 点或更早的行:

select *
from   team_schedule
where  **game_time <= '22:00'**;

如果 game_time 恰好为 22:00(晚上 10 点),当你使用 <= 时将返回该行,但使用 < 时则不会返回。

is null

如第二章和第三章所讨论的,null 是一个特殊值,表示数据不可用或不适用。is null 语法允许你指定只返回 null 值的记录。例如,假设你想查询 employee 表,查看那些没有退休或没有设置退休日期的员工:

select  *
from    employee
where   **retirement_date** **is null**;

现在只返回那些 retirement_datenull 的行:

emp_name   retirement_date
--------   ---------------
Nancy      null
Chuck      null
Mitch      null

只有使用 is null 比较操作符才能检查 null 值。例如,使用 = null 是无效的:

select *
from   employee
where  retirement_date = null;

即使表中有 null 值,这个语法也不会返回任何行。在这种情况下,MySQL 不会抛出错误,因此你可能没有意识到返回的是错误的数据。

is not null

你可以使用 is not null 来检查值是否不是 null。尝试反转之前示例的逻辑,检查已经退休或设定退休日期的员工:

select *
from   employee
where  **retirement_date** **is not null**;

现在,查询返回 retirement_date 不为 null 的行:

emp_name   retirement_date
--------   ---------------
Alfred     2034-01-08
Latasha    2029-11-17

is null 一样,你必须使用 is not null 语法进行此类查询。使用其他语法,如 != null<> null,将不会产生正确的结果:

select *
from   employee
where  retirement_date != null;

正如你之前看到的使用 = null,当你尝试使用 != null 语法时,MySQL 不会返回任何行,也不会给出错误提示。

in

你可以使用 in 关键字指定一个多个值的列表,以便查询返回这些值。例如,让我们重新查看 wine 数据库,返回 wine_type 表中特定的酒:

select  *
from    wine_type
where   **wine_type_name in ('Chardonnay', 'Riesling')**;

这将返回 wine_type_name 为 Chardonnay 或 Riesling 的行。

你还可以使用 in 和子查询,从另一个表中选择一组酒类类型:

select  *
from    wine_type
where   **wine_type_name in**
 **(**
 **select  wine_type_name**
 **from    cheap_wine**
 **)**;

你可以选择不提供硬编码的酒类类型列表,而是从 cheap_wine 表中选择所有酒类类型。

not in

要反转前一个示例的逻辑并排除某些酒类类型,你可以使用 not in

select  *
from    wine_type
where   **wine_type_name not in ('Chardonnay', 'Riesling')**;

这将返回所有 wine_type_name 不是 Chardonnay 或 Riesling 的行。

要选择不在 cheap_wine 表中的酒,你可以使用 not in 与子查询,如下所示:

select  *
from    wine_type
where   **wine_type_name not in**
 **(**
 **select  wine_type_name**
 **from    cheap_wine**
 **)**;

这个查询排除了 cheap_wine 表中的酒类类型。

between

你可以使用 between 运算符检查某个值是否在指定范围内。例如,要列出 customer 表中的千禧一代,可以查找出生在 1981 年到 1996 年之间的人:

select  *
from    customer
where   **birthyear between 1981 and 1996**;

between 关键字是包含的。这意味着它会检查范围内的每个 birthyear包括1981 年和 1996 年。

not between

你可以使用 not between 运算符检查某个值是否不在范围内。使用之前示例中的相同表,找到不是千禧一代的客户:

select  *
from    customer
where   **birthyear not between 1981 and 1996**;

not between 运算符返回的客户列表与 between 返回的相反,并且是不包含的。1981 年或 1996 年出生的客户将被此查询排除,因为他们属于 between 1981 and 1996 组。

like

like 运算符允许你检查一个字符串是否匹配某种模式。例如,你可以使用 like 来查找来自 No Starch Press 的书籍,检查书籍的 ISBN 是否包含 No Starch 出版商代码 59327。

要指定匹配的模式,你可以使用两个通配符字符之一与 like 运算符:百分号(%)或下划线(_)。

百分号字符

百分号通配符字符可以匹配任意数量的字符。例如,要返回姓氏以字母 M 开头的亿万富翁列表,你可以使用 % 通配符字符与 like 一起使用:

select  *
from    billionaire
where   **last_name** **like 'M%'**;

你的查询将找到姓氏以M开头,后面跟着零个或多个其他字符的亿万富翁。这意味着like 'M%'只会匹配字母M后没有字符,或者M后跟着几个字符(比如Musk),或者M后跟着很多字符(比如Melnichenko)。你的查询结果可能如下所示:

first_name   last_name
----------   ---------
Elon         Musk
Jacqueline   Mars
John         Mars
Andrey       Melnichenko

你可以使用两个%字符来查找位于字符串中任何位置的字符,无论是在开头、中间还是结尾。例如,以下查询查找姓氏中包含字母e的亿万富翁:

select  *
from    billionaire
where   **last_name like '%e%'**;

结果可能如下所示:

first_name   last_name
----------   ---------
Jeff         Bezos
Bill         Gates
Mark         Zuckerberg
Andrey       Melnichenko

虽然语法last_name like '%e%'很方便,但它可能导致查询的运行速度比正常情况更慢。这是因为当你在搜索模式的开头使用%通配符时,MySQL 无法利用last_name列上的任何索引。(记住,索引帮助 MySQL 优化查询;如果需要复习,请参阅第二章中的“索引”部分。)

_ 字符

下划线通配符字符匹配任何单个字符。例如,假设你需要找到一个联系人,但你不记得她的名字是 Jan 还是 Jen。你可以写一个查询,选择以J开头,后面跟着通配符字符,再后面跟着n的名字。

这里你使用下划线通配符来返回以at结尾的三字母词汇列表:

select  *
from    three_letter_term
where   **term** **like '_at';**

结果可能如下所示:

term
----
cat
hat
bat

not like

not like运算符可以用于查找不匹配某个模式的字符串。它也使用%_通配符字符。例如,要反转like示例的逻辑,输入以下查询:

select  *
from    three_letter_term
where   **term** **not like '_at'**;

结果是three_letter_term表中不以at结尾的单词:

term
----
dog
egg
ape

类似地,你可以使用以下查询找到那些姓氏不以字母M开头的亿万富翁:

select  *
from    billionaire
where   **last_name** **not like 'M%'**;

结果可能如下所示:

first_name   last_name
----------   ---------
Jeff         Bezos
Bill         Gates
Mark         Zuckerberg

exists

exists运算符用于检查子查询是否返回至少一行数据。在这里,你回到customer表中的not between示例,并使用exists来检查该表是否至少包含一个千禧一代:

select 'There is at least one millennial in this table'
where **exists**
**(**
 **select  ***
 **from    customer**
 **where   birthyear between 1981 and 1996**
**)**;

customer表中有千禧一代,所以你的结果是:

There is at least one millennial in this table

如果 1981 年到 1996 年之间没有出生的客户,你的查询将不会返回任何行,并且There is at least one millennial in this table的文本也不会显示。

你可能会看到同一个查询使用select 1代替子查询中的select *

select 'There is at least one millennial in this table'
where exists
(
    select  1
    from    customer
    where   birthyear between 1981 and 1996
);

在这个查询中,选择*还是1并不重要,因为你只关心至少有一个客户符合你的描述。你真正关心的是内部查询返回了某些东西

检查布尔值

在第四章中,你学习了布尔值只有两种可能的值:truefalse。你可以使用特殊语法is trueis false,只返回符合某个值的结果。在这个例子中,你通过在employed_flag列中使用is true语法,返回了bachelor表中所有已就业的学士学位持有者:

select  *
from    bachelor
where   **employed_flag is true**;

这个查询使 MySQL 只返回已就业的学士学位持有者的行。

要检查employed_flag值为false的学士学位持有者,可以使用is false

select  *
from    bachelor
where   **employed_flag is false**;

现在 MySQL 只返回失业的学士学位持有者的行。

你也可以用其他方式检查布尔列的值。这些行是检查true值的等效方式:

employed_flag is true
employed_flag
employed_flag = true
employed_flag != false
employed_flag = 1
employed_flag != 0

以下几行是检查false值的等效方式:

employed_flag is false
not employed_flag
employed_flag = false
employed_flag != true
employed_flag = 0
employed_flag != 1

如你所见,1的值等同于true,而0的值等同于false

或条件

你可以使用 MySQL 的or关键字来检查是否满足两个条件中的至少一个。

考虑一下名为applicant的表格,它包含了关于求职者的信息。

name          associates_degree_flag  bachelors_degree_flag  years_experience
------------  ----------------------  ---------------------  ----------------
Joe Smith               0                       1                   7
Linda Jones             1                       0                   2
Bill Wang               0                       1                   1
Sally Gooden            1                       0                   0
Katy Daly               0                       0                   0

associates_degree_flagbachelors_degree_flag列是布尔值,其中0表示false1表示true

在以下查询中,你从applicant表中选择,得到一个符合要求的求职者名单,该工作要求有学士学位两年或以上的工作经验:

select  *
from    applicant
where   **bachelors_degree_flag is true**
**or      years_experience >= 2;**

结果如下:

name          associates_degree_flag  bachelors_degree_flag  years_experience
------------  ----------------------  ---------------------  ----------------
Joe Smith               0                       1                   7
Linda Jones             1                       0                   2
Bill Wang               0                       1                   1

假设你需要编写一个查询,包含and(两个条件都必须满足)和or(其中一个条件满足即可)关键字。在这种情况下,你可以使用括号将条件分组,以便 MySQL 返回正确的结果。

让我们看看使用括号如何带来好处。在这里,你为需要求职者拥有两年或以上的工作经验并且有副学士学位学士学位的新职位创建了另一个查询,查询的是applicant表:

select  *
from    applicant
**where   years_experience >= 2**
**and     associates_degree_flag is true**
**or      bachelors_degree_flag is true;**

这个查询的结果并非你预期的:

name          associates_degree_flag  bachelors_degree_flag  years_experience
------------  ----------------------  ---------------------  ----------------
Joe Smith               0                      1                   7
Linda Jones             1                      0                   2
Bill Wang               0                      1                   1

Bill 没有两年或以上的工作经验,那为什么他出现在你的结果集中?

查询同时使用了andorand运算符优先级高于or,这意味着and会在or之前进行计算。这导致你的查询找到了满足以下两个条件中至少一个的求职者:

  • 两年或以上的工作经验以及副学士学位

  • 学士学位

这不是你编写查询时的意图。你可以通过使用括号将条件分组来修正问题:

select  *
from    applicant
where   years_experience >= 2
and     (
        associates_degree_flag is true
or      bachelors_degree_flag is true
        );

现在查询找到了符合这些条件的求职者:

  • 两年或以上的工作经验

  • 副学士学位学士学位

现在你的结果应该与你的预期一致:

name          associates_degree_flag  bachelors_degree_flag  years_experience
------------  ----------------------  ---------------------  ----------------
Joe Smith               0                      1                   7
Linda Jones             1                      0                   2

总结

在本章中,你学习了通过比较运算符在 MySQL 中比较值的多种方式,比如检查值是否相等、是否为 null、是否在某个范围内,或是否匹配某个模式。你还学习了如何在查询中检查是否满足至少一个条件。

在下一章中,你将了解如何使用 MySQL 的内置函数,包括处理数学、日期和字符串的函数。你还将学习聚合函数以及如何在一组值中使用它们。

第八章:调用内建 MySQL 函数

MySQL 有数百个预写的函数,执行各种任务。在本章中,您将回顾一些常见的函数,并学习如何从查询中调用它们。您将使用聚合函数,这些函数基于数据库中许多行数据返回一个单一的值汇总,以及帮助执行数学计算、处理字符串、处理日期等的函数。

在第十一章中,您将学习如何创建自己的函数,但目前您将专注于调用 MySQL 最有用的内建函数。关于所有内建函数的最新列表,最好的来源是 MySQL 参考手册。在线搜索“MySQL 内建函数与操作符参考”,并将网页添加到您的浏览器书签中。

什么是函数?

函数 是一组已保存的 SQL 语句,用于执行某些任务并返回一个值。例如,pi() 函数确定圆周率的值并返回它。以下是一个调用 pi() 函数的简单查询:

select pi();

到目前为止,您看到的大多数查询都包含 from 子句,指定要使用的表。在这个查询中,您并没有从任何表中选择数据,因此您可以在没有 from 的情况下调用该函数。它返回以下结果:

pi()
----------
3.141593

对于这种常见任务,使用 MySQL 的内建函数比每次都记住该值更为合理。

向函数传递参数

正如您刚刚看到的,函数返回一个值。有些函数还允许您传递值给它们。当您调用函数时,可以指定它应该使用的值。传递给函数的值称为 参数

为了了解参数是如何工作的,您将调用 upper() 函数,该函数允许您接受一个参数:一个字符串值。该函数确定该字符串的大写等效形式并返回它。以下查询调用 upper() 并指定一个参数为文本 rofl

select upper('rofl');

结果如下:

upper('rofl')
--------------
ROFL

该函数将每个字母转换为大写并返回 ROFL

在某些函数中,您可以指定多个参数。例如,datediff() 允许您指定两个日期作为参数,然后返回它们之间的天数差异。在这里,您调用 datediff() 来查找 2024 年圣诞节与感恩节之间有多少天:

select datediff('2024-12-25', '2024-11-28');

结果是:

datediff('2024-12-25', '2024-11-28')
27

当您调用 datediff() 函数时,您指定了两个参数,圣诞节的日期和感恩节的日期,并用逗号将它们分开。该函数计算出天数差并返回该值(27)。

函数接受不同数量和类型的值。例如,upper() 接受一个字符串值,而 datediff() 接受两个 date 类型的值。正如您将在本章中看到的,其他函数接受整数、布尔值或其他数据类型的值。

可选参数

一些函数接受一个可选参数,在调用函数时,你可以提供另一个值来获取更具体的结果。例如,round()函数,它用于四舍五入小数,接受一个必须提供的参数和一个可选的第二个参数。如果你只传入一个要四舍五入的数字作为唯一参数,它会将数字四舍五入到零位。尝试使用2.71828作为唯一参数调用round()函数:

select round(2.71828);

round()函数将返回四舍五入后的数字,去掉小数点后的位数:

round(2.71828)
--------------
       3

如果你为round()提供了可选参数,你可以指定四舍五入小数点后保留的位数。尝试使用2.71828作为第一个参数,2作为第二个参数,并用逗号分隔参数调用round()

select round(2.71828, 2);

现在结果是:

round(2.71828)
--------------
      2.72

这次,round()返回一个四舍五入到小数点后两位的数字。

在函数内调用函数

你可以通过包装或嵌套函数来将一个函数的结果用作另一个函数的参数。

假设你想获取 pi 的四舍五入值。你可以将对pi()函数的调用包装在对round()函数的调用中:

select round(pi());

结果是:

round(pi())
-----------
     3

最内层的函数首先执行,结果传递给外层函数。对pi()函数的调用返回3.141593,该值作为参数传递给round()函数,round()返回3

你可以修改查询,通过指定round()函数可选的第二个参数,将 pi 四舍五入到两位小数,如下所示:

select round(pi(), 2);

结果是:

round(pi(), 2)
-------------
     3.14

pi()函数的调用返回3.141593,这个值作为函数的第一个参数传递给round()。这个语句的计算为round(3.141593,2),返回3.14

从查询的不同部分调用函数

你可以在查询的select列表中调用函数,也可以在where子句中调用。例如,看看movie表,它包含以下关于电影的数据:

movie_name         star_rating  release_date
-----------------  -----------  ------------
Exciting Thriller  4.72         2024-09-27
Bad Comedy         1.2          2025-01-02
OK Horror          3.1789       2024-10-01

star_rating列保存了观众给电影打的平均星级,评分范围是 1 到 5。你被要求写一个查询,显示评分超过 3 星且发布年份为 2024 年的电影。你还需要将电影名称转换为大写并四舍五入星级评分:

select upper(movie_name),
       round(star_rating)
from   movie
where  star_rating > 3
and    year(release_date) = 2024;

首先,在查询的select列表中,你使用upper()round()函数。你将电影名称值包裹在upper()函数中,并将星级评分值包裹在round()函数中。然后你指定从movie表中提取数据。

where子句中,你调用year()函数并指定一个参数:movie表中的release_dateyear()函数返回电影的发布年份,你将其与2024进行比较(=),从而只显示发布年份为 2024 年的电影。

结果是:

upper(movie_name)  round(star_rating)
-----------------  ------------------
EXCITING THRILLER           5
OK HORROR                   3

聚合函数

聚合函数是一种基于数据库中多个值返回单一值的函数类型。常见的聚合函数包括count()max()min()sum()avg()。在本节中,你将看到如何使用以下continent表来调用这些函数:

continent_id  continent_name  population
------------  --------------  ----------
1             Asia            4641054775
2             Africa          1340598147
3             Europe          747636026
4             North America   592072212
5             South America   430759766
6             Australia       43111704
7             Antarctica      0

count()

count()函数返回查询结果中的行数,可以帮助回答有关数据的问题,例如“你有多少顾客?”或“你今年收到了多少投诉?”

你可以使用count()函数来确定continent表中有多少行,像这样:

select  count(*)
from    continent;

当你调用count()函数时,你在括号中使用星号(或通配符)来计算所有行。星号选择表中的所有行,包括每行的所有列值。

结果是:

count(*)
--------
   7

使用where子句选择所有人口超过 10 亿的大陆:

select  count(*)
from    continent
where   population > 1000000000;

结果是:

count(*)
--------
   2

查询返回2,因为只有亚洲和非洲这两个大陆的人口超过了 10 亿。

max()

max()函数返回一组值中的最大值,可以帮助回答诸如“最高的年度通货膨胀率是多少?”或“哪个销售员本月卖出了最多的车?”等问题。

这里你使用max()函数来查找表中任何大陆的最大人口:

select max(population)
from   continent;

结果是:

max(population)
---------------
  4641054775

当你调用max()函数时,它返回人口最多的大陆的居民数。表中人口最多的大陆是亚洲,人口为 4,641,054,775。

max()这样的聚合函数在子查询中尤其有用。暂时离开continent表,将注意力转向train表:

train            mile
---------------  ----
The Chief        8000
Flying Scotsman  6500
Golden Arrow     2133

这里你将使用max()来帮助确定train表中行驶里程最多的火车:

select   *
from     train
where    mile =
(
  select max(mile)
  from   train
);

在内部查询中,你选择表中任何火车所行驶的最大里程数。在外部查询中,你显示所有行驶了该里程数的火车的列。

结果是:

train_name  mile
----------  ----
The Chief   8000

min()

min()函数返回一组值中的最小值,可以帮助回答诸如“市区汽油的最低价格是多少?”或“哪种金属的熔点最低?”等问题。

让我们回到continent表。使用min()函数来查找人口最少的大陆:

select min(population)
from   continent;

当你调用min()函数时,它返回表中的最小人口值:

min(population)
---------------
       0

表中人口最少的行是南极洲,人口为0

sum()

sum()函数计算一组数字的总和,并帮助回答诸如“中国有多少辆自行车?”或“你今年的总销售额是多少?”等问题。

使用sum()函数来计算所有大陆的总人口,像这样:

select sum(population)
from   continent;

当你调用sum()函数时,它返回所有大陆人口的总和。

结果是:

max(population)
---------------
   7795232630

avg()

avg()函数根据一组数字返回平均值,能够帮助回答“威斯康星州的平均降雪量是多少?”或者“医生的平均薪水是多少?”等问题。

使用avg()函数来找出大洲的平均人口:

select avg(population)
from   continent;

当你调用avg()函数时,它会返回表中大洲的平均人口值:

avg(population)
---------------
1113604661.4286

MySQL 通过将每个大洲的人口总和(7,795,232,630)除以大洲数量(7)得出了 1,113,604,661.4286。

现在,使用avg()函数在子查询中显示人口少于平均大洲人口的所有大洲:

select    *
from      continent
where     population <
(
  select  avg(population)
  from    continent
);

内部查询选择所有大洲的平均人口数量:1,113,604,661.4286 人。外部查询选择人口少于该值的大洲的所有列。

结果是:

continent_id  continent_name  population
------------  --------------  ----------
     3        Europe           747636026
     4        North America    592072212
     5        South America    430759766
     6        Australia         43111704
     7        Antarctica               0

group by

group by子句告诉 MySQL 你希望如何对结果进行分组,并且只能在包含聚合函数的查询中使用。要查看group by如何工作,可以查看sale表,它存储了公司的销售记录:

sale_id  customer_name  salesperson  amount
-------  -------------  -----------  ------
1        Bill McKenna   Sally        12.34
2        Carlos Souza   Sally        28.28
3        Bill McKenna   Tom           9.72
4        Bill McKenna   Sally        17.54
5        Jane Bird      Tom          34.44

你可以使用sum()聚合函数来添加销售金额,但你是想计算所有销售的总额,按客户汇总金额,按销售员汇总金额,还是计算每个销售员向每个客户销售的总额?

要显示按客户汇总的金额,你需要在customer_name列上使用group by,如示例 8-1 所示。

select sum(amount)
from   sale
group by customer_name;

示例 8-1:按客户汇总金额的查询

结果如下:

sum(amount)
-----------
      39.60
      28.28
      34.44

客户 Bill McKenna 消费的总金额为$39.60;Carlos Souza 为$28.28;Jane Bird 为$34.44。结果按客户的名字字母顺序排序。

另外,你可能想查看每个销售员的汇总金额。示例 8-2 展示了如何在salesperson_name列上使用group by

select sum(amount)
from   sale
group by salesperson_name;

示例 8-2:按销售员汇总金额的查询

你的结果是:

sum(amount)
-----------
      58.16
      44.16

Sally 的总销售额为$58.16,Tom 的为$44.16。

因为sum()是一个聚合函数,它可以对任意数量的行进行操作,并返回一个值。group by语句告诉 MySQL 你希望sum()作用于哪些行,因此语法group by salesperson_name会对每个销售员的金额进行求和。

假设你只想查看一行,其中包含表中所有amount的总和。在这种情况下,你不需要使用group by,因为你并不是按任何分组来求和。你的查询应该如下所示:

select  sum(amount)
from    sale;

结果应为:

sum(amount)
-----------
     102.32

group by子句适用于所有聚合函数。例如,你可以将group bycount()一起使用,返回每个销售员的销售数量,如示例 8-3 所示。

select count(*)
from   sale
group by salesperson_name;

示例 8-3:按销售员统计行数的查询

结果是:

count(*)
--------
   3
   2

查询统计了sales表中 Sally 的三行和 Tom 的两行。

或者,你可以使用avg()来获取平均销售额,并根据salesperson_name进行分组,返回每个销售人员的平均销售额,如清单 8-4 所示。

select   avg(amount)
from     sale
group by salesperson_name;

清单 8-4:获取每个销售人员平均销售额的查询

结果是:

avg(amount)
-----------
  19.386667
  22.080000

结果显示,Sally 每笔销售的平均金额为$19.386667,而 Tom 每笔销售的平均金额为$22.08。

然而,查看这些结果时,尚不清楚哪个销售人员的平均值是$19.386667,哪个销售人员的是$22.08。为了澄清这一点,让我们修改查询以显示更多的信息。在清单 8-5 中,你也选择了销售人员的名字。

select   salesperson_name,
         avg(amount)
from     sale
group by salesperson_name;

清单 8-5:显示销售人员姓名及其平均销售额的查询

修改后的查询结果是:

salesperson_name  avg(amount)
----------------  -----------
Sally               19.386667
Tom                 22.080000

你的平均值显示了相同的数值,但现在销售人员的名字也显示在其旁边。添加这些额外的信息使得结果更加易于理解。

在你编写了多个使用聚合函数和group by的查询后,你可能会注意到,你通常会对查询中选择的相同列进行分组。例如,在清单 8-5 中,你选择了salesperson_name列,并且也根据salesperson_name列进行了分组。

为了帮助你确定应分组的列,查看选择列表,即查询中selectfrom之间的部分。选择列表包含你希望从数据库表中选择的项;你几乎总是希望按这个相同的列表进行分组。选择列表中唯一不应该出现在group by语句中的部分是调用的聚合函数。

例如,看看这个 theme_park 表,它包含了六个不同主题公园的数据,包括它们的国家、州以及所在城市:

country  state           city                park
-------  ------------    ------------------  -----------------
USA      Florida         Orlando             Disney World
USA      Florida         Orlando             Universal Studios
USA      Florida         Orlando             SeaWorld
USA      Florida         Tampa               Busch Gardens
Brazil   Santa Catarina  Balneario Camboriu  Unipraias Park
Brazil   Santa Catarina  Florianopolis       Show Water Park

假设你想选择国家、州以及这些国家和州的公园数量。你可能会开始像这样编写 SQL 语句:

select country,
       state,
       count(*)
from   theme_park;

然而,这个查询是不完整的,运行它会返回错误信息或错误的结果,具体取决于你的配置设置。

你应该对所有选中的非聚合函数列进行分组。在这个查询中,你选择的列countrystate不是聚合函数,所以你将使用group by来对它们进行分组:

select   country,
         state,
         count(*)
from     theme_park
**group by country,**
 **state;**

结果如下:

country state           count(*)
------  --------------  --------
USA     Florida            4
Brazil  Santa Catarina     2

如你所见,查询现在返回了正确的结果。

字符串函数

MySQL 提供了多个函数来帮助你处理字符字符串,执行诸如比较、格式化和组合字符串等任务。让我们来看看一些最有用的字符串函数。

concat()

concat() 函数连接两个或更多字符串。例如,假设你有以下的 phone_book 表:

first_name  last_name
----------  ----------
Jennifer    Perez
Richard     Johnson
John        Moore

你可以编写一个查询,将名字和姓氏一起显示,并用空格字符分隔:

select  concat(first_name, ' ', last_name)
from    phone_book;

结果应如下所示:

concat(first_name, ' ', last_name)
----------------------------------
Jennifer Perez
Richard Johnson
John Moore

名字作为一个字符串显示,以空格分隔。

format()

format() 函数通过添加逗号和显示所请求的小数位数来格式化数字。例如,我们重新访问 continent 表并选择亚洲人口,如下所示:

select  population
from    continent
where   continent_name = 'Asia';

结果是:

population
----------
4641054775

很难判断亚洲人口是约 46 亿人还是 4.64 亿人。为了使结果更易读,你可以使用 format() 函数为 population 列添加逗号格式,如下所示:

select format(population, 0)
from   continent;

format() 函数接受两个参数:一个数字来格式化和显示小数点后位数的数量。你使用了两个参数调用 format()population 列和数字 0

现在 population 列已经用逗号格式化,结果中清楚地显示亚洲大约有 46 亿人口:

population
-------------
4,641,054,775

现在调用 format() 函数将数字 1234567.89 格式化为小数点后五位:

select format(1234567.89, 5);

结果是:

format(1234567.89, 5)
---------------------
    1,234,567.89000

format() 函数接受 1234567.89 作为第一个参数中的数字进行格式化,添加逗号,并且添加尾随零,使得结果显示五位小数。

left()

left() 函数从值的左侧返回若干字符。考虑以下 taxpayer 表:

last_name  soc_sec_no
---------  ------------
Jagger     478-555-7598
McCartney  478-555-1974
Hendrix    478-555-3555

要从 taxpayer 表中选择姓氏,并且还要选择 last_name 列的前三个字符,你可以写如下查询:

select  last_name,
        left(last_name, 3)
from    taxpayer;

结果是:

last_name   left(last_name, 3)
----------  -----------------
Jagger      Jag
McCartney   McC
Hendrix     Hen

left() 函数在你想忽略右侧字符的情况下非常有用。

right() 函数从值的右侧返回若干字符。继续使用 taxpayer 表选择税务员社会安全号码的最后四位数字:

select  right(soc_sec_no, 4)
from    taxpayer;

结果是:

right(soc_sec_no, 4)
--------------------
        7598
        1974
        3555

right() 函数选择最右边的字符,忽略左边的字符。

lower()

lower() 函数返回字符串的小写版本。选择税务员的姓氏并将其转换为小写:

select  lower(last_name)
from    taxpayer;

结果是:

lower(last_name)
----------------
jagger
mccartney
hendrix

upper()

upper() 函数返回字符串的大写版本。选择税务员的姓氏并将其转换为大写:

select  upper(last_name)
from    taxpayer;

结果是:

upper(last_name)
----------------
JAGGER
MCCARTNEY
HENDRIX

substring()

substring() 函数返回字符串的一部分,接受三个参数:一个字符串、你想要的子字符串的起始字符位置和结束字符位置。

你可以通过以下查询从字符串 gumbo 中提取子字符串 gum

select substring('gumbo', 1, 3);

结果是:

substring('gumbo', 1, 3)
------------------------
          gum

gumbo 中,g 是第一个字符,u 是第二个字符,m 是第三个字符。从第 1 个字符开始,选择到第 3 个字符,会返回这前三个字符。

substring() 函数的第二个参数可以接受负数。如果你传递负数,它会从字符串的末尾向回计数来确定子字符串的起始位置。例如:

select substring('gumbo', -3, 2);

结果是:

substring('gumbo', -3, 2)
------------------------
          mb

字符串gumbo包含五个字符。你要求substring()从字符串末尾减去三个字符位置开始子字符串,即位置 3。你的第三个参数是 2,因此子字符串会从第三个字符开始,并取两个字符,得到mb子字符串。

substring()函数的第三个参数是可选的。你只需提供前两个参数——一个字符串和起始字符位置——即可返回从起始位置到字符串末尾的字符集:

select substring('MySQL', 3);

结果是:

substring('MySQL', 3)
------------------------
          SQL

substring()函数返回了从字符串MySQL的第三个字符开始,直到字符串末尾的所有字符,结果是SQL

MySQL 提供了一种替代语法用于substring(),使用fromfor关键字。例如,要选择单词gumbo的前三个字符,可以使用以下语法:

select substring('gumbo' from 1 for 3);

这个子字符串从第一个字符开始,持续三个字符。结果如下:

substring('gumbo' from 1 for 3)
------------------------------
             gum

这个结果与第一个子字符串示例相同,但你可能会觉得这种语法更容易阅读。

trim()

trim()函数会去除字符串中的任意数量的前导或尾随字符。你可以指定要移除的字符,以及是否要移除前导字符、尾随字符或两者。

例如,如果你有字符串**instructions**,你可以使用trim()来返回去掉星号后的字符串,像这样:

select trim(leading  '*' from '**instructions**') as column1,
       trim(trailing '*' from '**instructions**') as column2,
       trim(both     '*' from '**instructions**') as column3,
       trim(         '*' from '**instructions**') as column4;

column1中,你去除前导的星号。在column2中,你去除尾随的星号。在column3中,你去除前导和尾随的星号。当你没有指定leadingtrailingboth时,如在column4中,MySQL 默认为去除两端的空格。

结果如下:

column1         column2         column3       column4
--------------  --------------  ------------  ------------
instructions**  **instructions  instructions  instructions

默认情况下,trim()会移除空格字符。这意味着,如果字符串两侧有空格字符,你可以直接使用trim(),无需指定要移除的字符:

select trim('   asteroid   ');

结果是字符串asteroid,两侧都没有空格:

trim('   asteroid   ')
----------------------
asteroid

trim()函数默认会移除字符串两侧的空格。

ltrim()

ltrim()函数用于移除字符串左侧的前导空格:

select ltrim('   asteroid   ');

结果是字符串asteroid,左侧没有空格:

ltrim('   asteroid   ')
----------------------
asteroid

右侧的空格不会受到影响。

rtrim()

rtrim()函数用于移除字符串右侧的尾随空格:

select rtrim('   asteroid   ');

结果是字符串asteroid,右侧没有空格:

rtrim('   asteroid   ')
----------------------
   asteroid

左侧的空格不会受到影响。

日期和时间函数

MySQL 提供了与日期相关的函数,帮助你执行获取当前日期和时间、选择日期的某一部分以及计算两个日期之间相差多少天等任务。

如您在第四章中所见,MySQL 提供了 datetimedatetime 数据类型,其中 date 包含月、日和年;time 包含小时、分钟和秒;datetime 则包含这些所有部分,因为它既包括日期又包括时间。这些是 MySQL 用于返回许多函数结果的格式。

curdate()

curdate() 函数以 date 格式返回当前日期:

select curdate();

您的结果应类似于以下内容:

curdate()
----------
2024-12-14

current_date()current_date 都是 curdate() 的同义词,并会产生相同的结果。

curtime()

curtime() 函数返回当前时间,格式为 time

select curtime();

您的结果应类似于以下内容:

curtime()
---------
09:02:41

对我来说,当前时间是上午 9:02 和 41 秒。current_time()current_time 都是 curtime() 的同义词,并会产生相同的结果。

now()

now() 函数以 datetime 格式返回当前的日期和时间:

select now();

您的结果应类似于以下内容:

now()
-------------------
2024-12-14 09:02:18

current_timestamp()current_timestamp 都是 now() 的同义词,并会产生相同的结果。

date_add()

date_add() 函数将一定量的时间加到 date 值上。要对日期值进行加(或减)操作,需要使用 间隔,这是一种可以用于执行日期和时间计算的值。使用间隔时,您可以提供一个数字和一个时间单位,例如 5 day4 hour2 week。请看以下名为 event 的表:

event_id  eclipse_datetime
--------  -------------------
   374    2024-10-25 11:01:20

要从 event 表中选择 eclipse_datetime 日期并加上 5 天、4 小时和 2 周,您可以使用带有 intervaldate_add(),如下所示:

select  eclipse_datetime,
        date_add(eclipse_datetime, interval 5 day)  as add_5_days,
        date_add(eclipse_datetime, interval 4 hour) as add_4_hours,
        date_add(eclipse_datetime, interval 2 week) as add_2_weeks
from    event
where   event_id = 374;

您的结果应类似于此:

eclipse_datetime     add_5_days           add_4_hours          add_2_weeks
-------------------  -------------------  -------------------  -------------------
2024-10-25 11:01:20  2024-10-30 11:01:20  2024-10-25 15:01:20  2024-11-08 11:01:20

结果显示,5 天、4 小时和 2 周的时间间隔已加到日全食的日期和时间,并列出了您指定的列。

date_sub()

date_sub() 函数从 date 值中减去一个时间间隔。例如,在这里,您从 event 表的 eclipse_datetime 列中减去与前面示例相同的时间间隔:

select  eclipse_datetime,
        date_sub(eclipse_datetime, interval 5 day)  as sub_5_days,
        date_sub(eclipse_datetime, interval 4 hour) as sub_4_hours,
        date_sub(eclipse_datetime, interval 2 week) as sub_2_weeks
from    event
where   event_id = 374;

结果如下:

eclipse_datetime     sub_5_days           sub_4_hours          sub_2_weeks
-------------------  -------------------  -------------------  -------------------
2024-10-25 11:01:20  2024-10-20 11:01:20  2024-10-25 07:01:20  2024-10-11 11:01:20

结果显示,5 天、4 小时和 2 周的时间间隔已从日全食的日期和时间中减去,并列出了您指定的列。

extract()

extract() 函数提取指定的 datedatetime 值的部分。它使用与 date_add()date_sub() 相同的时间单位,如 dayhourweek

在此示例中,您选择了 eclipse_datetime 列的部分内容:

select  eclipse_datetime,
        extract(year from eclipse_datetime)   as year,
        extract(month from eclipse_datetime)  as month,
        extract(day from eclipse_datetime)    as day,
        extract(week from eclipse_datetime)   as week,
        extract(second from eclipse_datetime) as second
from    event
where   event_id = 374;

extract() 函数从 event 表中的 eclipse_datetime 值中提取并显示您指定列名所请求的各个部分。结果如下:

eclipse_datetime     year  month  day  week  second
-------------------  ----  -----  ---  ----  ------
2024-10-25 11:01:20  2024     10   25    43      20

MySQL 还提供了其他函数,您可以用来与 extract() 达到相同的目的,包括 year()month()day()week()hour()minute()second()。该查询与前一个查询产生相同的结果:

select  eclipse_datetime,
        year(eclipse_datetime)   as year,
        month(eclipse_datetime)  as month,
        day(eclipse_datetime)    as day,
        week(eclipse_datetime)   as week,
        second(eclipse_datetime) as second
from    event
where   event_id = 374;

你也可以使用 date()time() 函数,只选择 datetime 值中的 datetime 部分:

select  eclipse_datetime,
        date(eclipse_datetime)   as date,
        time(eclipse_datetime)   as time
from    event
where   event_id = 374;

结果是:

eclipse_datetime     date        time
-------------------  ----------  --------
2024-10-25 11:01:20  2024-10-25  11:01:20

如你所见,date()time() 函数提供了一种快速方式,从 datetime 值中提取出日期或时间。

datediff()

datediff() 函数返回两个日期之间的天数。假设你想检查 2024 年新年和 Cinco de Mayo 之间有多少天:

select datediff('2024-05-05', '2024-01-01');

结果是 125 天:

datediff('2024-05-05', '2024-01-01')
------------------------------------
                 125

如果左边的日期参数比右边的日期参数更新,datediff() 会返回一个正数。如果右边的日期更晚,datediff() 会返回一个负数。如果两个日期相同,返回 0

date_format()

date_format() 函数根据你指定的格式字符串格式化日期。格式字符串由你添加的字符和以百分号开头的格式符组成。最常见的格式符列在表 8-1 中。

表 8-1:常见的格式化符号

格式符 描述
%a 缩写的星期名称(SunSat
%b 缩写的月份名称(JanDec
%c 数字表示的月份(112
%D 带后缀的日期(1st2nd3rd,...)
%d 日期(两位数字,适用时带前导零,范围为0131
%e 日期(131
%H 小时,适用时带前导零(0023
%h 小时(0112
%i 分钟(0059
%k 小时(023
%l 小时(112
%M 月份名称(JanuaryDecember
%m 月份(0012
%p AMPM
%r 时间,12 小时制(hh:mm:ss 后跟 AMPM
%s 秒(0059
%T 时间,24 小时制(hh:mm:ss
%W 星期几的名称(SundaySaturday
%w 星期几(0 = 星期天,6 = 星期六)
%Y 四位数字年份
%y 两位数字的年份

2024-02-02 01:02:03 代表 2024 年 2 月 2 日凌晨 1:02:03。试试为该 datetime 使用不同的格式:

select  date_format('2024-02-02 01:02:03', '%r') as format1,
        date_format('2024-02-02 01:02:03', '%m') as format2,
        date_format('2024-02-02 01:02:03', '%M') as format3,
        date_format('2024-02-02 01:02:03', '%Y') as format4,
        date_format('2024-02-02 01:02:03', '%y') as format5,
        date_format('2024-02-02 01:02:03', '%W, %M %D at %T') as format6;

结果是:

format1      format2  format3   format4  format5  format6
-----------  -------  --------  -------  -------  -----------------------------------
01:02:03 AM     02    February    2024      24    Friday, February 2nd at 01:02:03

你将列别名为 format6 显示了如何将格式化符号组合在一起。在该格式字符串中,除了为日期和时间添加四个格式符号外,你还添加了一个逗号和单词 at

str_to_date()

str_to_date() 函数根据你提供的格式将字符串值转换为日期。你使用的格式符与 date_format() 中的相同,但这两个函数的作用正好相反:date_format() 将日期转换为字符串,而 str_to_date() 将字符串转换为日期。

根据你提供的格式,str_to_date() 可以将字符串转换为 datetimedatetime

select str_to_date('2024-02-02 01:02:03', '%Y-%m-%d')          as date_format,
       str_to_date('2024-02-02 01:02:03', '%Y-%m-%d %H:%i:%s') as datetime_format,
       str_to_date('01:02:03', '%H:%i:%s')                     as time_format;

结果是:

date_format  datetime_format      time_format
-----------  -------------------  -----------
2024-02-02   2024-02-02 01:02:03    01:02:03

最后一列 time_format 也可以通过同名函数进行转换。接下来我们将讨论这个。

time_format()

正如其名称所示,time_format()函数用于格式化时间。你可以像date_format()那样使用相同的格式说明符来格式化time_format()。例如,以下是获取当前时间并以不同方式格式化的示例:

select  time_format(curtime(), '%H:%i:%s')                            as format1,
        time_format(curtime(), '%h:%i %p')                            as format2,
        time_format(curtime(), '%l:%i %p')                            as format3,
        time_format(curtime(), '%H hours, %i minutes and %s seconds') as format4,
        time_format(curtime(), '%r')                                  as format5,
        time_format(curtime(), '%T')                                  as format6;

按军用时间格式表示,我现在的时间是21:09:55,即晚上 9:09 分 55 秒。你的结果应如下所示:

format1   format2   format3  format4                              format5      format6
--------  --------  -------  -----------------------------------  -----------  --------
21:09:55  09:09 PM  9:09 PM  21 hours, 09 minutes and 55 seconds  09:09:55 PM  21:09:55

你将别名为format2的列显示了带有前导0的小时,因为你使用了%H格式说明符,而format3列则没有,因为你使用了%h格式说明符。在列 1–3 中,你向格式字符串中添加了冒号字符。在format4中,你添加了单词hours,逗号,单词minutes,单词and,以及单词seconds

数学运算符和函数

MySQL 提供了许多函数来进行计算。也提供了一些算术运算符,如+表示加法,-表示减法,*表示乘法,/div表示除法,%mod表示模运算。你将开始查看一些使用这些运算符的查询,然后使用括号来控制运算顺序。之后,你将使用数学函数来执行各种任务,包括求一个数的幂、计算标准差以及对数字进行四舍五入和截断。

数学运算符

你将首先使用payroll表中的数据进行一些数学计算:

employee   salary    deduction   bonus    tax_rate
--------  ---------  ---------  --------  --------
Max Bain   80000.00    5000.00  10000.00      0.24
Lola Joy   60000.00       0.00    800.00      0.18
Zoe Ball  110000.00    2000.00  30000.00      0.35

尝试以下的一些算术运算符:

select  employee,
        salary - deduction,
        salary + bonus,
        salary * tax_rate,
        salary / 12,
        salary div 12
from    payroll;

在这个例子中,你使用数学运算符计算员工的工资减去扣款,再加上奖金,乘以税率,最后通过将年薪除以 12 来计算月薪。

结果如下:

employee salary - deduction  salary + bonus   salary * tax_rate  salary / 12  salary div 12
-------- ------------------  --------------  ------------------  -----------  -------------
Max Bain           75000.00        90000.00   9199.999570846558  6666.666667           6666
Lola Joy           60000.00        60800.00  10800.000429153442  5000.000000           5000
Zoe Ball          108000.00       140000.00   38499.99934434891  9166.666667           9166

请注意,在右侧的两列中,salary / 12salary div 12使用/div运算符时,得到的结果不同。这是因为div会舍弃任何小数部分,而/则不会。

模运算

MySQL 提供了两个模运算符:百分号(%)和mod运算符。模运算接受一个数字,将其除以另一个数字,并返回余数。考虑一个名为roulette_winning_number的表:

winning_number
--------------
       21
        8
       13

你可以使用模运算来判断一个数字是奇数还是偶数,通过将其除以 2 并检查余数,如下所示:

select  winning_number,
        winning_number % 2
from    roulette_winning_number;

任何余数为 1 的数字都是奇数。结果如下:

winning_number  winning_number % 2
--------------  ------------------
       21              1
        8              0
       13              1

结果显示奇数的余数是1,偶数的余数是0。在第一行,21 % 2的结果是1,因为 21 除以 2 得到商 10,余数为 1。

使用mod%运算符会得到相同的结果。模运算也可以通过mod()函数来实现。这些查询都会返回相同的结果:

select winning_number % 2     from roulette_winning_number;
select winning_number mod 2   from roulette_winning_number;
select mod(winning_number, 2) from roulette_winning_number;

运算符优先级

当数学表达式中使用多个算术运算符时,*/div%mod会先被计算,+-会最后计算。这被称为运算符优先级。以下查询(使用payroll表)是为了计算员工根据薪水、奖金和税率应支付的税款,但该查询返回了错误的税额:

select  employee,
        salary,
        bonus,
        tax_rate,
        salary + bonus * tax_rate
from    payroll;

结果是:

employee  salary     bonus     tax_rate  salary + bonus * tax_rate
--------  ---------  --------  --------  -------------------------
Max Bain   80000.00  10000.00      0.24                 82400.0000
Lola Joy   60000.00    800.00      0.18                 60144.0000
Zoe Ball  110000.00  30000.00      0.35                120500.0000

右侧的列应表示员工需要支付的税款,但似乎太高了。如果 Max Bain 的薪水是 80,000 美元,奖金是 10,000 美元,那么要求他支付 82,400 美元的税款似乎不合理。

查询返回了错误的值,因为你期望 MySQL 首先将salarybonus相加,然后将结果乘以tax_rate。然而,MySQL 先将bonus乘以tax_rate,然后再加上salary。这是因为乘法的优先级高于加法。

为了修正这个问题,使用括号来告诉 MySQL 将salary + bonus作为一个整体处理:

select  employee,
        salary,
        bonus,
        tax_rate,
        **(**salary + bonus**)** * tax_rate
from    payroll;

结果是:

employee  salary     bonus     tax_rate  salary + bonus * tax_rate
--------  ---------  --------  --------  -------------------------
Max Bain   80000.00  10000.00      0.24                 21600.0000
Lola Joy   60000.00    800.00      0.18                 10944.0000
Zoe Ball  110000.00  30000.00      0.35                 49000.0000

现在,查询返回了 Max Bain 的 21,600 美元,这就是正确的值。你在进行计算时应该经常使用括号——不仅因为它能帮助你控制运算顺序,还因为它让你的 SQL 更加易读和易懂。

数学函数

MySQL 提供了许多数学函数,可以帮助处理诸如四舍五入、获取数字的绝对值、处理指数等任务,以及计算余弦、对数和弧度。

abs()

abs()函数获取一个数字的绝对值。一个数字的绝对值总是正数。例如,5 的绝对值是 5,–5 的绝对值是 5。

假设你举办了一个比赛,猜测罐子里有多少颗果冻豆。写一个查询,看看谁的猜测最接近实际数字 300:

select  guesser,
        guess,
        300          as actual,
        300 – guess  as difference
from    jelly_bean;

这里你从jelly_bean表中选择了猜测者的姓名和他们的猜测值。你选择了300并将该列别名为actual,这样它将在结果中显示该标题。然后你从 300 中减去猜测值,并将该列别名为difference。结果是:

guesser  guess actual  difference
-------  ----- ------  ----------
Ruth       275    300          25
Henry      350    300         -50
Ike        305    300          -5

difference列显示了猜测与实际值 300 之间的偏差,但结果有点难以理解。当猜测高于实际值 300 时,difference列显示为负数;当猜测低于实际值时,difference列显示为正数。对于你的比赛,你不关心猜测是高于还是低于 300,你只关心哪个猜测最接近 300。

你可以使用abs()函数从difference列中移除负数:

select  guesser,
        guess,
        300 as actual,
 **abs(**300 – guess**)** as difference
from    jelly_bean;

结果是:

guesser  guess actual  difference
-------  ----- ------  ----------
Ruth       275    300          25
Henry      350    300          50
Ike        305    300           5

现在你可以轻松地看到,Ike 赢得了你的比赛,因为他在difference列中的值是最小的。

ceiling()

ceiling()函数返回大于或等于参数的最小整数。如果你支付了$3.29 的油费,并想将该数字四舍五入到下一个整数,你可以写下以下查询:

select ceiling(3.29);

结果是:

ceiling(3.29)
-------------
      4

ceiling()函数有一个同义词ceil(),它返回相同的结果。

floor()

floor()函数返回小于或等于参数的最大整数。要将$3.29 四舍五入到最接近的整数,你可以写下以下查询:

select floor(3.29);

结果是:

floor(3.29)
-----------
      3

如果参数已经是整数,那么ceiling()floor()函数都会返回该整数。例如,ceiling(33)floor(33)都会返回33

pi()

pi()函数返回 pi 的值,如本章开头所示。

degrees()

degrees()函数将弧度转换为角度。你可以通过这个查询将 pi 转换为角度:

select degrees(pi());

结果是:

degrees(pi())
-------------
     180

你通过将pi()函数包装在degrees()函数中得到了答案。

radians()

radians()函数将角度转换为弧度。你可以使用以下查询将 180 转换为弧度:

select radians(180);

你的结果是:

radians(180)
-----------------
3.141592653589793

该函数接收到参数180并返回了 pi 的值。

exp()

exp()函数返回自然对数底数e(2.718281828459)被你提供的数字(例如 2)作为指数时的结果:

select exp(2);

结果是:

7.38905609893065

该函数返回7.38905609893065,即e(2.718281828459)的平方。

log()

log()函数返回你提供的数字的自然对数:

select log(2);

结果是:

0.6931471805599453

MySQL 还提供了log10()函数,它返回以 10 为底的对数,以及log2()函数,它返回以 2 为底的对数。

log()函数可以接受两个参数:一个是数字的底数,另一个是该数字本身。例如,要计算 log2;


结果是:

log(2, 8)

3

该函数接收到两个参数,`2`和`8`,并返回值`3`。

#### mod()

`mod()`函数,如你之前看到的,是取模函数。它接受一个数字,将其除以另一个数字,并返回余数。

select mod(7, 2);


结果是:

mod(7, 2)

1

`mod(7,2)`函数的结果为`1`,因为 7 除以 2 的商为 3,余数为 1。取模运算也可以通过`%`运算符和`mod`运算符实现。

#### pow()

`pow()`函数返回一个数值的幂。要将 5 的 3 次方计算出来,你可以写下这个查询:

select pow(5, 3);


结果是:

pow(5, 3)

125


`pow()`函数有一个同义词`power()`,它返回相同的结果。

#### round()

本章前面介绍的`round()`函数用于四舍五入小数。要将数字 9.87654321 四舍五入到小数点后 3 位,可以使用以下查询:

select round(9.87654321, 3);


结果是:

round(9.87654321, 3)

   9.877

要四舍五入所有小数数值,可以只用一个参数调用`round()`:

select round(9.87654321);


结果是:

round(9.87654321)

     10

如果调用`round()`时没有提供可选的第二个参数,它会默认四舍五入到小数点后 0 位。

#### truncate()

`truncate()` 函数将数字截断到指定的小数位数。要将数字 9.87654321 截断到小数点后三位,请使用以下查询:

select truncate(9.87654321, 3);


结果是:

truncate(9.87654321, 3)

   9.876

要截断所有小数部分,可以将 `truncate()` 函数的第二个参数设为 `0`:

select truncate(9.87654321, 0);


结果是:

truncate(9.87654321, 0)

      9

`truncate()` 函数通过移除数字来将数字转换为小数点后的指定位数。这与 `round()` 函数不同,后者在去除数字之前会四舍五入。

#### sin()

`sin()` 函数返回一个以弧度表示的数的正弦值。你可以使用这个查询来得到 2 的正弦值:

select sin(2);


结果是:

sin(2)

0.9092974268256817


函数接收到 `2` 作为参数,并返回值 `0.9092974268256817`。

#### cos()

`cos()` 函数返回一个以弧度表示的数的余弦值。使用以下查询可以得到 2 的余弦值:

select cos(2);


结果是:

cos(2)

-0.4161468365471424


函数接收到 `2` 作为参数,并返回值 `-0.4161468365471424`。

#### sqrt()

`sqrt()` 函数返回一个数的平方根。你可以像这样计算 16 的平方根:

sqrt(16)

4


函数接收到 `16` 作为参数,并返回值 `4`。

#### stddev_pop()

`stddev_pop()` 函数返回提供的数值的总体标准差。*总体标准差*是考虑数据集中所有值时的标准差。例如,查看包含你所有考试成绩的 `test_score` 表:

score

70
82
97


现在编写查询来获取考试成绩的总体标准差:

select stddev_pop(score)
from test_score;


结果是:

stddev_pop(score)

11.045361017187261


`std()` 和 `stddev()` 函数是 `stddev_pop()` 的同义词,会产生相同的结果。

若要获取样本值的标准差,而不是整个数据集的标准差,你可以使用 `stddev_samp()` 函数。

#### tan()

`tan()` 函数接受弧度作为参数并返回正切值。例如,你可以通过以下查询获取 3.8 的正切值:

select tan(3.8);


结果是:

0.7735560905031258


函数接收到 `3.8` 作为参数,并返回值 `0.7735560905031258`。

## 其他实用函数

其他有用的函数包括 `cast()`、`coalesce()`、`distinct()`、`database()`、`if()` 和 `version()`。

### cast()

`cast()` 函数将一个值从一种数据类型转换为另一种数据类型。调用 `cast()` 函数时,将值作为第一个参数传入 `cast()`,接着使用 `as` 关键字,指定要转换成的目标数据类型。

例如,从名为 `online_order` 的表中选择 `datetime` 列 `order_datetime`:

select order_datetime
from online_order;


你的结果显示以下 `datetime` 值:

order_datetime

2024-12-08 11:39:09
2024-12-10 10:11:14


你可以通过将 `datetime` 数据类型转换为 `date` 数据类型,来选择没有时间部分的值,像这样:

select cast(order_datetime as date)
from online_order;


你的结果是:

cast(order_datetime as date)

     2024-12-08
     2024-12-10

`datetime` 的日期部分现在显示为 `date` 值。

### coalesce()

`coalesce()` 函数返回列表中第一个非空值。你可以指定空值后跟非空值,`coalesce()` 会返回非空值:

select coalesce(null, null, 42);


结果是:

coalesce(null, null, 42)

        42

`coalesce()`函数在你想要在结果中显示某个值而不是`null`时也非常有用。例如,在以下查询中使用的`candidate`表中,`employer`列有时会存储候选人的雇主名称,其他时候该列会是`null`。为了显示`Between Jobs`而不是`null`,你可以输入以下内容:

select employee_name,
coalesce(employer, 'Between Jobs')
from candidate;


结果是:

employee_name employer


Jim Miller Acme Corp
Laura Garcia Globex
Jacob Davis Between Jobs


现在查询显示的是 Jacob Davis 的`Between Jobs`,而不是`null`,这更加信息丰富,特别是对于那些不理解`null`含义的非技术用户来说。

### distinct()

当你有重复的值时,可以使用`distinct()`函数使每个值只显示一次。例如,如果你想知道你的客户来自哪些国家,可以像这样查询`customer`表:

select country
from customer;


结果是:

country

India
USA
USA
USA
India
Peru


查询返回了`customer`表中每一行的`country`列值。你可以使用`distinct()`函数使结果集中每个国家只显示一次:

select distinct(country)
from customer;


现在结果是:

country

India
USA
Peru


`distinct()`函数也可以作为操作符使用。要使用它,去掉括号,如下所示:

select distinct country
from customer;


结果集是相同的:

country

India
USA
Peru


`distinct()`函数在与`count()`函数结合使用时尤其有用,用来找出你表中有多少个唯一的值。这里你写一个查询来计算表中不同国家的数量:

select count(distinct country)
from customer;


结果是:

count(distinct country)

       3

你使用`distinct()`函数识别了不同的国家,并将它们包裹在`count()`函数中以获取数量。

### database()

`database()`函数告诉你当前使用的是哪个数据库。正如在第二章中所看到的,`use`命令允许你选择要使用的数据库。在你的一天中,你可能会在不同的数据库之间切换,忘记当前的数据库。你可以像这样调用`database()`函数:

use airport;

select database();


结果是:

database()

airport


如果你不在你以为自己所在的数据库中,并且你尝试查询一个表,MySQL 会给出错误,说明该表不存在。调用`database()`是一种快速检查的方式。

### if()

`if()`函数根据条件是否为`true`或`false`返回不同的值。`if()`函数接受三个参数:你要测试的条件、条件为`true`时返回的值、条件为`false`时返回的值。

让我们写一个查询,列出学生及其是否通过考试。`test_result`表包含以下数据:

student_name grade


Lisa 98
Bart 41
Nelson 11


你检查每个学生是否通过考试的查询应该类似于以下内容:

select student_name,
if(grade > 59, 'pass', 'fail')
from test_result;


你正在测试的条件是学生的`grade`是否大于`59`。如果是,你返回文本`pass`。如果不是,你返回文本`fail`。结果是:

student_name if(grade > 59, 'pass', 'fail')


Lisa pass
Bart fail
Nelson fail


MySQL 还具有 `case` 运算符,它允许你执行比 `if()` 函数更复杂的逻辑。`case` 运算符允许你测试多个条件,并返回第一个满足条件的结果。在以下查询中,你根据学生的成绩选择学生姓名,并为学生添加评论:

select student_name,
case
when grade < 30 then 'Please retake this exam'
when grade < 60 then 'Better luck next time'
else 'Good job'
end
from test_result;


`case` 运算符使用匹配的 `end` 关键字来标志 `case` 语句的结束。

对于任何得分低于 30 分的学生,`case` 语句将返回 `Please retake this exam`,然后控制权传递到 `end` 语句。

得分 30 分或以上的学生不会被第一个 `when` 条件处理,因此控制权转到下一行。

如果学生的成绩为 30 分或更高,但低于 60 分,将返回 `Better luck next time`,然后控制权传递到 `end` 语句。

如果学生的成绩不符合任一 `when` 条件,即学生的分数高于 60,控制权将转到 `else` 关键字,返回 `Good job`。你可以使用 `else` 子句来捕捉任何不符合前两个条件的学生成绩。结果是:

student_name case when grade < 30 then 'Please...


Lisa Good job
Bart Better luck next time
Nelson Please retake this exam


与 `if()` 函数不同——`if()` 函数在条件为 `true` 或 `false` 时返回结果——`case` 允许你检查多个条件,并根据第一个满足的条件返回结果。

### version()

`version()` 函数返回你正在使用的 MySQL 版本:

select version();


结果是:

version

8.0.27


我服务器上安装的 MySQL 版本是 8.0.27。你的版本可能不同。

## 总结

在本章中,你学习了如何调用 MySQL 内置函数并向这些函数传递值,这些值被称为参数。你探索了最有用的函数,并了解了如何在需要时查找那些较不常见的函数。在下一章中,你将学习如何从 MySQL 数据库中插入、更新和删除数据。


# 第九章:插入、更新和删除数据

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在本章中,你将学习如何插入、更新和删除表中的数据。你将练习将数据从一个表插入到另一个表,使用查询来更新或删除表中的数据,并创建一个在插入行时自动递增数字值的表。

## 插入数据

到目前为止,你一直是在查询表中的数据。那么,这些数据是如何最初进入表中的呢?通常,你是通过`insert`语句来插入数据的。

使用`insert`语句向表中添加行称为*填充*表格。你需要指定表的名称、你要插入值的列名,以及你想要插入的值。

在这里,你向`arena`表插入了一行数据,其中包含关于不同竞技场名称、位置和容量的信息:

❶ insert into arena
(
❷ arena_id,
arena_name,
location,
seating_capacity
)
❸ values
(
1,
❹ 'Madison Square Garden',
'New York',
20000
);


首先,你需要指定你要将一行数据插入到`arena`表中❶,并且你的数据将填入`arena_id`、`arena_name`、`location`和`seating_capacity`这几列❷。然后,你在`values`关键字下列出你想插入的值,顺序与列名一致❸。你需要将`Madison Square Garden`和`New York`这两个值用引号括起来,因为它们是字符串❹。

当你运行此`insert`语句时,MySQL 会返回`1 row(s) affected`的信息,告诉你表中已插入了一行数据。

然后,你可以查询你的`arena`表,确认新插入的行符合预期:

select * from arena;


结果是:

arena_id arena_name location seating_capacity


1     Madison Square Garden  New York       20000

行已成功插入,列及其值如你所预期的那样显示。

### 插入`null`值

当你想要插入一个`null`值到某列时,你有两个选择。首先,你可以列出该列名,并使用`null`关键字作为要插入的值。例如,如果你想向`arena`表中添加一行`Dean Smith Center`的数据,但不知道它的座位容量,你可以像这样编写`insert`语句:

insert into arena
(
arena_id,
arena_name,
location,
seating_capacity
)
values
(
2,
'Dean Smith Center',
'North Carolina',
null
);


第二种选择是完全省略列名。作为前面`insert`语句的替代方案,你可以将`seating_capacity`列从列名列表中省略,并且在值列表中不为该列提供任何值:

insert into arena
(
arena_id,
arena_name,
location
)
values
(
2,
'Dean Smith Center',
'North Carolina'
);


由于你没有向`seating_capacity`列插入任何值,MySQL 将默认将其设置为`null`。你可以通过以下查询查看插入的行:

select *
from arena
where arena_id = 2;


结果是:

arena_id arena_name location seating_capacity


2     Dean Smith Center  North Carolina         null

无论你采用哪种方法,`seating_capacity`列的值都会被设置为`null`。

如果在创建表时,`seating_capacity`列已被定义为`not null`,则无论采用哪种方法,你都不允许插入`null`值(参见第二章)。

### 一次插入多行数据

当你想要插入多行数据时,你可以选择一次插入一行,或者将它们作为一组插入。我们先从第一种方法开始。以下是如何通过单独的`insert`语句向`arena`表插入三条数据:

insert into arena (arena_id, arena_name, location, seating_capacity)
values (3, 'Philippine Arena', 'Bocaue', 55000);

insert into arena (arena_id, arena_name, location, seating_capacity)
values (4, 'Sportpaleis', 'Antwerp', 23359);

insert into arena (arena_id, arena_name, location, seating_capacity)
values (5, 'Bell Centre', 'Montreal', 22114);


你也可以通过将所有三行合并成一个 `insert` 语句来达到相同的效果:

insert into arena (arena_id, arena_name, location, seating_capacity)
values (3, 'Philippine Arena', 'Bocaue', 55000),
(4, 'Sportpaleis', 'Antwerp', 23359),
(5, 'Bell Centre', 'Montreal', 22114);


若要一次插入多行,需将每行的值用括号括起来,并在每组值之间使用逗号。MySQL 将会把所有三行插入到表中,并给出消息 `3 row(s) affected`,表示所有三行已成功插入。

### 不列出列名的插入

你也可以在不指定列名的情况下向表中插入数据。由于你要插入四个值,而 `arena` 表只有四列,你可以用不列出列名的 `insert` 语句替代列出列名的语句:

insert into arena
values (6, 'Staples Center', 'Los Angeles', 19060);


MySQL 能够确定将值插入到哪些列中,因为你提供的数据顺序与表中的列顺序相同。

虽然省略列名可以减少一些打字工作,但最佳实践是列出它们。将来你可能会向 `arena` 表中添加一个第五列。如果不列出列名,进行该更改时会破坏你的 `insert` 语句,因为你会试图将四个值插入到一个有五个列的表中。

### 插入数字序列

你可能想将连续的数字插入到表的某个列中,比如在 `arena` 表中,`arena_id` 列的第一行应该为 `1`,第二行应该为 `2`,第三行应该为 `3`,以此类推。MySQL 提供了一种简单的方法,让你通过定义带有 `auto_increment` 属性的列来实现这一点。`auto_increment` 属性特别适用于主键列——即唯一标识表中行的列。

我们来看它是如何工作的。从你到目前为止创建的 `arena` 表中选择所有内容:

select * from arena;


结果是:

arena_id arena_name location seating_capacity


1     Madison Square Garden  New York             20000
2     Dean Smith Center      North Carolina        null
3     Philippine Arena       Bocaue               55000
4     Sportpaleis            Antwerp              23359
5     Bell Centre            Montreal             22114
6     Staples Center         Los Angeles          19060

你可以看到每个竞技场都有自己的 `arena_id`,它比之前插入的竞技场的 `arena_id` 大 1。

当你在 `arena_id` 列中插入值时,你需要先找到表中已存在的最大 `arena_id`,然后在插入下一行时将其加 1。例如,当你为 `Staples Center` 插入行时,你硬编码了 `arena_id` 为 `6`,因为前一个 `arena_id` 是 `5`:

insert into arena (arena_id, arena_name, location, seating_capacity)
values (6, 'Staples Center', 'Los Angeles', 19060);


这种方法在实际的生产数据库中效果不好,因为在生产环境下,很多新的行会迅速被创建。一个更好的方法是让 MySQL 通过在创建表时定义带有 `auto_increment` 的 `arena_id` 列来为你处理这项工作。我们来试试吧。

删除 `arena` 表,并使用 `auto_increment` 重新创建它以适配 `arena_id` 列:

drop table arena;

create table arena (
arena_id int primary key auto_increment,
arena_name varchar(100),
location varchar(100),
seating_capacity int
);


现在,当你向表中插入行时,你就不需要再处理 `arena_id` 列的数据插入了。你只需要插入其他列的数据,MySQL 会自动为每个新插入的行递增 `arena_id` 列。你的 `insert` 语句应该是这样的:

insert into arena (arena_name, location, seating_capacity)
values ('Madison Square Garden', 'New York', 20000);

insert into arena (arena_name, location, seating_capacity)
values ('Dean Smith Center', 'North Carolina', null);

insert into arena (arena_name, location, seating_capacity)
values ('Philippine Arena', 'Bocaue', 55000);

insert into arena (arena_name, location, seating_capacity)
values ('Sportpaleis', 'Antwerp', 23359);

insert into arena (arena_name, location, seating_capacity)
values ('Bell Centre', 'Montreal', 22114);

insert into arena (arena_name, location, seating_capacity)
values ('Staples Center', 'Los Angeles', 19060);


你没有在列的列表中列出`arena_id`作为一列,也没有在值的列表中提供`arena_id`的值。看看在 MySQL 运行你的`insert`语句后表中的行:

select * from arena;


结果如下:

arena_id arena_name location seating_capacity


1     Madison Square Garden  New York             20000
2     Dean Smith Center      North Carolina        null
3     Philippine Arena       Bocaue               55000
4     Sportpaleis            Antwerp              23359
5     Bell Centre            Montreal             22114
6     Staples Center         Los Angeles          19060

如你所见,MySQL 自动为`arena_id`列的值进行了递增。

每个表格只能定义一个`auto_increment`列,并且该列必须是主键列(或主键的一部分)。

当向一个定义了`auto_increment`的列插入值时,MySQL 会始终插入一个更大的数字,但这些数字之间可能会有间隙。例如,你的表格可能会出现`arena_id`为 22、23,然后是 29 的情况。造成这种情况的原因与数据库使用的存储引擎、MySQL 服务器的配置以及其他超出本书范围的因素有关,因此请记住,定义为`auto_increment`的列始终会生成递增的数字列表。

### 使用查询插入数据

你可以基于查询返回的值将数据插入到表格中。例如,假设`large_building`表中有你想添加到`arena`表的数据。`large_building`表是使用以下数据类型创建的:

create table large_building
(
building_type varchar(50),
building_name varchar(100),
building_location varchar(100),
building_capacity int,
active_flag bool
);


它包含以下数据:

building_type building_name building_location building_capacity active_flag


Hotel Wanda Inn Cape Cod 125 1
Arena Yamada Green Dome Japan 20000 1
Arena Oracle Arena Oakland 19596 1


对你来说,你并不关心表格中的第一行数据,因为`Wanda Inn`是一个酒店,而不是一个竞技场。你可以编写查询,从`large_building`表中的其他行返回竞技场的数据,如下所示:

select building_name,
building_location,
building_capacity
from large_building
where building_type = 'Arena'
and active_flag is true;


结果如下:

building_name building_location building_capacity


Yamada Green Dome Japan 20000
Oracle Arena Oakland 19596


然后,你可以使用该查询作为`insert`语句的基础,将这些行数据插入到`arena`表中:

insert into arena (
arena_name,
location,
seating_capacity
)
select building_name,
building_location,
building_capacity
from large_building
where building_type = 'Arena'
and active_flag is true;


MySQL 将从查询中返回的两行数据插入到`arena`表中。你可以查询`arena`表以查看新插入的行:

select * from arena;


这是包含新行的结果:

arena_id arena_name location seating_capacity


1     Madison Square Garden  New York             20000
2     Dean Smith Center      North Carolina        null
3     Philippine Arena       Bocaue               55000
4     Sportpaleis            Antwerp              23359
5     Bell Centre            Montreal             22114
6     Staples Center         Los Angeles          19060
7     Yamada Green Dome      Japan                20000
8     Oracle Arena           Oakland              19596

`insert`语句将竞技场`7`和`8`添加到`arena`表中的现有数据中。

### 使用查询创建并填充新表

`create table as`语法允许你在一步操作中创建并填充表格。在这里,你创建了一个名为`new_arena`的新表,并同时插入行数据:

create table new_arena as
select building_name,
building_location,
building_capacity
from large_building
where building_type = 'Arena'
and active_flag is true;


该语句根据前面的`large_building`查询结果创建了一个名为`new_arena`的表格。现在查询新表:

select * from new_arena;


结果如下:

building_name building_location building_capacity


Yamada Green Dome Japan 20000
Oracle Arena Oakland 19596


`new_arena`表与`large_building`表具有相同的列名和数据类型。你可以使用`desc`关键字描述表格,以确认数据类型:

desc new_arena;


结果如下:

Field Type Null Key Default Extra


building_name varchar(100) YES null
building_location varchar(100) YES null
building_capacity int YES null


你还可以使用`create table`复制一个表格。例如,你可以通过复制`arena`表并将新表命名为`arena_`,后面加上当前日期来保存`arena`表的当前状态,如下所示:

create table arena_20241125 as
select * from arena;


在你添加或删除`arena`表的列之前,你可能希望先确保你已将原始数据保存在第二个表格中。当你即将对表格进行重大更改时,这一点尤其有用,但如果表格非常大,可能不切实际去复制整个表格。

## 更新数据

一旦你的表中有了数据,你可能会想要随着时间推移对其进行修改。MySQL 的`update`语句允许你修改现有数据。

场馆因名称变更而臭名昭著,你表中的场馆也不例外。在这里,你通过`update`语句将`arena_id 6`的`arena_name`值从`Staples Center`更改为`Crypto.com Arena`:

update arena
set arena_name = 'Crypto.com Arena'
where arena_id = 6;


首先,你使用`set`关键字来设置表中列的值。在这里,你将`arena_name`列的值设置为`Crypto.com Arena`。

接下来,你在`where`子句中指定要更新的行。在这种情况下,你选择根据`arena_id`列值为`6`来更新行,但你也可以根据其他列来更新相同的行。例如,你可以根据`arena_name`列来更新这一行:

update arena
set arena_name = 'Crypto.com Arena'
where arena_name = 'Staples Center';


或者,由于你在洛杉矶只列出了一个场馆,你可以使用`location`列来更新这一行:

update arena
set arena_name = 'Crypto.com Arena'
where location = 'Los Angeles';


精心编写`where`子句非常重要,因为任何符合该子句中指定条件的行都将被更新。例如,如果有五个场馆的`location`为`Los Angeles`,那么这个`update`语句将把这五个场馆的名称全部更改为`Crypto.com Arena`,无论这是否是你原本的意图。

通常,最好根据主键列来更新行。当你创建`arena`表时,你已将`arena_id`列定义为表的主键。这意味着表中会有唯一的一行对于`arena_id`为`6`,因此如果你使用语法`where arena_id = 6`,你可以确保只更新这一行。

在`where`子句中使用主键也是最佳实践,因为主键列是已建立索引的。已建立索引的列通常在查找表中的行时比未建立索引的列要快。

### 更新多个行

要更新多个行,你可以使用匹配多行的`where`子句。在这里,你更新了所有`arena_id`大于`3`的场馆的座位容量:

update arena
set seating_capacity = 20000
where arena_id > 3;


MySQL 将场馆`4`、`5`和`6`的`seating_capacity`值更新为 20,000。

如果你完全移除`where`子句,那么表中的所有行都会被更新:

update arena
set seating_capacity = 15000;


如果现在执行`select * from arena`,你会发现所有场馆的座位容量都是 15,000:

arena_id arena_name location seating_capacity


1     Madison Square Garden  New York             15000
2     Dean Smith Center      North Carolina       15000
3     Philippine Arena       Bocaue               15000
4     Sportpaleis            Antwerp              15000
5     Bell Centre            Montreal             15000
6     Crypto.com Arena       Los Angeles          15000

在这个例子中,很明显你忘记使用`where`子句来限制更新的行数。

### 更新多个列

你可以通过用逗号分隔列名,在一个`update`语句中更新多个列:

update arena
set arena_name = 'Crypto.com Arena',
seating_capacity = 19100
where arena_id = 6;


在这里,你已更新了`arena_name`和`seating_capacity`列的值,针对的是`arena_id`为`6`的那一行。

## 删除数据

要从表中删除数据,你可以使用`delete`语句。你可以一次删除一行、多行或使用一个`delete`语句删除所有行。你使用`where`子句来指定要删除的行。在这里,你删除了`arena_id`为`2`的那一行:

delete from arena
where arena_id = 2;


在你执行完这个`delete`语句后,可以像这样从表中选择剩余的行:

select * from arena;


结果是:

arena_id arena_name location seating_capacity


1     Madison Square Garden  New York             15000
3     Philippine Arena       Bocaue               15000
4     Sportpaleis            Antwerp              15000
5     Bell Centre            Montreal             15000
6     Crypto.com Arena       Los Angeles          15000

你可以看到,包含`arena_id`为`2`的行已经被删除。

在第七章中,你学习了如何使用`like`进行简单的模式匹配。你可以在这里使用它删除所有名称中包含`Arena`的场馆:

delete from arena
where arena_name like '%Arena%';


从表中选择剩余的行:

select * from arena;


结果是:

arena_id arena_name location seating_capacity


1     Madison Square Garden  New York             15000
4     Sportpaleis            Antwerp              15000
5     Bell Centre            Montreal             15000

包含`Philippine Arena`和`Crypto.com Arena`的两行已不再存在于表中。

如果你编写了一个`delete`语句,并且`where`子句没有匹配任何行,那么就不会删除任何行:

delete from arena
where arena_id = 459237;


这个语句不会删除任何行,因为没有`arena_id`为`459237`的行。MySQL 不会产生错误消息,但会告诉你`0 row(s) affected`。

要删除表中的所有行,你可以使用不带`where`子句的`delete`语句:

delete from arena;


这个语句会删除表中的所有行。

## 截断和删除表格

*截断*表会删除所有行,但保留表本身。它的效果与使用不带`where`子句的`delete`相同,但通常更快。

你可以使用`truncate table`命令来截断表,如下所示:

truncate table arena;


一旦语句执行完毕,表格仍然存在,但其中将没有任何行。

如果你想删除表及其所有数据,你可以使用`drop table`命令:

drop table arena;


如果你现在尝试从`arena`表中选择数据,MySQL 会显示一条消息,说明该表不存在。

## 摘要

在这一章中,你学习了如何插入、更新和删除表中的数据。你了解了如何插入空值,并快速创建或删除整个表。在下一章,你将学习使用类似表的结构——*视图*——的好处。


# 第三部分

数据库对象

在第三部分,你将创建像视图、函数、过程、触发器和事件这样的数据库对象。这些对象将存储在你的 MySQL 服务器上,以便你在需要时随时调用它们。

在第十章,你将学习如何创建视图,让你像访问表格一样访问查询结果。

在第十一章,你将创建自己的函数和过程来执行任务,比如获取和更新州的人口数据。

在第十二章,你将创建自己的触发器,当行被插入、更新或删除时,触发器会自动执行你定义的操作。

在第十三章,你将创建自己的 MySQL 事件来管理计划任务。

在这些章节中,你将使用以下命名约定来表示不同类型的对象:

| `beer` | 一个包含啤酒数据的表。 |
| --- | --- |
| `v_beer` | 一个包含啤酒数据的视图。 |
| `f_get_ipa()` | 一个获取印度淡色艾尔啤酒列表的函数。 |
| `p_get_pilsner()` | 一个获取比尔森啤酒列表的过程。 |
| `tr_beer_ad` | 一个触发器,在啤酒表中的某些行被删除后自动执行某个操作。我使用`tr_`作为触发器的前缀,以避免与表格混淆,因为表格也以字母`t`开头。后缀`_ad`表示删除后,`_bd`表示删除前,`_bu`和`_au`分别表示更新前和更新后,`_bi`和`_ai`分别表示插入前和插入后。你将在第十二章了解这些后缀的含义。 |
| `e_load_beer` | 一个定时事件,用于将新啤酒数据加载到啤酒表中。 |

在前面的章节中,你已经对表格进行了描述性命名,以便其他程序员能够快速理解表格所存储的数据的性质。对于表格以外的数据库对象,你将继续使用这种命名方法,并且在对象名称前添加简短的类型描述(例如,`v_`表示*视图*);有时,你还会添加后缀(例如,`_ad`表示*删除后*)。

虽然这些命名约定并非硬性规定,但考虑使用它们,因为它们有助于你快速理解数据库对象的用途。


# 第十章:创建视图

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在本章中,你将学习如何创建和使用视图。*视图*是基于你编写的查询输出的虚拟表,用于定制结果集的显示。每次你从视图中选择数据时,MySQL 都会重新执行定义视图时的查询,返回最新的结果,作为类似表格的结构,包含行和列。

视图在你想简化复杂查询或隐藏敏感或无关数据的情况下非常有用。

## 创建新视图

你使用`create view`语法来创建一个视图。让我们看一个包含以下`course`表的示例:

course_name course_level


Introduction to Python beginner
Introduction to HTML beginner
React Full-Stack Web Development advanced
Object-Oriented Design Patterns in Java advanced
Practical Linux Administration advanced
Learn JavaScript beginner
Advanced Hardware Security advanced


在这里,你创建了一个名为`v_course_beginner`的视图,从`course`表中选择所有`course_level`为`beginner`的列:

create view v_course_beginner as
select *
from course
where level = 'beginner';


执行此语句将创建视图并将其保存在你的 MySQL 数据库中。现在你可以随时查询`v_course_beginner`视图,如下所示:

select * from v_course_beginner;


结果如下:

course_name course_level


Introduction to Python beginner
Introduction to HTML beginner
Learn JavaScript beginner


由于你通过从`course`表中选择`*`(通配符字符)来定义视图,因此它具有与表相同的列名。

`v_course_beginner`视图应由初学者使用,因此你只选择了`course_level`为`beginner`的课程,隐藏了高级课程。

现在为高级学生创建第二个视图,仅包含高级课程:

create view v_course_advanced as
select *
from courses
where level = 'advanced';


从`v_course_advanced`视图中选择显示高级课程:

select * from v_course_advanced;


结果如下:

course_name course_level


React Full-Stack Web Development advanced
Object-Oriented Design Patterns in Java advanced
Practical Linux Administration advanced
Advanced Hardware Security advanced


当你定义`v_course_advanced`视图时,你提供了一个查询,该查询从`course`表中选择数据。每次使用视图时,MySQL 都会执行该查询,这意味着视图始终会显示`course`表中最新的行。在这个示例中,任何新添加到`course`表中的高级课程都会在每次从`v_course_advanced`视图中选择时显示。

这种方法允许你在`course`表中维护课程,并为初学者和高级学生提供不同的数据视图。

## 使用视图隐藏列值

在`course`表示例中,你创建了显示表中某些行并隐藏其他行的视图。你还可以创建显示不同*列*的视图。

让我们看一个使用视图隐藏敏感列数据的示例。你有两个表,`company`和`complaint`,它们帮助跟踪本地公司的投诉。

`company`表如下:

company_id company_name owner owner_phone_number


1 Cattywampus Cellular Sam Shady 784-785-1245
2 Wooden Nickel Bank Oscar Opossum 719-997-4545
3 Pitiful Pawn Shop Frank Fishy 917-185-7911


这是`complaint`表:

complaint_id company_id complaint_desc


1 1 Phone doesn't work
2 1 Wi-Fi is on the blink
3 1 Customer service is bad
4 2 Bank closes too early
5 3 My iguana died
6 3 Police confiscated my purchase


你将首先编写一个查询来选择每个公司及其接收的投诉数量:

select a.company_name,
a.owner,
a.owner_phone_number,
count(*)
from company a
join complaint b
on a.company_id = b.company_id
group by a.company_name,
a.owner,
a.owner_phone_number;


结果如下:

company_name owner owner_phone_number count(*)


Cattywampus Cellular Sam Shady 784-785-1245 3
Wooden Nickel Bank Oscar Opossum 719-997-4545 1
Pitiful Pawn Shop Frank Fishy 917-185-7911 2


要在名为`v_complaint`的视图中显示此查询的结果,只需在原始查询的第一行添加`create view`语法:

create view v_complaint as
select a.company_name,
a.owner,
a.owner_phone_number,
count(*)
from company a
join complaint b
on a.company_id = b.company_id
group by a.company_name,
a.owner,
a.owner_phone_number;


现在,下次你想获取公司及其投诉计数的列表时,你可以简单地输入`select * from v_complaint`,而无需重写整个查询。

接下来,你将创建另一个隐藏所有者信息的视图。你将命名该视图为`v_complaint_public`,并允许所有数据库用户访问该视图。该视图将显示公司名称和投诉数量,但不显示所有者的姓名或电话号码:

create view v_complaint_public as
select a.company_name,
count(*)
from company a
join complaint b
on a.company_id = b.company_id
group by a.company_name;


你可以像这样查询视图:

select * from v_complaint_public;


结果是:

company_name count(*)


Cattywampus Cellular 3
Wooden Nickel Bank 1
Pitiful Pawn Shop 2


这是使用视图来隐藏存储在列中的数据的一个例子。虽然所有者的联系信息存储在你的数据库中,但你通过在`v_complaint_public`视图中不选择这些列来隐藏这些信息。

一旦你创建了视图,就可以像使用表一样使用它们。例如,你可以将视图与表连接,将视图与其他视图连接,并在子查询中使用视图。

## 从视图中插入、更新和删除数据

在第九章中,你学习了如何插入、更新和删除表中的行。在某些情况下,也可以通过视图修改行。例如,`v_course_beginner`视图是基于`course`表的。你可以使用以下`update`语句更新该视图:

update v_course_beginner
set course_name = 'Introduction to Python 3.1'
where course_name = 'Introduction to Python';


这个`update`语句更新了`v_course_beginner`视图底层`course`表中的`course_name`列。MySQL 能够执行该更新,因为视图和表非常相似;对于`v_course_beginner`视图中的每一行,`course`表中都有一行。

现在,尝试用类似的查询更新`v_complaint`视图:

update v_complaint
set owner_phone_number = '578-982-1277'
where owner = 'Sam Shady';


你会收到以下错误消息:

Error Code: 1288. The target table v_complaint of the UPDATE is not updatable


MySQL 不允许你更新`v_complaint`视图,因为它是通过多个表和`count()`聚合函数创建的。它比`v_course_beginner`视图更复杂。关于哪些视图允许更新、插入或删除行的规则相当复杂。因此,我建议直接从表中更改数据,而避免将视图用于此目的。

## 删除视图

要删除视图,使用`drop view`命令:

drop view v_course_advanced;


虽然视图已从数据库中删除,但底层表仍然存在。

## 索引与视图

你不能为视图添加索引以加速查询,但 MySQL 可以使用底层表上的任何索引。例如,下面的查询

select *
from v_complaint
where company_name like 'Cattywampus%';


可以利用`company`表中`company_name`列上的索引,因为`v_complaint`视图是基于`company`表创建的。

## 总结

在本章中,你了解了如何使用视图来提供数据的自定义表示。在下一章中,你将学习如何编写函数和过程,并向其中添加逻辑,根据数据值执行特定任务。


# 第十一章:创建函数和存储过程

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在第八章,你学习了如何调用内置的 MySQL 函数;在这一章,你将编写自己的函数。你还将学习如何编写存储过程,并探索两者的主要区别。

你将通过`if`语句、循环、游标和`case`语句,向你的函数和存储过程添加逻辑,根据数据的值执行不同的任务。最后,你将练习在函数和存储过程中接收值并返回值。

## 函数与存储过程

函数和存储过程是可以通过名称调用的程序。由于它们保存在你的 MySQL 数据库中,因此有时也称为*存储*函数和存储过程。统称它们为*存储例程*或*存储程序*。当你编写一个复杂的 SQL 语句或包含多个步骤的语句时,你应该将其保存为一个函数或存储过程,以便以后可以方便地通过名称调用。

函数和存储过程的主要区别在于,函数可以从 SQL 语句中调用,并始终返回一个值。而存储过程则是通过显式的`call`语句调用的。存储过程与函数不同,它们返回值的方式也不同。(请注意,调用者可能是使用像 MySQL Workbench 这样的工具的人,或是用 Python、PHP 等编程语言编写的程序,或者是另一个 MySQL 存储过程。)存储过程可以不返回值、返回一个值或返回多个值,而函数接受参数,执行某些任务,并返回单个值。例如,你可以通过`select`语句调用`f_get_state_population()`函数,传入州名作为参数,从而得到纽约的州人口:

select f_get_state_population('New York');


你通过将参数放在括号中来向函数传递参数。如果要传递多个参数,用逗号分隔。函数接收参数,执行你在创建函数时定义的处理,然后返回一个值:

f_get_state_population('New York')

          19299981

`f_get_state_population()`函数以文本`New York`作为参数,查询数据库找到人口信息,并返回`19299981`。

你还可以在 SQL 语句的`where`子句中调用函数,例如以下示例,返回每个`state_population`大于纽约州的人口:

select *
from state_population
where population > f_get_state_population('New York');


在这里,你调用了`f_get_state_population()`函数,传入参数`New York`。该函数返回了值`19299981`,这使得你的查询结果评估为以下内容:

select *
from state_population
where population > 19299981;


你的查询返回了来自`state`表的数据,数据包含人口超过 19,299,981 的州:

state population


California 39613493
Texas 29730311
Florida 21944577


存储过程与函数不同,它们不能从 SQL 查询中调用,而是通过`call`语句调用。你传入存储过程设计时需要的任何参数,存储过程执行你定义的任务,然后控制权返回给调用者。

例如,你调用一个名为`p_set_state_population()`的过程,并传入`New York`作为参数,如下所示:

call p_set_state_population('New York');


你将在 Listing 11-2 中看到如何创建`p_set_state_population()`过程并定义其任务。目前,你只需知道这是调用过程的语法。

过程通常用于通过更新、插入和删除表中的记录来执行业务逻辑,也可以用来显示数据库中的数据集。函数则用于执行较小的任务,比如从数据库获取一条数据或格式化一个值。有时,你可以用过程或函数来实现相同的功能。

像表和视图一样,函数和过程也保存在你创建它们的数据库中。你可以使用`use`命令来设置当前数据库;然后,当你定义过程或函数时,它将被创建在该数据库中。

现在你已经了解了如何调用函数和过程,让我们来看看如何创建它们。

## 创建函数

Listing 11-1 定义了`f_get_state_population()`函数,它接受一个州的名称并返回该州的人口。

❶ use population;

❷ drop function if exists f_get_state_population;

delimiter //

❸ create function f_get_state_population(
state_param varchar(100)
)
returns int
deterministic reads sql data
begin
declare population_var int;

select  population
into    population_var

from state_population
where state = state_param;

return(population_var);

❹ end//

delimiter ;


Listing 11-1: 创建`f_get_state_population()`函数

在第一行,你通过`use`命令 ❶ 设置当前数据库为`population`,这样你的函数就会创建在该数据库中。

在创建函数之前,你需要使用`drop function`语句,以防该函数已经存在。如果你尝试创建一个函数,而旧版本已存在,MySQL 会发送一个`function already exists`的错误,并且不会创建该函数。同样,如果你尝试删除一个不存在的函数,MySQL 也会发送错误。为了避免出现该错误,你可以在`drop function`后加上`if exists` ❷,这样如果函数已存在,它将被删除,但如果不存在,则不会发送错误。

函数本身在`create function` ❸ 和 `end` 语句 ❹ 之间定义。我们将在接下来的章节中详细讲解它的组成部分。

### 重新定义分隔符

函数定义还包括几行代码,用于重新定义然后重置分隔符。*分隔符*是一个或多个字符,用来分隔一个 SQL 语句和另一个 SQL 语句,并标记每个语句的结束。通常,你会使用分号作为分隔符。

在 Listing 11-1 中,你使用`delimiter //`语句暂时将 MySQL 的分隔符设置为`//`,因为你的函数由多个以分号结尾的 SQL 语句组成。例如,`f_get_state_population()`包含三个分号,分别位于`declare`语句、`select`语句和`return`语句之后。为了确保 MySQL 从`create function`语句开始,到`end`语句结束时正确创建你的函数,你需要告诉它不要将这两个语句之间的分号视为函数的结束。这就是你重新定义分隔符的原因。

让我们看看如果你不重新定义分隔符会发生什么。如果你删除或注释掉代码开头的`delimiter //`语句,并在 MySQL Workbench 中查看它,你会注意到第 12 行和第 19 行出现了红色 X 标记,表示错误(图 11-1)。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f11001.png)

图 11-1:MySQL Workbench 显示第 12 行和第 19 行的错误

你通过在第 5 行的`delimiter`语句前添加两个短横线和空格(`--`)注释掉了该语句,这导致 MySQL Workbench 在第 12 行和第 19 行报告错误,因为分号已成为分隔符字符。因此,每当 MySQL 遇到分号时,它会认为这是 SQL 语句的结束。MySQL Workbench 试图通过显示带有红色 X 的错误标记来帮助你,让你知道以分号结尾的语句无效。

将分隔符重新定义为`//`(或其他不是`;`的字符)告诉 MySQL Workbench,在遇到第 21 行的`//`之前,创建函数的语句尚未结束。你可以通过取消注释第 5 行(移除行首的两个短横线和空格`--`),重新插入`delimiter //`命令,从而修复错误。

函数创建后,你将分隔符设置回第 23 行的分号。

尽管在这里重新定义分隔符为`//`是必要的,因为你的函数体包含了三个分号,但在其他情况下你不需要重新定义分隔符。例如,你可以简化以下函数:

delimiter //
create function f_get_world_population()
returns bigint
deterministic no sql
begin
return(7978759141);
end//

delimiter ;


`begin`和`end`关键字将函数体内的语句分组。由于这个函数体只有一条 SQL 语句,即返回世界人口,你不需要在这里使用`begin`和`end`。而且,你也不需要重新定义分隔符,因为只有一个分号——位于`return`语句的末尾。你可以删除重新定义和重置分隔符的代码,并将函数简化为以下形式:

create function f_get_world_population()
returns bigint
deterministic no sql
return(7978759141);


虽然这种写法更简洁,但你可能希望保留`begin`和`end`语句并重新定义分隔符,因为这样未来添加第二条 SQL 语句会更容易。选择权在你。

### 添加参数并返回值

内建函数和自定义函数都可以接受参数。在清单 11-1 中,你创建了`f_get_state_population()`函数,接受一个名为`state_param`的参数,其数据类型为`varchar(100)`。你可以在第四章中定义带有数据类型的参数,包括`int`、`date`、`decimal`和`text`,用于定义表的列。

因为函数会将值返回给函数的调用者,所以在清单 11-1 中,你使用`returns`关键字告诉 MySQL 你的函数将返回的值的数据类型。在这种情况下,函数将返回一个整数,表示某个州的人口。

### 指定特性

在示例 11-1 中,一旦你确定函数返回一个整数,你就可以指定该函数的一些特性。*特性*是函数的一个属性或特性。在此示例中,你使用了`deterministic`和`reads sql data`特性:

deterministic reads sql data


你可以在一行中列出所有特性,也可以将每个特性列在单独的行上,如下所示:

deterministic
reads sql data


你需要从两个特性集选择:`deterministic`或`not deterministic`,以及`reads sql data`、`modifies sql data`、`contains sql`或`no sql`。你必须为所有函数指定至少一个特性:`deterministic`、`no sql`或`reads sql data`。如果你没有这样做,MySQL 会返回错误信息并且不会创建该函数。

#### 确定性或非确定性

选择`deterministic`意味着该函数在相同的参数和数据库状态下会返回相同的值。这通常是情况。`f_get_state_population()`函数是确定性的,因为,除非数据库中的数据发生变化,否则每次调用`f_get_state_population()`并传入`New York`作为参数时,函数都会返回值`19299981`。

`not deterministic`特性意味着该函数在相同的参数和数据库状态下可能不会返回相同的值。例如,对于一个返回当前日期的函数来说,今天调用它会得到与明天调用它不同的返回值。

如果将一个非确定性函数标记为`deterministic`,调用该函数时可能会得到错误结果。如果将一个确定性函数标记为`not deterministic`,则函数可能会比必要的运行得更慢。如果你没有将函数定义为`deterministic`或`not deterministic`,MySQL 默认将其视为`not deterministic`。

MySQL 使用`deterministic`或`not deterministic`有两个目的。首先,MySQL 有一个查询优化器,它确定执行查询的最快方式。指定`deterministic`或`not deterministic`有助于查询优化器做出更好的执行选择。

其次,MySQL 有一个二进制日志,它跟踪数据库中数据的变化。二进制日志用于执行*复制*,即将来自一个 MySQL 数据库服务器的数据复制到另一个服务器(称为*副本*)。指定`deterministic`或`not deterministic`有助于 MySQL 执行这个复制过程。

#### 读取 SQL 数据,修改 SQL 数据,包含 SQL,或无 SQL

`reads sql data` `data`特性意味着该函数通过`select`语句从数据库中读取数据,但不会更新、删除或插入任何数据;而`modifies sql data`则意味着该函数确实会更新、删除或插入数据。对于过程来说,这种情况更为常见,因为过程通常用于修改数据库中的数据,而函数则较少用于此。

`contains sql` 特性意味着该函数至少包含一个 SQL 语句,但不会从数据库读取或写入任何数据,而 `no sql` 则意味着该函数不包含任何 SQL 语句。`no sql` 的例子是一个返回硬编码数字的函数,在这种情况下它不会查询数据库。例如,你可以编写一个总是返回 `212` 的函数,这样你就不需要记住水的沸点温度。

如果你没有指定 `reads sql data`、`modifies sql data`、`contains sql` 或 `no sql`,MySQL 默认使用 `contains sql`。

### 定义函数体

列出特性之后,你需要定义函数体,这是在函数被调用时执行的代码块。你使用 `begin` 和 `end` 语句来标记函数体的开始和结束。

在 列表 11-1 中,你使用 `declare` 关键字声明了一个名为 `population_var` 的变量。变量是可以保存值的命名对象。你可以使用任何 MySQL 数据类型来声明它们;在这个例子中,你使用了 `int` 类型。你将在本章稍后的“定义本地变量和用户变量”一节中了解不同类型的变量。

然后,你添加一个 `select` 语句,它从数据库中选择人口数据并将其写入 `population_var` 变量中。这个 `select` 语句类似于你之前使用过的,只是这次你使用了 `into` 关键字,将从数据库中获取的值选择到一个变量中。

然后,你使用 `return` 语句将 `population_var` 的值返回给函数的调用者。由于函数总是返回一个值,因此你的函数中必须有一个 `return` 语句。返回值的数据类型必须与函数开始时的 `returns` 语句匹配。你使用 `returns` 来声明返回值的数据类型,而使用 `return` 来实际返回值。

你的 `end` 语句后面跟着 `//`,因为你之前将分隔符重新定义为 `//`。一旦到达 `end` 语句,函数体就完成了,因此你将分隔符重新定义回分号。

## 创建过程

与函数类似,过程接受参数,包含由 `begin` 和 `end` 包围的代码块,可以定义变量,并可以重新定义分隔符。

与函数不同,过程不使用 `returns` 或 `return` 关键字,因为过程不像函数那样返回一个值。此外,你可以使用 `select` 关键字在过程中显示值。另外,虽然 MySQL 在创建函数时要求指定像 `deterministic` 或 `reads sql data` 等特性,但这对于过程来说不是必须的。

清单 11-2 创建了一个名为 `p_set_state_population()` 的存储过程,该存储过程接受一个州名作为参数,从 `county_population` 表中获取该州各县的最新人口数据,求和并将总人口写入 `state_population` 表。

❶ use population;

❷ drop procedure if exists p_set_state_population;

❸ delimiter //

❹ create procedure p_set_state_population(
❺ in state_param varchar(100)
)
begin
❻ delete from state_population
where state = state_param;

❼ insert into state_population
(
state,
population
)
select state,
❽ sum(population)
from county_population
where state = state_param
group by state;

❾ end//

delimiter ;


清单 11-2:创建 `p_set_state_population()` 存储过程

首先,你通过 `use` 将当前数据库设置为 `population`,这样存储过程将在 `population` 数据库中创建 ❶。在创建存储过程之前,你会检查该存储过程是否已经存在,如果存在,则使用 `drop` 命令删除旧版本 ❷。然后,像创建函数时一样,你将分隔符重新定义为 `//` ❸。

接下来,你创建存储过程并将其命名为 `p_set_state_population()` ❹。与函数相似,你将参数命名为 `state_param`,并赋予其 `varchar(100)` 数据类型,同时指定 `in` 以将 `state_param` 设置为输入参数 ❺。让我们更详细地看一下这一步。

与函数不同,存储过程可以接受参数值作为输入,并且还可以将值作为输出返回给调用者。存储过程还可以接受多个输入和输出参数。(你将在本章稍后深入探讨输出参数。)在编写存储过程时,你使用 `in` 关键字指定输入参数,`out` 关键字指定输出参数,或者使用 `inout` 关键字指定既是输入又是输出的参数。对于函数来说,这种指定是没有必要的,因为函数的参数默认总是作为输入。如果你没有为存储过程的参数指定 `in`、`out` 或 `inout`,MySQL 默认将其视为 `in`。

接下来,存储过程的主体位于 `begin` 和 `end` 语句之间。在该主体中,你删除 `state_population` 表中该州的现有行(如果存在) ❻,然后向 `state_population` 表中插入新的一行 ❼。如果你不先删除现有行,表中将为每次运行存储过程时都生成一行数据。你希望在写入当前信息到 `state_population` 表之前先清理干净。

你通过从 `county_population` 表中对该州所有县的人口进行求和,来获取该州的人口数据 ❽。

与函数创建时一样,当你完成存储过程的定义后,你会将分隔符重新定义为分号 ❾。

### 使用 `select` 显示值

当你创建存储过程和函数时,可以使用 `select...into` 语法将数据库中的值写入变量中。但与函数不同,存储过程还可以使用不带 `into` 关键字的 `select` 语句来显示值。

清单 11-3 创建了一个名为 `p_set_and_show_state_population()` 的存储过程,该存储过程将州的人口选择到一个变量中,并向存储过程的调用者显示一条消息。

use population;

drop procedure if exists p_set_and_show_state_population;

delimiter //

create procedure p_set_and_show_state_population(
in state_param varchar(100)
)
begin
❶ declare population_var int;

delete from state_population
where state = state_param;

❷ select sum(population)
into population_var
from county_population
where state = state_param;

❸ insert into state_population
(
state,
population
)
values
(
state_param,
population_var
);

❹ select concat(
'Setting the population for ',
state_param,
' to ',
population_var
);
end//

delimiter ;


清单 11-3:创建 `p_set_and_show_state_population()` 存储过程

在这个存储过程中,你声明了一个名为`population_var`的整数变量 ❶,并使用`select...into`语句 ❷ 将县人口的总和插入其中。然后,你将`state_param`参数值和`population_var`变量值插入到`state_population`表中 ❸。

当你调用存储过程时,它不仅会在`state_population`表中设置正确的纽约人口,还会显示一条信息性消息:

call p_set_and_show_state_population('New York');


显示的消息是:

Setting the population for New York to 20201249


你使用了`select`来显示消息,通过连接(使用`concat()`函数)文本`Setting the population for`、`state_param`值、单词`to`和`population_var`值 ❹。

### 定义局部变量和用户变量

`population_var`变量是一个局部变量。*局部变量*是你在存储过程和函数中使用`declare`命令和数据类型定义的变量:

declare population_var int;


局部变量只在包含它们的存储过程或函数执行期间有效或*在作用范围内*。由于你将`population_var`定义为`int`,它只接受整数值。

你还可以使用*用户变量*,它以 at 符号(`@`)开头,并且可以在整个会话期间使用。只要你连接到 MySQL 服务器,用户变量就会在作用范围内。例如,如果你从 MySQL Workbench 创建用户变量,它将一直有效,直到你关闭工具。

创建局部变量时,必须指定其数据类型;而创建用户变量时则不需要。

你可能会在函数或存储过程中看到同时使用局部变量和用户变量的代码:

declare local_var int;
set local_var = 2;
set @user_var = local_var + 3;


你从未为`@user_var`变量声明过数据类型,如`int`、`char`或`bool`,但因为它被设置为一个整数值(`local_var`值加上 3),MySQL 自动将其设置为`int`。

### 在存储过程中的逻辑使用

在存储过程中,你可以使用类似于 Python、Java 或 PHP 等编程语言中的编程逻辑。例如,你可以使用`if`和`case`等条件语句控制执行流,以便在特定条件下执行代码的某些部分。你还可以使用循环反复执行代码的某些部分。

#### if 语句

`if`语句是一个决策语句,当条件为真时执行特定的代码行。列表 11-4 创建了一个名为`p_compare_population()`的存储过程,用于比较`state_population`表中的人口与`county_population`表中的人口。如果人口值匹配,它返回一条消息。如果不匹配,它返回另一条消息。

use population;

drop procedure if exists p_compare_population;

delimiter //

create procedure p_compare_population(
in state_param varchar(100)
)
begin
declare state_population_var int;
declare county_population_var int;

select  population

❶ into state_population_var
from state_population
where state = state_param;

select sum(population)
❷ into county_population_var
from county_population
where state = state_param;

❸ if (state_population_var = county_population_var) then
select 'The population values match';
❹ else
select 'The population values are different';
end if;

end//

delimiter ;


列表 11-4:`p_compare_population()`存储过程

在第一个`select`语句中,你从`state_population`表中选择州的人口,并将其写入`state_population_var`变量❶。然后,在第二个`select`语句中,你从`county_population`表中选择该州每个县的人口总和,并将其写入`county_population_var`变量❷。你使用`if...then`语法比较这两个变量。你在说,`if`值匹配❸,`then`执行显示消息`The population values match`的行;`else`(否则)❹,执行下一行,显示消息`The population values are different`。然后你使用`end if`标记`if`语句的结束。

你可以使用以下`call`语句调用该过程:

call p_compare_population('New York');


结果是:

The population values are different


该过程显示两个表中的值不匹配。可能是县级人口表包含了更新的数据,但`state_population`表还没有更新。

MySQL 提供了`elseif`关键字来检查更多的条件。你可以扩展你的`if`语句,显示三个消息中的一个:

if (state_population_var = county_population_var) then
select 'The population values match';
elseif (state_population_var > county_population_var) then
select 'State population is more than the sum of county population';
else
select 'The sum of county population is more than the state population';
end if;


第一个条件检查`state_population_var`值是否等于`county_population_var`值。如果该条件为真,代码将显示文本`The population values match`,然后控制流将进入`end if`语句。

如果第一个条件不满足,代码将检查`elseif`条件,查看`state_population_var`是否大于`county_population_var`。如果该条件为真,代码显示文本`State population is more than the sum of county population`,然后控制流进入`end if`语句。

如果两个条件都不满足,控制流将进入`else`语句,代码显示`The sum of county population is more than the state population`,然后控制流下降到`end if`语句。

#### `case`语句

`case`语句是一种编写复杂条件语句的方式。例如,Listing 11-5 定义了一个使用`case`语句的过程,用于判断一个州的人口是超过 3000 万、在 1000 万到 3000 万之间,还是低于 1000 万。

use population;

drop procedure if exists p_population_group;

delimiter //

create procedure p_population_group(
in state_param varchar(100)
)
begin
declare state_population_var int;

select population
into   state_population_var
from   state_population
where  state = state_param;

case
❶ when state_population_var > 30000000 then select 'Over 30 Million';
❷ when state_population_var > 10000000 then select 'Between 10M and 30M';
❸ else select 'Under 10 Million';
end case;

end//

delimiter ;


Listing 11-5:`p_population_group()`过程

你的`case`语句以`case`开头,以`end case`结尾。它有两个`when`条件——类似于`if`语句——和一个`else`语句。

当条件`state_population_var > 30000000`为真时,过程将显示`Over 30 Million`❶,控制流将进入`end case`语句。

当条件`state_population_var > 10000000`为真时,过程将显示`Between 10M and 30M`❷,控制流将进入`end case`语句。

如果没有满足任何`when`条件,`else`语句将被执行,过程显示`Under 10 Million`❸,控制流下降到`end case`语句。

你可以调用你的过程来查看一个州属于哪个人口组:

call p_population_group('California');
Over 30 Million

call p_population_group('New York');
Between 10M and 30M

call p_population_group('Rhode Island');
Under 10 Million


根据从数据库中检索到的该州人口,`case`语句会显示该州的正确人口分组。

#### 循环

你可以在过程里创建*循环*,以反复执行代码的一部分。MySQL 允许你创建简单循环、`repeat`循环和`while`循环。这个过程使用了一个简单循环,不断显示`Looping Again`:

drop procedure if exists p_endless_loop;

delimiter //
create procedure p_endless_loop()
begin
loop
select 'Looping Again';
end loop;
end;
//
delimiter ;


现在调用这个过程:

call p_endless_loop();


你通过`loop`和`end loop`命令标记你的循环的开始和结束。它们之间的命令将被反复执行。

这个过程会不断显示文本`Looping Again`,理论上会一直持续下去。这就是所谓的*无限循环*,应该避免。你创建了循环,但没有提供停止的方式。哎呀!

如果你在 SQL Workbench 中运行这个过程,它会打开一个不同的结果标签,每次经过循环时都会显示`Looping Again`文本。幸运的是,MySQL 最终会检测到已经打开了太多结果标签,并会给你停止运行过程的选项(图 11-2)。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f11002.png)

图 11-2:在 MySQL Workbench 中运行无限循环

为了避免创建无限循环,你必须设计循环在满足某个条件时结束。这个过程使用了一个更合理的简单循环,它循环 10 次后停止:

drop procedure if exists p_more_sensible_loop;

delimiter //
create procedure p_more_sensible_loop()
begin
❶ set @cnt = 0;
❷ msl: loop
select 'Looping Again';
❸ set @cnt = @cnt + 1;
❹ if @cnt = 10 then
❺ leave msl;
end if;
end loop msl;
end;
//
delimiter ;


在这个过程里,你定义了一个用户变量`@cnt`(简写为*counter*)并将其设置为`0` ❶。你通过在`loop`语句前加上`msl:`来标记循环为`msl`(*更合理的循环*)❷。每次循环时,你将`@cnt`加 1 ❸。为了使循环结束,`@cnt`的值必须达到`10` ❹。一旦达到,你通过`leave`命令退出循环,并指定要退出的循环名`msl` ❺。

当你调用这个过程时,它会执行`loop`和`end loop`语句之间的代码 10 次,每次显示`Looping Again`。在代码执行 10 次后,循环停止,控制权会转到`end loop`语句之后的行,然后过程将控制权返回给调用者。

你还可以使用`repeat...until`语法编写一个`repeat`循环,如下所示:

drop procedure if exists p_repeat_until_loop;

delimiter //
create procedure p_repeat_until_loop()
begin
set @cnt = 0;
repeat
select 'Looping Again';
set @cnt = @cnt + 1;
until @cnt = 10
end repeat;
end;
//
delimiter ;


`repeat`和`end repeat`之间的代码是你循环的主体。它们之间的命令会被反复执行,直到`@cnt`等于`10`,然后控制权会跳转到`end`语句。`until`语句在循环的末尾,因此你的循环中的命令至少会执行一次,因为在第一次通过循环时,条件`until @cnt = 10`并没有被检查。

你还可以使用`while`和`end while`语句编写一个`while`循环:

drop procedure if exists p_while_loop;

delimiter //
create procedure p_while_loop()
begin
set @cnt = 0;
while @cnt < 10 do
select 'Looping Again';
set @cnt = @cnt + 1;
end while;
end;
//
delimiter ;


你的`while`命令指定了必须满足的条件,才能执行循环中的命令。如果条件`@cnt < 10`满足,程序将`do`循环中的命令。当达到`end while`语句时,控制流回到`while`命令,并再次检查`@cnt`是否仍小于`10`。一旦计数器不再小于 10,控制流将转到`end`命令,循环结束。

循环是一个方便的方式,当你需要一遍又一遍地执行相似任务时,可以使用它。别忘了给你的循环设置退出条件,这样你就能避免写出无休止的循环,如果你需要循环至少执行一次,可以使用`repeat...until`语法。

### 使用`select`显示过程结果

由于你可以在过程里使用`select`语句,你可以编写查询数据库并显示结果的过程。当你编写一个需要再次运行的查询时,可以将其保存为过程,并在需要时调用该过程。

比如你写了一个查询,选择所有县的总人口,并用逗号格式化它们,同时按人口从大到小排序。你可能想把这个工作保存为一个过程,并命名为`p_get_county_population()`,如下所示:

use population;

drop procedure if exists p_get_county_population;

delimiter //

create procedure p_get_county_population(
in state_param varchar(100)
)
begin
select county,
format(population, 0)
from county_population
where state = state_param
order by population desc;
end//

delimiter ;


有了这个过程,你可以在每次需要该信息时调用它:

call p_get_county_population('New York');


结果显示了纽约州的所有 62 个县,并适当地格式化了它们的人口:

Kings 2,736,074
Queens 2,405,464
New York 1,694,251
Suffolk 1,525,920
Bronx 1,472,654
Nassau 1,395,774
Westchester 1,004,457
Erie 954,236
--snip--


下次你想查看该数据的最新版本时,只需再次调用这个过程。

在你的过程里使用`select`来显示结果。你还可以通过`output`参数将值传回过程的调用者。

### 使用游标

虽然 SQL 非常擅长快速更新或删除表中的多行数据,但有时你需要循环遍历数据集并一次处理一行。你可以通过游标来实现这一点。

*游标*是一个数据库对象,它从数据库中选择行,将它们保存在内存中,并允许你一次处理一行。要使用游标,首先声明游标,然后打开它,从中获取每一行,最后关闭它。这些步骤如图 11-3 所示。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f11003.png)

图 11-3:使用游标的步骤

创建一个名为`p_split_big_ny_counties()`的过程,它使用游标。该过程将使用`county_population`表,其中包含每个州的各个县的人口。纽约州有 62 个县,最大的一些县如下所示:

county population


Kings 2,736,074
Queens 2,405,464
New York 1,694,251
Suffolk 1,525,920
Bronx 1,472,654
Nassau 1,395,774
Westchester 1,004,457


假设你是一个为纽约州工作的数据库开发者。你被要求将人口超过 200 万的县划分为两个更小的县,每个县的人口是原县的一半。

例如,Kings 县的人口为 2,736,074 人。你被要求创建一个名为`Kings-1`的县,人口为 1,368,037 人,另一个名为`Kings-2`的县,人口也是 1,368,037 人。然后,你需要删除原来的`Kings`行,原始人口为 2,736,074 人。你可以编写列表 11-6 中所示的过程来完成这个任务。

drop procedure if exists p_split_big_ny_counties;

delimiter //

create procedure p_split_big_ny_counties()
begin
❶ declare v_state varchar(100);
declare v_county varchar(100);
declare v_population int;

❷ declare done bool default false;

❸ declare county_cursor cursor for
select state,
county,
population
from county_population
where state = 'New York'
and population > 2000000;

❹ declare continue handler for not found set done = true;

❺ open county_cursor;

❻ fetch_loop: loop
fetch county_cursor into v_state, v_county, v_population;

❼ if done then
leave fetch_loop;
end if;

❽ set @cnt = 1;

❾ split_loop: loop

  insert into county_population
  (
    state,
    county,
    population
  )

values
(
v_state,
concat(v_county, '-', @cnt),
round(v_population/2)
);

  set @cnt = @cnt + 1;

  if @cnt > 2 then
    leave split_loop;
  end if;

end loop split_loop;

-- delete the original county

❿ delete from county_population where county = v_county;

end loop fetch_loop;

close county_cursor;
end;
//

delimiter ;


列表 11-6:创建`p_split_big_ny_counties()`过程

这个过程使用游标从`county_population`表中选择原始的州、县和人口值。你每次从游标中获取一行,并循环执行`fetch_loop`,直到所有行都处理完毕。让我们逐步讲解。

首先,你声明`v_state`、`v_county`和`v_population`变量,用来存储每个超过 200 万人的县的`state`、`county`和`population`值 ❶。你还声明了一个名为`done`的变量,用来判断游标是否还有更多行可获取。你将`done`变量定义为布尔型,并将其默认值设置为`false` ❷。

然后你声明一个名为`county_cursor`的游标,它的`select`语句从`county_population`表中获取所有人口超过 200 万的县:以此示例中的 Kings 和 Queens 县为例 ❸。

接下来,你声明一个条件处理程序,当游标没有更多行可读取时,它会自动将`done`变量设置为`true` ❹。*条件处理程序*定义了 MySQL 在过程运行中遇到各种情况时的响应。你的条件处理程序处理`not found`条件;如果`fetch`语句找不到更多行,过程将执行`set done = true`语句,这将把`done`变量的值从`false`改为`true`,告诉你没有更多行可获取。

当你声明一个条件处理程序时,你可以选择让它`continue`—继续执行过程,或者在处理完条件后`exit`。你在列表 11-6 中选择了`continue`。

接下来,你`open`之前声明的`county_cursor`,准备开始使用 ❺。你创建一个`fetch_loop`循环,每次从`county_cursor`中获取一行并进行迭代 ❻。之后,你将游标中的一行的州、县和人口值获取到`v_state`、`v_county`和`v_population`变量中。

你检查`done`变量 ❼。如果所有行都已从游标中获取,你将退出`fetch_loop`,控制流转到`end loop`语句之后的行。然后,你关闭游标并退出过程。

如果你还没有完成从游标中获取所有行,设置一个用户变量`@cnt`为`1` ❽。然后,你进入一个名为`split_loop`的循环,负责将县分割成两个部分 ❾。

在`split_loop`中,你向`county_population`表插入了一行数据,其中县名加上了`-1`或`-2`后缀,`population`值是原县人口的一半。`-1`或`-2`后缀由你的`@cnt`用户变量控制。你将`@cnt`初始化为`1`,每次循环通过`split_loop`时,你会将它加 1。然后,你将原县名与一个短横线和`@cnt`变量拼接。通过将原人口(保存在`v_population`变量中)除以 2,你就得到了新的县的半人口。

你可以从过程调用函数;例如,你可以使用`concat()`将后缀添加到县名,使用`round()`确保新的`population`值没有小数部分。如果原县的人口是一个奇数,你不希望新县的人口是像 1368036.5 这样的小数。

当`@cnt`变量大于 2 时,你的分割县工作的任务完成,此时你`离开`了`split_loop`,控制流跳转到`end loop split_loop`语句之后的那一行。然后,你从数据库中删除原县的记录❿。

你到达了`fetch_loop`的结束,这也标志着你完成了这个县的工作。控制流返回到`fetch_loop`的开头,开始获取并处理下一个县的数据。

现在你可以调用你的过程了

call p_split_big_ny_counties();


然后像这样查看数据库中纽约州最大的人口县:

select *
from county_population
order by population desc;


结果是:

state county population


New York New York 1694251
New York Suffolk 1525920
New York Bronx 1472654
New York Nassau 1395774
New York Kings-1 1368037
New York Kings-2 1368037
New York Queens-1 1202732
New York Queens-2 1202732
New York Westchester 1004457
--snip--


你的过程成功了!你现在拥有`Kings-1`、`Kings-2`、`Queens-1`和`Queens-2`这几个县,它们的规模是原始`Kings`和`Queens`县的一半。没有县的人口超过 200 万,原始的`Kings`和`Queens`记录已从表中删除。

### 声明输出参数

到目前为止,你在过程里使用的所有参数都是输入参数,但过程也允许你使用输出参数,将值传回给过程调用者。如前所述,调用者可以是使用像 MySQL Workbench 这样的工具的人员,或是用其他编程语言如 Python 或 PHP 编写的程序,或者是另一个 MySQL 过程。

如果过程的调用者是一个仅需查看某些值但不需要进一步处理这些值的最终用户,你可以使用`select`语句来显示这些值。但如果调用者需要使用这些值,你可以通过输出参数将它们从过程传递回来。

这个过程,叫做`p_return_state_population()`,通过输出参数将一个州的人口返回给过程调用者:

use population;

drop procedure if exists p_return_state_population;

delimiter //

create procedure p_return_state_population(
❶ in state_param varchar(100),
❷ out current_pop_param int
)
begin
❸ select population
into current_pop_param
from state_population
where state = state_param;
end//

delimiter ;


在过程内,你声明了一个名为`state_param`的`in`(输入)参数,其类型为`varchar(100)`,即一个最多包含 100 个字符的字符串❶。然后,你定义了一个名为`current_pop_param`的`out`(输出)参数,其类型为`int`❷。你将州的人口选入输出参数`current_pop_param`,因为你将其声明为`out`参数,所以它会自动返回给调用者❸。

现在,使用`call`语句调用过程并传递`New York`作为输入参数。声明你希望将过程的输出参数作为一个新的用户变量`@pop_ny`返回:

call p_return_state_population('New York', @pop_ny);


你传递给过程的参数顺序与创建过程时定义的参数顺序匹配。该过程被定义为接受两个参数:`state_param`和`current_pop_param`。当你调用该过程时,你为`state_param`输入参数提供`New York`的值。然后,你提供`@pop_ny`,这是将接受过程的`current_pop_param`输出参数值的变量名称。

你可以通过编写`select`语句来查看过程的结果,该语句显示`@pop_ny`变量的值:

select @pop_ny;


结果是:

20201249


纽约的人口已保存至`@pop_ny`用户变量中。

### 编写调用其他过程的过程

过程可以调用其他过程。例如,这里你创建了一个名为`p_population_caller()`的过程,它调用`p_return_state_population()`,获取`@pop_ny`变量的值,并对其进行一些额外处理:

use population;

drop procedure if exists p_population_caller;

delimiter //

create procedure p_population_caller()
begin
call p_return_state_population('New York', @pop_ny);
call p_return_state_population('New Jersey', @pop_nj);

set @pop_ny_and_nj = @pop_ny + @pop_nj;

select concat(
'The population of the NY and NJ area is ',
@pop_ny_and_nj);

end//

delimiter ;


`p_population_caller()`过程调用了`p_return_state_population()`过程两次:一次使用`New York`作为输入参数,返回值给`@pop_ny`变量,另一次使用`New Jersey`作为输入参数,返回值给`@pop_nj`变量。

然后,你创建了一个新的用户变量`@pop_ny_and_nj`,并用它保存纽约和新泽西的合并人口,将`@pop_ny`和`@pop_nj`相加。接着,你显示`@pop_ny_and_nj`变量的值。

使用`call`语句运行你的调用过程:

call p_population_caller();


结果是:

The population of the NY and NJ area is 29468379


从调用过程显示的总人口为 29,468,379 人,这是 20,201,249 名纽约人和 9,267,130 名新泽西人之和。

## 列出数据库中的存储过程

要获取存储在数据库中的函数和过程列表,可以查询`information_schema`数据库中的`routines`表:

select routine_type,
routine_name
from information_schema.routines
where routine_schema = 'population';


结果是:

routine_type routine_name


FUNCTION f_get_state_population
PROCEDURE p_compare_population
PROCEDURE p_endless_loop
PROCEDURE p_get_county_population
PROCEDURE p_more_sensible_loop
PROCEDURE p_population_caller
PROCEDURE p_repeat_until_loop
PROCEDURE p_return_state_population
PROCEDURE p_set_and_show_state_population
PROCEDURE p_set_state_population
PROCEDURE p_split_big_ny_counties
PROCEDURE p_while_loop


如你所见,此查询返回了`population`数据库中的函数和过程列表。

## 总结

在本章中,你学会了创建和调用过程及函数。你使用了`if`语句、`case`语句,并通过循环反复执行功能。你还看到了使用游标逐行处理数据的好处。

在下一章中,您将创建触发器,以便根据诸如行插入或删除等事件自动触发并执行处理。


# 第十二章:创建触发器

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在本章中,你将创建触发器,数据库对象,它们会在一行被插入、更新或删除之前或之后自动*触发*,执行你定义的功能。每个触发器都与一个表关联。

触发器最常用于跟踪对表的更改,或在数据被保存到数据库之前增强数据质量。

像函数和存储过程一样,触发器会保存在你创建它们的数据库中。

## 数据审计触发器

你将首先使用触发器来跟踪对数据库表的更改,方法是创建一个第二个*审计表*,记录哪个用户更改了哪条数据,并保存更改的日期和时间。

请看一下公司`accounting`数据库中的`payable`表。

payable_id company amount service


 1      Acme HVAC          123.32   Repair of Air Conditioner
 2      Initech Printers  1459.00   Printer Repair
 3      Hooli Cleaning     398.55   Janitorial Services

为了创建一个审计表来跟踪对`payable`表进行的任何更改,可以输入如下内容:

create table payable_audit
(
audit_datetime datetime,
audit_user varchar(100),
audit_change varchar(500)
);


你将创建触发器,以便在对`payable`表进行更改时,将更改的记录保存到`payable_audit`表中。你将保存更改的日期和时间到`audit_datetime`列;保存进行更改的用户到`audit_user`列;以及将更改内容的文本描述保存到`audit_change`列。

触发器可以设置在行数据更改之前或之后触发。你将创建的第一组触发器是*after*触发器。你将设置三个触发器,在`payable`表的数据发生更改后触发。

### 插入后的触发器

一个*插入后*触发器(在代码中通过后缀`_ai`表示)会在一行被插入后触发。示例 12-1 展示了如何为`payable`表创建一个插入后触发器。

use accounting;

drop trigger if exists tr_payable_ai;

delimiter //

❶ create trigger tr_payable_ai
❷ after insert on payable
❸ for each row
begin
❹ insert into payable_audit
(
audit_datetime,
audit_user,
audit_change
)
values
(
now(),
user(),
concat(
'New row for payable_id ',
❺ new.payable_id,
'. Company: ',
new.company,
'. Amount: ',
new.amount,
'. Service: ',
new.service
)
);
end//

delimiter ;


示例 12-1:创建一个插入后触发器

首先,你创建触发器并命名为`tr_payable_ai` ❶。接着,你指定`after`关键字来表示触发器应当在何时触发 ❷。在这个例子中,一行将被插入到`payable`表中,*然后*触发器将触发,将审计记录写入`payable_audit`表。

在触发器中,对于每一行 ❸ 被插入到`payable`表中时,MySQL 会执行`begin`和`end`语句之间的代码。所有触发器都会包含`for each row`语法。

你通过一个`insert`语句插入一行数据到`payable_audit`表中,该语句调用了三个函数:`now()`用于获取当前日期和时间;`user()`用于获取插入该行的用户的用户名;`concat()`用于构建一个描述插入到`payable`表中数据的字符串 ❹。

在编写触发器时,你使用`new`关键字来访问插入到表中的新值 ❺。例如,你可以通过引用`new.payable_id`来获取新的`payable_id`值,通过引用`new.company`来获取新的`company`值。

现在你已经设置了触发器,尝试向`payable`表中插入一行数据,看看新的行是否会自动在`payable_audit`表中被跟踪:

insert into payable
(
payable_id,
company,
amount,
service
)
values
(
4,
'Sirius Painting',
451.45,
'Painting the lobby'
);

select * from payable_audit;


结果显示触发器生效了。向 `payable` 表插入新行导致你的 `tr_payable_ai` 触发器被触发,从而向你的 `payable_audit` 审计表插入了一行:

audit_datetime audit_user audit_change


2024-04-26 10:43:14 rick@localhost New row for payable_id 4.
Company: Sirius Painting. Amount: 451.45.
Service: Painting the lobby


`audit_datetime` 列显示了行被插入的日期和时间。`audit_user` 列显示了插入该行的用户的用户名和主机(*主机* 是 MySQL 数据库所在的服务器)。`audit_change` 列包含了使用 `concat()` 函数构建的新增行的描述。

### 删除后触发器

现在你将编写一个 *删除后* 触发器(在代码中以 `_ad` 后缀表示),它会将从 `payable` 表中删除的任何行记录到 `payable_audit` 表中(列表 12-2)。

use accounting;

drop trigger if exists tr_payable_ad;

delimiter //

create trigger tr_payable_ad
after delete on payable
for each row
begin
insert into payable_audit
(
audit_date,
audit_user,
audit_change
)
values
(
now(),
user(),
concat(
'Deleted row for payable_id ',
❶ old.payable_id,
'. Company: ',
old.company,
'. Amount: ',
old.amount,
'. Service: ',
old.service
)
);
end//

delimiter ;


列表 12-2:创建一个删除后触发器

`delete` 触发器看起来与 `insert` 触发器类似,但有一些不同之处;即你使用了 `old` 关键字 ❶ 而不是 `new`。由于该触发器在行被删除时触发,因此列只有 `old` 值。

在你设置好删除触发器后,从 `payable` 表中删除一行,看看删除是否会记录到 `payable_audit` 表中:

delete from payable where company = 'Sirius Painting';


结果如下:

audit_datetime audit_user audit_change


2024-04-26 10:43:14 rick@localhost New row for payable_id 4.
Company: Sirius Painting. Amount: 451.45.
Service: Painting the lobby
2024-04-26 10:47:47 rick@localhost Deleted row for payable_id 4.
Company: Sirius Painting. Amount: 451.45.
Service: Painting the lobby


触发器工作了!`payable_audit` 表仍然包含你插入到 `payable` 表中的行,但你也有一行记录了删除操作。

无论是插入行还是删除行,你都将更改记录到同一个 `payable_audit` 表中。你将文本 `New row` 或 `Deleted row` 作为 `audit_change` 列值的一部分,以明确所执行的操作。

### 更新后触发器

要编写一个 *更新后* 触发器(`_au`),它将记录在 `payable` 表中更新的任何行到 `payable_audit` 表中,请在 列表 12-3 中输入代码。

use accounting;

drop trigger if exists tr_payable_au;

delimiter //

create trigger tr_payable_au
after update on payable
for each row
begin
❶ set @change_msg =
concat(
'Updated row for payable_id ',
old.payable_id
);

❷ if (old.company != new.company) then
set @change_msg =
concat(
@change_msg,
'. Company changed from ',
old.company,
' to ',
new.company
);
end if;

if (old.amount != new.amount) then
set @change_msg =
concat(
@change_msg,
'. Amount changed from ',
old.amount,
' to ',
new.amount
);
end if;

if (old.service != new.service) then
set @change_msg =
concat(
@change_msg,
'. Service changed from ',
old.service,
' to ',
new.service
);
end if;

❸ insert into payable_audit
(
audit_datetime,
audit_user,
audit_change
)
values
(
now(),
user(),
@change_msg
);

end//

delimiter ;


列表 12-3:创建一个更新后的触发器

你声明此触发器在 `payable` 表更新后触发。当你更新表中的一行时,你可以更新其中一个或多个列。你设计的更新后触发器仅显示 `payable` 表中发生变化的列值。例如,如果你没有更改 `service` 列,你就不会在 `payable_audit` 表中包含任何关于 `service` 列的文本。

你创建了一个名为 `@change_msg` 的用户变量 ❶(用于 *更改消息*),它用来构建一个包含每个更新列的列表的字符串。你检查 `payable` 表中的每个列是否发生了变化。如果旧的 `company` 列值与新的 `company` 列值不同,你会将文本 `Company changed from` `old value` `to` `new value` 添加到 `@change_msg` 变量 ❷。然后,你对 `amount` 和 `service` 列做同样的处理,调整消息文本。完成后,`@change_msg` 的值被插入到 `payable_audit` 表的 `audit_change` 列中 ❸。

设置好更新后触发器后,看看当用户更新 `payable` 表中的一行时会发生什么:

update payable
set amount = 100000,
company = 'House of Larry'
where payable_id = 3;


`payable_audit`表中的前两行数据仍然出现在结果中,同时还新增了一行,记录了`update`语句的操作:

audit_datetime audit_user audit_change


2024-04-26 10:43:14 rick@localhost New row for payable_id 4.
Company: Sirius Painting. Amount: 451.45.
Service: Painting the lobby
2024-04-26 10:47:47 rick@localhost Deleted row for payable_id 4.
Company: Sirius Painting. Amount: 451.45.
Service: Painting the lobby
2024-04-26 10:49:20 larry@localhost Updated row for payable_id 3. Company
changed from Hooli Cleaning to House of
Larry. Amount changed from 4398.55 to
100000.00


看起来有一个名为`larry@localhost`的用户更新了一行数据,将`amount`更改为$100,000,并将将支付对象的`company`更改为`House of Larry`。嗯……

## 影响数据的触发器

你还可以编写触发器,在行被更改之前触发,以更改写入表中的数据或防止插入或删除行。这有助于在将数据保存到数据库之前提高数据的质量。

在`bank`数据库中创建一个`credit`表,用来存储客户及其信用分数:

create table credit
(
customer_id int,
customer_name varchar(100),
credit_score int
);


和后触发器一样,插入前、删除前和更新前的三个触发器会在行被插入、删除或更新之前触发。

### 插入前触发器

*插入前*触发器(`_bi`)在插入新行之前触发。列表 12-4 展示了如何编写一个插入前触发器,以确保不会有低于 300 或高于 850 的信用分数(即最低信用分数和最高信用分数)被插入到`credit`表中。

use bank;

delimiter //

❶ create trigger tr_credit_bi
❷ before insert on credit
for each row
begin
❸ if (new.credit_score < 300) then
set new.credit_score = 300;
end if;

❹ if (new.credit_score > 850) then
set new.credit_score = 850;
end if;

end//

delimiter ;


列表 12-4:创建插入前触发器

首先,你将触发器命名为`tr_credit_bi` ❶,并定义为`插入前`触发器 ❷,这样它将在行插入到`credit`表之前触发。由于这是一个插入触发器,你可以利用`new`关键字检查即将插入`credit`表中的`new.credit_score`值是否小于 300。如果是这样,你将其设置为`300` ❸。对于超过 850 的信用分数,你也会做类似的检查,将其值更改为`850` ❹。

向`credit`表插入一些数据,看看你的触发器有什么效果:

insert into credit
(
customer_id,
customer_name,
credit_score
)
values
(1, 'Milton Megabucks', 987),
(2, 'Patty Po', 145),
(3, 'Vinny Middle-Class', 702);


现在查看`credit`表中的数据:

select * from credit;


结果是:

customer_id customer_name credit_score


 1       Milton Megabucks        850
 2       Patty Po                300
 3       Vinny Middle-Class      702

你的触发器起作用了。它将 Milton Megabucks 的信用分数从`987`改为`850`,并将 Patti Po 的信用分数从`145`改为`300`,这两个值在它们被插入到`credit`表之前就已发生变化。

### 更新前触发器

*更新前*触发器(`_bu`)在表更新之前触发。你已经编写了一个触发器,防止`insert`语句将信用分数设置为低于 300 或高于 850 的值,但有可能`update`语句也会将信用分数更新到这个范围之外。列表 12-5 展示了如何创建一个`before` `update`触发器来解决这个问题。

use bank;

delimiter //

create trigger tr_credit_bu
before update on credit
for each row
begin
if (new.credit_score < 300) then
set new.credit_score = 300;
end if;

if (new.credit_score > 850) then
set new.credit_score = 850;
end if;

end//

delimiter ;


列表 12-5:创建更新前触发器

更新一行以测试你的触发器:

update credit
set credit_score = 1111
where customer_id = 3;


现在查看`credit`表中的数据:

select * from credit;


结果是:

customer_id customer_name credit_score


 1       Milton Megabucks        850
 2       Patty Po                300
 3       Vinny Middle-Class      850

它起作用了。触发器不允许你将`Vinny Middle-Class`的信用分数更新为`1111`。相反,在更新该行数据之前,它将值设置为`850`。

### 删除前触发器

最后,一个*删除前*触发器(`_bd`)会在行被从表中删除之前触发。你可以使用删除前触发器作为检查,在允许删除行之前进行验证。

假设你的银行经理要求你编写一个触发器,防止用户删除`credit`表中信用评分超过 750 的客户。你可以通过编写一个删除前触发器来实现这一点,如列表 12-6 所示。

use bank;

delimiter //

create trigger tr_credit_bd
before delete on credit
for each row
begin
❶ if (old.credit_score > 750) then
signal sqlstate '45000'
set message_text = 'Cannot delete scores over 750';
end if;
end//

delimiter ;


列表 12-6:创建一个删除前触发器

如果你即将删除的行的信用评分超过 750,触发器会返回错误❶。你使用`signal`语句来处理错误返回,后面跟着`sqlstate`关键字和代码。*sqlstate 代码*是一个五个字符的代码,用于标识特定的错误或警告。由于你正在创建自己的错误,你使用`45000`,它代表一个用户定义的错误。然后,你定义`message_text`来显示你的错误信息。

通过从`credit`表中删除一些行来测试你的触发器:

delete from credit where customer_id = 1;


由于客户`1`的信用评分为 850,因此结果是:

Error Code: 1644. Cannot delete scores over 750


你的触发器起作用了。它阻止了删除该行,因为信用评分超过了 750。

现在删除客户`2`的行,他的信用评分为 300:

delete from credit where customer_id = 2;


你会收到一条消息,告知你该行已被删除:

1 row(s) affected.


你的触发器按预期工作。它允许你删除客户`2`的行,因为他们的信用评分不超过 750,但阻止你删除客户`1`的行,因为他们的信用评分超过了 750。

## 总结

在本章中,你创建了触发器,这些触发器会自动触发并执行你定义的任务。你了解了前触发器和后触发器之间的区别,以及每种触发器的三种类型。你使用触发器来跟踪表的变化,防止特定行被删除,并控制允许值的范围。

在下一章中,你将学习如何使用 MySQL 事件来调度任务。


# 第十三章:创建事件

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在本章中,你将创建*事件*。这些事件也称为调度事件,是根据设置的时间表触发的数据库对象,执行你在创建事件时定义的功能。

事件可以安排为一次性触发或按某个间隔触发,如每天、每周或每年;例如,你可能会创建一个事件来执行每周的工资处理。你可以使用事件安排在非高峰时间进行长时间运行的处理,比如基于当天订单更新账单表。有时你会安排在非高峰时间执行事件,因为你的功能需要在特定时间发生,比如在夏令时开始的凌晨 2 点对数据库进行更改。

## 事件调度器

MySQL 有一个*事件调度器*,用于管理事件的调度和执行。事件调度器可以启用或禁用,但默认情况下应启用。要确认调度器是否启用,可以运行以下命令:

show variables like 'event_scheduler';


如果你的调度器已启用,结果应如下所示:

Variable_name Value


event_scheduler ON


如果显示的`Value`是`OFF`,你(或数据库管理员)需要使用以下命令启用调度器:

set global event_scheduler = on;


如果返回的`Value`是`DISABLED`,说明你的 MySQL 服务器在启动时禁用了事件调度器。有时这样做是为了临时停止调度器。你仍然可以调度事件,但在调度器重新启用之前,事件不会被触发。如果事件调度器被禁用,必须通过数据库管理员管理的配置文件来进行更改。

## 创建没有结束日期的事件

在清单 13-1 中,你创建了一个事件,该事件会从`payable_audit`表中移除旧行,`payable_audit`表位于`bank`数据库中。

use bank;

drop event if exists e_cleanup_payable_audit;

delimiter //

❶ create event e_cleanup_payable_audit
❷ on schedule every 1 month
❸ starts '2024-01-01 10:00'
❹ do
begin
❺ delete from payable_audit
where audit_datetime < date_sub(now(), interval 1 year);
end //

delimiter ;


清单 13-1:创建一个每月事件

要在`bank`数据库中创建事件,首先使用`use`命令将当前数据库设置为`bank`。然后,删除该事件的旧版本(如果存在),以便创建新的事件。接下来,你创建事件`e_cleanup_payable_audit` ❶,并设置一个每月运行一次的调度。

每个事件都以`on schedule`开始;对于一次性事件,后面跟上`at`关键字和事件触发的时间戳(日期和时间)。对于定期事件,`on schedule`后面应该跟上`every`一词以及触发的间隔。例如,`every 1 hour`、`every 2 week`或`every 3 year`。(间隔以单数形式表示,如`3 year`,而不是`3 years`。)在这种情况下,你指定`every 1 month` ❷。你还将定义定期事件的`start`和`end`时间。

对于你的事件,你将`start`定义为`2024-01-01 10:00` ❸,这意味着事件将在 2024 年 1 月 1 日上午 10 点开始触发,并将在每月的这个时间触发。你没有使用`ends`关键字,因此该事件将每月触发——理论上是永久的——直到使用`drop event`命令删除该事件。

然后,使用 `do` 命令 ❹ 定义事件的操作,并在事件体内添加执行功能的 SQL 语句。事件体以 `begin` 开始,以 `end` 结束。在这里,你删除 `payable_audit` 表中超过一年的行 ❺。虽然这里只使用了一个语句,但也可以在事件体中放置多个 SQL 语句。

`show events` 命令显示当前数据库中计划的事件列表,如图 13-1 所示。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f13001.png)

图 13-1:MySQL Workbench 中显示的 `show events` 命令

定义事件的用户账户被列为定义者。这为你提供了审计跟踪,告诉你是谁安排了哪些事件。

要仅显示特定数据库的事件(即使你当前不在该数据库中),可以使用 `show events in` `database` 命令。例如,在本例中,命令将是 `show events in bank`。

要获取所有数据库中的所有事件列表,可以使用以下查询:

select * from information_schema.events;


MySQL 提供了 `information_schema` 数据库中的 `events` 表,你可以查询该表来实现此目的。

## 创建具有结束日期的事件

对于需要在有限时间内运行的事件,可以使用 `ends` 关键字。例如,你可能想要创建一个事件,每年 1 月 1 日从上午 9 点到下午 5 点,每小时运行一次:

on schedule every 1 hour
starts '2024-01-01 9:00'
ends '2024-01-01 17:00'


要安排一个在接下来的 1 小时内每 5 分钟运行一次的事件,你可以输入以下内容:

on schedule every 5 minute
starts current_timestamp
ends current_timestamp + interval 1 hour


你立即启动了事件。它将每 5 分钟触发一次,并将在 1 小时后停止触发。

有时候你需要一个事件在特定的日期和时间只触发一次。例如,你可能需要等到午夜过后,再进行一次性的账户更新,更新你的 `bank` 数据库,以便其他进程先计算利率。你可以这样定义一个事件:

use bank;

drop event if exists e_account_update;

delimiter //

create event e_account_update
on schedule at '2024-03-10 00:01'
do
begin
call p_account_update();
end //

delimiter ;


你的 `e_account_update` 事件计划在 2024 年 3 月 10 日午夜过后 1 分钟执行。

当时钟切换到夏令时时,安排一次性事件可能会很有用。例如,在 2024 年 3 月 10 日,时钟将提前一小时。而在 2024 年 11 月 6 日,夏令时结束,时钟将回拨一小时。在许多数据库中,数据可能需要进行相应的更改。

安排一个一次性事件,在 2024 年 3 月 10 日,数据库在夏令时开始时进行更改。在当天 2 点,系统时钟将变为 3 点。将事件安排在时钟变化前 1 分钟:

use bank;

drop event if exists e_change_to_dst;

delimiter //

create event e_change_to_dst
on schedule
at '2024-03-10 1:59'
do
begin
-- Make any changes to your application needed for DST
update current_time_zone
set time_zone = 'EDT';
end //

delimiter ;


你不必熬夜到凌晨 1:59 才能更改时钟,你可以安排一个事件来为你执行这项操作。

## 检查错误

在事件运行后检查错误,可以查询 `performance_schema` 数据库中的一个名为 `error_log` 的表。

`performance_schema` 数据库用于监控 MySQL 的性能。`error_log` 表存储诊断信息,如错误、警告和 MySQL 服务器启动或停止的通知。

例如,你可以通过查找`data`列包含`Event Scheduler`文本的行来检查所有事件错误:

select *
from performance_schema.error_log
where data like '%Event Scheduler%';


此查询查找表中所有`data`列包含`Event Scheduler`文本的行。回顾第七章,`like`操作符可以检查字符串是否匹配某个模式。在这里,你使用`%`通配符来检查`data`列的值是否以任意字符开头,包含`Event Scheduler`文本,然后以任意字符结尾。

要查找特定事件的错误,可以搜索事件名称。假设`e_account_update`事件调用了名为`p_account_update()`的过程,但该过程并不存在。你可以像这样找到`e_account_update`事件的错误:

select *
from performance_schema.error_log
where data like '%e_account_update%';


该查询返回一行,显示`logged`列,记录事件触发时的日期和时间,`data`列显示错误信息(图 13-2)。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f13002.png)

图 13-2:在 MySQL Workbench 中显示事件错误

该消息告诉你,`bank`数据库中的`e_account_update`事件失败,因为`p_account_update`不存在。

你可以使用`alter`命令禁用事件:

alter event e_cleanup_payable_audit disable;


该事件不会再次触发,直到你重新启用它,如下所示:

alter event e_cleanup_payable_audit enable;


当事件不再需要时,你可以使用`drop event`命令将其从数据库中删除。

## 总结

在本章中,你安排了事件在一次性和定期的基础上触发。你学习了如何检查事件调度器中的错误,禁用并删除事件。下一章将重点介绍一些技巧和窍门,帮助提升 MySQL 的生产力和使用体验。


# 第四部分

高级主题

在第四部分,你将学习如何从文件加载数据,如何从脚本文件运行 MySQL 命令,如何避免常见的陷阱,以及如何在编程语言中使用 MySQL。

在第十四章,我们将介绍一些避免常见问题的技巧,你将学习如何从文件加载数据。你还会了解如何使用事务和 MySQL 命令行客户端。

在第十五章,你将学习如何在 PHP、Python 和 Java 等编程语言中使用 MySQL。


# 第十四章:提示与技巧

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在本章中,你将通过回顾常见的陷阱及其避免方法,增强你对 MySQL 技能的信心。然后,你将学习事务和 MySQL 命令行客户端。你还将学习如何从文件中加载数据或将数据加载到文件中。

## 常见错误

MySQL 可以非常快速地处理大量信息。你可以在眨眼之间更新成千上万行。虽然这给你带来了很大的能力,但也意味着更容易出现错误,比如在错误的数据库或服务器上运行 SQL,或运行部分 SQL 语句。

### 在错误的数据库中工作

在使用像 MySQL 这样的关系型数据库时,你需要时刻注意自己在使用哪个数据库。将 SQL 语句运行在错误的数据库中是一个相当常见的错误。让我们来看看一些可以避免它的方法。

假设你被要求创建一个名为`distribution`的新数据库,并创建一个名为`employee`的表。

你可能会使用以下 SQL 命令:

create database distribution;

create table employee
(
employee_id int primary key,
employee_name varchar(100),
tee_shirt_size varchar(3)
);


如果你使用 MySQL Workbench 来运行这些命令,你会看到下方面板中有两个绿色勾号,告诉你数据库和表已经成功创建(图 14-1)。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f14001.png)

图 14-1:你使用 MySQL Workbench 在`distribution`数据库中创建了一个`employee`表……对吧?

一切看起来都很顺利,因此你宣布任务完成并开始下一个任务。然后你开始接到电话,说表格没有创建。到底哪里出了问题?

尽管你创建了`distribution`数据库,但在创建表之前没有将当前数据库设置为`distribution`。你的新`employee`表实际上是在你当时所在的任何当前数据库中创建的。你应该在创建表之前使用`use`命令,像这样:

create database distribution;

use distribution;

create table employee
(
employee_id int primary key,
employee_name varchar(100),
tee_shirt_size varchar(3)
);


避免在错误数据库中创建表的一种方法是*完全限定*表名。你可以指定创建表所在的数据库名称,即使你当前不在那个数据库中,表也会在那里创建。在此,你指定要在`distribution`数据库中创建`employee`表:

create table distribution.employee


你可以通过在创建表之前检查当前数据库来避免将表创建在错误的数据库中,方法如下:

select database();


如果你的结果不是`distribution`,这将提醒你忘记使用`use`命令正确设置当前数据库。

你可以通过查明`employee`表创建在哪个数据库中,删除该表,并在`distribution`数据库中重新创建`employee`表来修复这个错误。

要确定哪个数据库或数据库中有`employee`表,请运行以下查询:

select table_schema,
create_time
from information_schema.tables
where table_name = 'employee';


你查询了`information_schema`数据库中的`tables`表,并选择了`create_time`列来查看该表是否是最近创建的。输出如下:

TABLE_SCHEMA CREATE_TIME


 bank     2024-02-05 14:35:00

可能在多个数据库中都有名为`employee`的表。如果是这种情况,你的查询将返回多行。但在这个例子中,唯一有`employee`表的数据库是`bank`,所以表被错误地创建在这里。

作为额外的检查,查看`employee`表中有多少行:

use bank;

select count(*) from employee;

count(*)

0

这个表中没有任何行,这对于一个错误创建的表来说是可以预期的。你已经确认`bank`数据库中的`employee`表是你错误创建在错误位置的那个表,现在你可以运行这些命令来纠正你的错误:

use bank;

-- Remove the employee table mistakenly created in the bank database
drop table employee;

use distribution;

-- Create the employee table in the bank database
create table employee
(
employee_id int primary key,
employee_name varchar(100),
tee_shirt_size varchar(3)
);


你在`bank`数据库中的`employee`表已经被删除,而在`distribution`数据库中创建了一个`employee`表。

你本可以使用`alter table`命令将表从一个数据库移动到另一个数据库,像这样:

alter table bank.employee rename distribution.employee;


最好是删除并重新创建表,而不是修改表,特别是如果表中有触发器或外键关联,可能仍然指向错误的数据库。

### 使用错误的服务器

有时,SQL 语句可能会在错误的 MySQL 服务器上执行。公司通常会为生产和开发设置不同的服务器。*生产*环境是最终用户访问的实时环境,因此你需要小心其数据。*开发*环境是开发者测试新代码的地方。由于该环境中的数据只有开发者可见,因此你应该始终在这里测试 SQL 语句,然后再发布到生产环境。

开发者有两个窗口同时打开的情况并不罕见:一个连接到生产服务器,另一个连接到开发服务器。如果不小心,你可能会在错误的窗口中进行更改。

如果你使用的是像 MySQL Workbench 这样的工具,建议将连接命名为*生产*和*开发*,这样它的标签可以清晰地标明哪个环境是哪个(见图 14-2)。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f14002.png)

图 14-2:命名 MySQL Workbench 标签为开发和生产

要在 MySQL Workbench 中命名连接,请进入**数据库**▶**管理连接**。在打开的设置新连接窗口中,输入一个连接名称,如 Development 或 Production,以指定环境。

其他工具也有类似的方式来标记生产和开发环境。有些允许你更改背景色,因此你可以考虑将生产环境的背景色设置为红色,提醒自己在该环境下要小心。

### 离开不完整的 `where` 子句

当你插入、更新或删除表中的数据时,确保你的`where`子句完整至关重要。如果不完整,你可能会更改到不希望更改的行。

假设你经营一家二手车经销商,并且你将库存中的车辆存储在`inventory`表中。查看表中的内容:

select * from inventory;


结果是:

vin mfg model color


1ADCQ67RFGG234561 Ford Mustang red
2XBCE65WFGJ338565 Toyota RAV4 orange
3WBXT62EFGS439561 Volkswagen Golf black
4XBCX68RFWE532566 Ford Focus green
5AXDY62EFWH639564 Ford Explorer yellow
6DBCZ69UFGQ731562 Ford Escort white
7XBCX21RFWE532571 Ford Focus black
8AXCL60RWGP839567 Toyota Prius gray
9XBCX11RFWE532523 Ford Focus red


看着场地上的一辆福特福克斯,你注意到它被列为绿色,但它的颜色实际上更接近蓝色。你决定在数据库中更新它的颜色(清单 14-1)。

update inventory
set color = 'blue'
where mfg = 'Ford'
and model = 'Focus';


清单 14-1:`update`语句中`where`子句缺少条件

当你运行`update`语句时,你惊讶地看到 MySQL 返回了`3 row(s) affected`的消息。你原本只打算更新一行,但似乎有三行被更改了。

你运行查询以查看发生了什么:

select *
from inventory
where mfg = 'Ford'
and model = 'Focus';


结果是:

4XBCX68RFWE532566 Ford Focus blue
7XBCX21RFWE532571 Ford Focus blue
9XBCX11RFWE532523 Ford Focus blue


因为`update`语句中的`where`子句缺少条件,你错误地将表中所有福特福克斯的颜色更新为`blue`。

你在清单 14-1 中的`update`语句应该是:

update inventory
set color = 'blue'
where mfg = 'Ford'
and model = 'Focus'
and color = 'green';


最后一行在清单 14-1 中缺失。添加这个条件后,`update`语句只会将*绿色*福特福克斯更新为蓝色。因为你场地上只有一辆绿色福特福克斯,所以只会更新正确的车辆。

更高效的更新方式是使用`where`子句中的 VIN(车辆识别号码):

update inventory
set color = 'blue'
where vin = '4XBCX68RFWE532566';


由于每辆车都有独特的 VIN,采用这种方法,你可以确保`update`语句只会更新一辆车。

这两个`update`语句都提供了足够的条件来识别你想要更改的那一行,因此你只会更新该行。

在插入、更新或删除行之前,你可以执行一个简单的完整性检查,即使用相同的`where`子句从表中`select`。例如,如果你计划运行清单 14-1 中的`update`语句,你应该首先运行这个`select`语句:

select *
from inventory
where mfg = 'Ford'
and model = 'Focus';


结果应该是:

vin mfg model color


4XBCX68RFWE532566 Ford Focus green
7XBCX21RFWE532571 Ford Focus black
9XBCX11RFWE532523 Ford Focus red


查询会生成你即将更新的行列表。如果你确实想更新所有三行,那么你可以运行使用相同`where`子句的`update`语句。在这种情况下,你会发现`select`语句中的`where`子句匹配了太多行,能够避免更新超过你预期的单行。

### 运行部分 SQL 语句

MySQL Workbench 有三个闪电图标,可以用于以不同方式执行 SQL 语句。每个图标的操作列在表 14-1 中。

表 14-1:MySQL Workbench 中的闪电图标

| 简单闪电图标 ![i14001](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/i14001.png) | 执行选定的语句,或者如果没有选定语句,则执行所有语句 |
| --- | --- |
| 光标闪电图标 ![i14002](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/i14002.png) | 执行键盘光标下的语句 |
| 放大镜闪电图标 ![i14003](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/i14003.png) | 执行光标下语句的 EXPLAIN 计划 |

大多数 MySQL Workbench 用户会使用简单和光标闪电图标来进行日常工作。放大镜闪电图标使用得较少,因为它是一个优化工具,用于解释 MySQL 执行查询时将采取的步骤。

如果你使用简单的闪电图标而没有注意到 SQL 语句的部分被高亮显示,你会无意中执行被高亮的部分。例如,假设你想从`inventory`表中删除一辆丰田普锐斯。你写了以下`delete`语句来删除具有普锐斯 VIN 的那辆车:

delete from inventory
where vin = '8AXCL60RWGP839567';


现在,你将使用 MySQL Workbench 来执行你的`delete`语句(图 14-3)。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f14003.png)

图 14-3:使用 MySQL Workbench 错误地删除表中的所有行

当你点击简单的闪电图标时,MySQL 告诉你表中的所有行都已被删除。发生了什么?

在执行`delete`语句之前,你错误地高亮了 SQL 命令的第一行。这导致 MySQL 删除了表中的所有行,而不是你原本想要删除的那一行。

## 事务

通过将语句作为事务的一部分执行,你可以减少错误的发生概率。一个*事务*是一组可以被*提交*(使永久)或*回滚*(取消)的一个或多个 SQL 语句。例如,在更新`inventory`表之前,你可以使用`start transaction`命令开始一个事务,稍后可以提交或回滚该事务:

start transaction;

update inventory
set color = 'blue'
where mfg = 'Ford'
and model = 'Focus';


`begin`命令是`start transaction`的别名,你可以使用任意一个。

如果你运行`update`语句时,MySQL 返回`3 row(s) affected`的消息,但你本来预期只有一行被修改,你可以回滚事务:

rollback;


你的`update`语句被回滚,所有更改都被取消,表中的行保持不变。要使更改生效,请提交事务:

commit;


在使用数据操作语言(DML)语句,如`insert`、`update`或`delete`时,事务是很有帮助的。数据定义语言(DDL)语句,如`create function`、`drop procedure`或`alter table`,不应在事务中执行。这些语句无法回滚,执行时会自动提交事务。

在你提交或回滚`update`语句之前,MySQL 会保持表的锁定。例如,如果你运行以下命令:

start transaction;

update inventory
set color = 'blue'
where mfg = 'Ford'
and model = 'Focus';


`inventory`表将保持锁定,直到你提交或回滚更改,其他用户无法修改表中的数据。如果你开始事务后去吃午餐,却没有提交或回滚,可能会回来面对一些愤怒的数据库用户。

## 支持现有系统

你可能会遇到需要支持一个已经开发好的 MySQL 系统。理解现有系统的一个好方法是通过 MySQL Workbench 浏览其数据库对象(图 14-4)。

你可以通过使用 MySQL 的导航面板,了解现有系统的许多信息。数据库里是有许多包含少量表的数据库,还是有一个或两个包含大量表的数据库?你应该遵循什么命名规范?是否有许多存储过程,还是大部分业务逻辑是通过像 PHP 或 Python 这样的编程语言在 MySQL 外部处理的?大部分表是否已经设置了主键和外键?是否使用了很多触发器?查看存储过程、函数和触发器时,使用了什么分隔符?检查现有数据库对象,并在你为系统添加任何新代码时遵循它们的命名规范。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f14004.png)

图 14-4:探索现有的 MySQL 数据库

有时,支持现有系统最困难的部分是理解应用程序的问题集和术语。你可以提出的第一个好问题是:“最重要的表是什么?”首先将注意力集中在了解这些表上。你可以从表中选择,并理解这些主键值如何唯一地标识表中的行。检查这些表上的触发器,查看触发器代码,了解当表中的数据发生更改时会自动执行哪些操作。

MySQL Workbench 还以图形化的方式展示了 MySQL 对象之间的关系。例如,你可以在图 14-4 中看到,数据库包含表、存储过程和函数。表包含列、索引、外键和触发器。

## 使用 MySQL 命令行客户端

MySQL 命令行客户端`mysql`允许你从计算机的命令行界面(通常叫做*控制台*、*命令提示符*或*终端*)运行 SQL 命令。这在你希望对 MySQL 数据库执行 SQL 语句,但又不需要像 MySQL Workbench 这样的图形用户界面时非常有用。

在你计算机的命令行界面输入`mysql`来启动 MySQL 命令行客户端工具,并提供更多信息,例如:

mysql --host localhost --database investment --user rick --password=icu2


你也可以使用单个字母选项并且只加一个短横线——例如,使用`-h`代替`--host`;`-D`代替`--database`;`-u`和`-p`分别代替`--user`和`--password=`。

你可以通过`--host`指定 MySQL 服务器所在的主机。在这个例子中,MySQL 服务器安装在我的计算机上,所以我提供了`localhost`作为值。如果你要连接的是安装在另一台计算机上的服务器,可以指定该主机,例如`--host www.nostarch.com`,或者提供一个 IP 地址。

然后,在`--database`后输入你想要连接的数据库名称,在`--user`后输入你的 MySQL 用户 ID,在`--password=`后输入你的 MySQL 密码。

你应该看到以下警告:

[Warning] Using a password on the command line interface can be insecure.


这是因为你以明文形式提供了数据库密码。这样做并不好,因为任何在你肩膀后面的人都能看到你的密码。更安全的做法是让 `mysql` 提示你输入密码。如果你在命令行中使用 `-p` 而不指定密码,工具会提示你输入密码。当你输入密码时,星号将显示出来:

mysql -h localhost -D investment -u rick -p
Enter password: ********


另一种方法是使用 MySQL 配置工具来安全地存储你的凭证:

mysql_config_editor set --host=localhost --user=investment --password
Enter password: ****


你可以通过 `--host` 和 `--user` 选项来指定主机和用户。`--password` 选项允许你输入密码。

一旦你保存了凭证,你可以使用 `print --all` 选项来显示它们:

mysql_config_editor print --all


密码会以星号的形式显示:

[client]
user = "investment"
password = ****
host = "localhost"


现在,你可以在命令行中输入 `mysql` 进入 MySQL 命令行客户端,而不需要输入用户名、密码或主机:

mysql -D investment


换句话说,你只需提供数据库的名称即可登录到 MySQL。

你可能会想,既然有更复杂的图形工具如 MySQL Workbench,为什么还要使用像 `mysql` 这样的基于文本的工具。`mysql` 工具特别有用当你想要运行保存在脚本文件中的 SQL 语句时。*脚本文件* 是一组 SQL 命令,保存在你计算机上的文件中。例如,你可以创建一个名为 *max_and_min_indexes.sql* 的文件,文件中包含以下 SQL 语句,用来获取市场指数中最小和最大的值:

use investment;

select *
from market_index
where market_value =
(
select min(market_value)
from market_index
);

select *
from market_index
where market_value =
(
select max(market_value)
from market_index
);


然后,你可以使用 `mysql` 从命令行运行 SQL 脚本:

mysql –h localhost -D investment -u rick -picu2 < min_and_max.sql > min_and_max.txt


你使用 `<` 让 `mysql` 从 *min_and_max.sql* 脚本中获取输入,使用 `>` 让它将输出写入 *min_and_max.txt* 文件。如果你提供密码,这里是 `icu2`,不要在 `-p` 后加空格。奇怪的是,`-picu2` 可用,但 `-p icu2` 不行。

执行命令后,输出文件 *min_and_max.txt* 应该如下所示:

market_index market_value
S&P 500 4351.77
market_index market_value
Dow Jones Industrial Average 34150.66


`mysql` 工具在文件的列之间插入一个制表符。

## 从文件加载数据

通常情况下,你会以文件的形式获取数据,例如接受来自其他组织的数据流。`load data` 命令从文件中读取数据并将其写入表中。

为了测试从文件加载数据到表中,我在我的计算机上创建了一个数据文件,名为 *market_indexes.txt*,存放在 *C:\Users\rick\market\* 目录中。文件内容如下:

Dow Jones Industrial Average 34150.66
Nasdaq 13552.93
S&P 500 4351.77


文件包含了三个金融市场指数的名称和当前值。它是 *制表符分隔* 的,这意味着文件中的字段由制表符分隔。

在 MySQL 中,将文件加载到表中,如下所示:

use investment;

load data local
infile 'C:/Users/rick/market/market_indexes.txt'
into table market_index;


你使用 `load data` 命令并指定 `local`,这会告诉 MySQL 在你的本地计算机上查找数据文件,而不是在安装了 MySQL 的服务器上。默认情况下,`load data` 会加载以制表符分隔的文件。

在 `infile` 关键字之后,你提供要加载的输入文件名。在这个例子中,你使用的是 Windows 计算机上的文件路径。为了指定 Windows 上文件所在的目录,使用正斜杠,因为反斜杠会导致错误。在 Mac 或 Linux 环境中加载文件时,照常使用正斜杠。

看一下加载到表中的数据:

select * from market_index;


结果是:

market_index market_value


Dow Jones Industrial Average 34150.66
Nasdaq 13552.93
S&P 500 4351.77


文件中有两个字段,表中有两列,所以左边的字段加载到表的第一列,右边的字段加载到表的第二列。

另一种常见的数据文件格式是*逗号分隔值(CSV)*文件。你可以加载一个名为*market_indexes.csv*的数据文件,内容如下:

Dow Jones Industrial Average, 34150.66
Nasdaq, 13552.93
S&P 500, 4351.77


要加载这个文件,添加语法 `fields terminated by ","` 来声明该文件中的分隔符为逗号。MySQL 使用数据文件中的逗号来标识字段的开始和结束。

load data local
infile 'C:/Users/rick/market/market_indexes.csv'
into table market_index
fields terminated by ",";


偶尔,你需要加载一个包含标题行的数据文件,如下所示:

Financial Index, Current Value
Dow Jones Industrial Average, 34150.66
Nasdaq, 13552.93
S&P 500, 4351.77


你可以通过使用 `ignore` 关键字让 `load data` 跳过标题行:

load data local
infile 'C:/Users/rick/market/market_indexes.csv'
into table market_index
fields terminated by ","
ignore 1 lines;


数据文件中有一行标题,所以你使用 `ignore 1 lines` 语法来防止第一行加载到表中。三行数据被加载,但数据文件中的 `Financial Index` 和 `Current Value` 标题被忽略。

## 加载数据到文件

你可以通过发送数据文件将数据提供给其他部门或组织。将数据库中的数据写入文件的一种方式是使用 `select...into outfile` 语法。你可以运行查询并将结果保存到文件中,而不是显示在屏幕上。

你可以指定希望使用的分隔符来格式化输出。创建一个 CSV 文件,包含 `market_index` 表中的值,如下所示:

select * from market_index
into outfile 'C:/ProgramData/MySQL/MySQL Server 8.0/Uploads/market_index.csv'
fields terminated by ',' optionally enclosed by '"';


你从 `market_index` 表中选择所有值,并将它们写入主机计算机上的*market_index.csv*文件,该文件位于 *C:/ProgramData/MySQL/MySQL Server 8.0/Uploads* 目录中。

你通过使用语法 `fields terminated by ','`,在输出文件中使用逗号作为分隔符。

`optionally enclosed by` `'"'` 这一行告诉 MySQL 将所有具有 `string` 数据类型的列字段用引号括起来。

你的*market_index.csv*文件是这样创建的:

"Dow Jones Industrial Average",34150.66
"Nasdaq",13552.93
"S&P 500",4351.77


`select...into outfile` 语法只能在 MySQL 运行的服务器上创建文件,不能在你的本地计算机上创建文件。

## MySQL Shell

虽然 MySQL 命令行客户端(`mysql`)是一种经过验证的、已经使用了数十年的 SQL 命令执行方式,但 MySQL Shell(`mysqlsh`)是一个较新的 MySQL 命令行客户端工具,可以运行 SQL、Python 或 JavaScript 命令。

你之前看到的,使用 `mysql` 语法运行名为*min_and_max.sql*的脚本是:

mysql –h localhost -D investment -u rick -picu2 < min_and_max.sql > min_and_max.txt


如果你愿意,可以使用 MySQL Shell 通过以下命令运行相同的脚本:

mysqlsh --sql –h localhost -D investment -u rick -picu2 < min_and_max.sql > min_and_max.txt


语法类似,唯一的区别是你调用的是`mysqlsh`而不是`mysql`。此外,由于`mysqlsh`可以在 SQL、Python 或 JavaScript 模式下运行,你需要指定`--sql`来运行 SQL 模式。(默认模式是 JavaScript。)

MySQL Shell 自带一个名为*parallel table import*(`import-table`)的实用工具,可以比`load data`更快地将大数据文件加载到表中。

mysqlsh ❶ --mysql -h localhost -u rick -picu2 ❷ -- util import-table c:\Users
\rick\market_indexes.txt --schema=investment --table=market_index


当你使用`import-table`工具时,需要用`--mysql`语法❶来调用`mysqlsh`,以使用经典的 MySQL 协议连接进行客户端和 MySQL 服务器之间的通信。

要运行并行表导入工具,请使用`-- util`语法,然后提供你想要使用的工具名称——在这种情况下是`import-table`❷。你需要提供要加载的文件名称,*c:\Users\rick\market_indexes.txt*,以及数据库`investment`和你想要将数据加载到的表`market_index`。

使用`mysql`还是`mysqlsh`由你自己决定。随着`mysqlsh`的逐渐成熟,更多的开发者将转向使用它,而不再使用`mysql`。如果你有一个数据量大且运行缓慢的任务,使用`mysqlsh`的并行表导入工具会比使用`load data`快得多。

你可以在[`dev.mysql.com/doc/mysql-shell/8.0/en/`](https://dev.mysql.com/doc/mysql-shell/8.0/en/)了解更多关于 MySQL Shell 的信息。

## 概述

在本章中,你学习了一些技巧和窍门,包括如何避免常见错误、使用事务、支持现有系统以及如何从文件中加载数据。

在下一章中,你将学习如何从 PHP、Python 和 Java 等编程语言调用 MySQL。


# 第十五章:从编程语言调用 MySQL

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在本章中,你将编写使用 MySQL 的计算机程序,重点介绍三种开源编程语言:PHP、Python 和 Java。你将在每种语言中编写程序,进行从表格中选择数据、向表格插入行以及调用存储过程等操作。

无论使用哪种编程语言,调用 MySQL 的基本步骤都是相同的。首先,你需要使用 MySQL 数据库凭证建立与 MySQL 数据库的连接,其中包括 MySQL 服务器的主机名、数据库、用户 ID 和密码。然后,你使用该连接执行 SQL 语句,操作数据库。

你在程序中嵌入 SQL 语句,当程序运行时,SQL 会执行并与数据库交互。如果你需要向 SQL 语句传递参数值,可以使用 *预处理语句*,这是一种可重用的 SQL 语句,使用占位符暂时表示参数。然后,你将参数值绑定到预处理语句,用实际值替换占位符。

如果你需要从数据库中检索数据,你会遍历结果并执行某些操作,例如显示结果。当操作完成后,你需要关闭与 MySQL 的连接。

让我们看一些使用 PHP、Python 和 Java 的示例。

## PHP

PHP(PHP:超文本预处理器的递归缩写)是一种开源编程语言,主要用于 Web 开发。数百万个网站是用 PHP 构建的。

PHP 通常与 MySQL 一起使用。两者都属于 *LAMP 堆栈*,这是一种流行的软件开发架构,包括 Linux、Apache、MySQL 和 PHP。(*P* 也可以指 Python 编程语言,较少情况下指 Perl。)许多网站使用 Linux 作为操作系统;Apache 作为接收请求并返回响应的 Web 服务器;MySQL 作为关系型数据库管理系统;PHP 作为编程语言。

要在 PHP 中使用 MySQL,你需要一个 PHP *扩展*,该扩展使你能够在 PHP 程序中使用核心语言中未包含的功能。由于并非所有 PHP 应用程序都需要访问 MySQL,因此该功能作为扩展提供,你可以加载它。有两种扩展选择:*PHP 数据对象(PDO)* 和 *MySQLi*。你可以在 *php.ini* 配置文件中列出你想加载的扩展,如下所示:

extension=pdo_mysql
extension=mysqli


PDO 和 MySQLi 扩展提供了不同的方式,在你的 PHP 程序中创建数据库连接并执行 SQL 语句。这些是面向对象的扩展。(MySQLi 也可以作为过程化扩展使用;你将在本章稍后的“过程化 MySQLi”部分了解这意味着什么。)*面向对象编程(OOP)*依赖于包含数据并能够以方法形式执行代码的对象。*方法*相当于过程化编程中的函数;它是一组可以调用以执行某些操作的指令,例如运行查询或执行存储过程。

要使用 PHP 的面向对象 MySQL 扩展,你需要在 PHP 代码中创建一个新的 `PDO` 或 `MySQLi` 对象,并使用 `->` 符号来调用该对象的方法。

让我们先从 PDO 开始,看看这些扩展的使用。

### PDO

PDO 扩展可以与许多关系数据库管理系统一起使用,包括 MySQL、Oracle、Microsoft SQL Server 和 PostgreSQL。

#### 从表中选择数据

在 Listing 15-1 中,你编写了一个名为 *display_mountains_pdo.php* 的 PHP 程序,使用 PDO 从名为 `topography` 数据库中的 `mountain` 表中选择数据。

query($sql); while ($row = $stmt->fetch(❹ PDO::FETCH_ASSOC)) { ❺ echo( $row['mountain_name'] . ' | ' . $row['location'] . ' | ' . $row['height'] . '
' ); } $conn = ❻ null; ?>

Listing 15-1:使用 PDO 从 `mountain` 表中显示数据 (*display_mountains_pdo.php*)

程序从开标签 `<?php` 开始,结束标签 `?>` 结束。标签告诉 Web 服务器将其中的代码作为 PHP 进行解析。

要在 PHP 中使用 MySQL,你需要通过创建一个新的 `PDO` 对象 ❶ 并传入你的数据库凭证来建立与 MySQL 数据库的连接。在这种情况下,你的主机名是 `localhost`,数据库名称是 `topography`,数据库用户 ID 是 `top_app`,MySQL 数据库的密码是 `pQ3fgR5u5`。

你还可以通过将端口号添加到主机和数据库名称的末尾来指定端口,例如:

'mysql:host=localhost;dbname=topography;port=3306',


如果你没有提供端口号,默认值为 `3306`,这是连接 MySQL 服务器时通常使用的端口。如果你的 MySQL 服务器实例配置为在另一个端口上运行,向你的数据库管理员询问配置文件中的端口号。

你将连接保存为一个名为`$conn`的变量。PHP 变量前面有一个美元符号。这一变量现在代表了你的 PHP 程序和 MySQL 数据库之间的连接。

接下来,你创建一个名为`$sql`的 PHP 变量,用于存储你的 SQL 语句 ❷。

你调用 PDO 的 `query()` 方法,并将你想要运行的 SQL 语句传递给它。在面向对象编程中,`->` 符号通常用于调用对象的实例方法,例如 `$conn->query()`。你将语句及其结果保存为名为 `$stmt` 的数组变量 ❸。

*数组*是你可以用来存储一组值的变量类型。它使用*索引*来标识组中的一个值。你使用 PDO 的`fetch()`方法从`$stmt`中获取每一行,并使用*模式*来控制数据返回的方式。在这里,模式`PDO::FETCH_ASSOC`❹返回一个由数据库表的列名索引的数组,比如`$row['mountain_name']`、`$row['location']`和`$row['height']`。如果你使用了`PDO::FETCH_NUM`模式,它会返回一个由列的编号(从零开始)索引的数组,比如`$row[0]`、`$row[1]`和`$row[2]`。更多的模式可以在 PHP 的在线手册中找到,[`php.net`](https://php.net)。

接下来,`while`循环将遍历每一行已获取的数据。你使用`echo()`命令❺来显示每一列,列与列之间通过竖线(`|`)分隔。`echo()`语句末尾的`<br />` HTML 标签将在浏览器中为每行数据创建换行符。

最后,你通过将连接设置为`null`❻来关闭连接。

访问[`localhost/display_mountains_pdo.php`](http://localhost/display_mountains_pdo.php)查看你的 PHP 程序的结果,结果显示在图 15-1 中。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f15001.png)

图 15-1: *display_mountains_pdo.php*的结果

你已经成功通过 PDO 访问 MySQL,选择了`mountain`表中的数据,并返回每列数据,列与列之间由竖线字符分隔。

#### 向表中插入一行数据

现在你将创建一个新的 PHP 程序,名为*add_mountain_pdo.php*,它使用 PDO 向`mountain`表插入一行新数据。

在代码清单 15-2 中,你将使用准备语句,正如之前所提到的,它使用占位符来表示 SQL 语句中的值。然后,你会用来自 PHP 变量的实际值替换这些占位符。使用准备语句是一种良好的安全实践,因为它有助于防止 SQL 注入攻击,这是一种黑客通过运行恶意 SQL 代码来攻击你数据库的常见方式。

❷prepare( 'insert into mountain (mountain_name, location, height) values (❸:mountain, :location, :height)' ); ❹ $stmt->bindParam(':mountain', $new_mountain, PDO::PARAM_STR); $stmt->bindParam(':location', $new_location, PDO::PARAM_STR); $stmt->bindParam(':height', $new_height, PDO::PARAM_INT); $stmt->❺execute(); $conn = null; ?>

代码清单 15-2: 使用 PDO 向`mountain`表插入一行数据(*add_mountain_pdo.php*)

如代码清单 15-1 所示,你首先创建一个与 MySQL 数据库的连接❶。你有三个 PHP 变量,分别是`$new_mountain`、`$new_location`和`$new_height`,它们分别保存你要插入到`mountain`表中的山名、位置和高度。

你使用连接的`prepare()`方法❷来创建一个准备语句,该语句使用命名占位符来表示你的值。你编写`insert` SQL 语句,但并不是直接包含你想插入的实际值,而是使用占位符❸。你的命名占位符是`:mountain`、`:location`和`:height`。命名占位符前面会有一个冒号。

接下来,你使用 `bindParam()` 方法 ❹ 替换占位符为实际值,该方法将占位符与变量绑定。你将第一个占位符绑定到 `$new_mountain` 变量,它将 `:mountain` 替换为值 `K2`。你将第二个占位符绑定到 `$new_location` 变量,它将 `:location` 替换为值 `Asia`。你将第三个占位符绑定到 `$new_height` 变量,它将 `:height` 替换为值 `28252`。

然后,你需要指定变量代表的数据类型。山脉和位置是字符串,所以你使用 `PDO::PARAM_STR`。高度是整数,因此你使用 `PDO::PARAM_INT`。

当你调用语句的 `execute()` 方法 ❺ 时,语句会执行,你的新行将被插入到 `mountain` 表中。

#### 调用存储过程

接下来,你将编写一个名为 *find_mountains_by_loc_pdo.php* 的 PHP 程序,调用一个 MySQL 存储过程 `p_get_mountain_by_loc()`。

你将为存储过程提供一个参数,用于指定你想要搜索的地点;在本例中,你将搜索位于 `Asia` 的山脉。你的 PHP 程序将调用存储过程,并返回位于亚洲的 `mountain` 表中的山脉数量(参见列表 15-3)。

prepare('❶call p_get_mountain_by_loc(❷:location)'); $stmt->❸bindParam(':location', $location, PDO::PARAM_STR); $stmt->❹execute(); ❺ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { echo( $row['mountain_name'] . ' | ' . $row['height'] . '
' ); } $conn = null; ?>

列表 15-3:使用 PDO 调用存储的 MySQL 存储过程 (*find_mountains_by_loc_pdo.php*)

你在预处理语句中使用 `call` 语句 ❶ 调用存储过程。然后,你创建一个命名占位符 `:location` ❷,并使用 `bindParam` ❸ 将 `:location` 替换为 `$location` 变量中的值,该值为 `Asia`。

接下来,你执行存储过程 ❹。然后,你使用 `while` 语句 ❺ 选择从存储过程中返回的行,并使用 `echo` 命令将它们显示给用户。最后,你结束连接。结果如图 15-2 所示。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f15002.png)

图 15-2:*find_mountains_by_loc_pdo.php* 的结果

你可以为这些程序添加更多功能。例如,你可能会选择允许用户选择他们希望查看的地点,而不是在 PHP 程序中硬编码 `Asia`。你甚至可以检查与数据库连接或调用存储过程时是否有错误,并在出现问题时向用户显示详细的错误信息。

### 面向对象的 MySQLi

MySQL 改进版(MySQLi)扩展是旧版 PHP 扩展 MySQL 的升级版。在本节中,你将学习如何使用 MySQLi 的面向对象版本。

#### 从表中选择

在列表 15-4 中,你编写了一个使用面向对象的 MySQLi 的 PHP 程序,从 `mountain` 表中选择数据。

❷query($sql); while ($row = ❸ $result->fetch_assoc()) { echo( $row['mountain_name'] . ' | ' . $row['location'] . ' | ' . $row['height'] . '
' ); } ❹ $conn->close(); ?>

列表 15-4:使用面向对象的 MySQLi 显示来自 `mountain` 表的数据 (*display_mountains_mysqli_oo.php*)

您通过创建一个 `mysqli` 对象 ❶ 并传入主机、用户 ID、密码和数据库,来建立与 MySQL 的连接。然后,使用连接的 `query()` 方法 ❷ 执行查询,并将结果保存到名为 `$result` 的 PHP 变量中。

您遍历结果行,调用 `$result` 的 `fetch_assoc()` 方法 ❸,这样您可以通过索引引用列,例如 `$row['mountain_name']`。然后打印这些列的值,并通过 `close()` 方法 ❹ 关闭连接。

结果显示在 图 15-3 中。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f15003.png)

图 15-3:*display_mountains_mysqli_oo.php* 的结果

#### 向表中插入一行数据

现在,您将创建一个 PHP 程序,使用面向对象的 MySQLi 向 `mountain` 表中插入一行数据(请参见 列表 15-5)。

prepare( 'insert into mountain (mountain_name, location, height) values (**?, ?, ?**)' ); ❷ $stmt->bind_param(**'ssi'**,$new_mountain,$new_location,$new_height); $stmt->execute(); $conn->close(); ?>

列表 15-5:使用面向对象的 MySQLi 向 `mountain` 表插入一行数据(*add_mountain_mysqli_oo.php*)

一旦建立了连接,您使用准备好的语句,并用问号作为占位符 ❶。然后,您使用 `bind_param()` 方法 ❷ 替换问号占位符为值。

使用 MySQLi,您可以作为字符串提供绑定变量的数据类型。您发送给 `bind_param()` 的第一个参数是值 `ssi`,这表示您希望将第一个和第二个占位符替换为字符串(`s`)类型的值,第三个占位符替换为整数(`i`)类型的值。如果绑定变量的数据类型是 `double`(双精度浮动小数),您还可以选择使用 `d`,如果是 `blob`(二进制大对象)类型,则使用 `b`。

最后,您通过 `execute()` 执行准备好的语句,并关闭连接。当您运行该程序时,它会在 `mountain` 表中插入一座新的山脉——`Makalu`。

#### 调用存储过程

列表 15-6 显示了一个使用面向对象的 MySQLi 执行存储过程的 PHP 程序。

prepare('call p_get_mountain_by_loc(?)'); ❶ $stmt->bind_param(**'s'**, $location); $stmt->execute(); $result = $stmt->get_result(); while ($row = $result->fetch_assoc()) { echo( $row['mountain_name'] . ' | ' . $row['height'] . '
' ); } $conn->close(); ?>

列表 15-6:使用面向对象的 MySQLi 调用存储的 MySQL 过程(*find_mountains_by_loc_mysqli_oo.php*)

您使用一个准备好的语句来调用 `p_get_mountain_by_loc()` 存储过程。它有一个问号占位符,表示您要搜索的山脉位置。您将位置绑定,并用 `Asia` 替换 `?`。您将 `s` 作为第一个参数发送给 `bind_param()` 方法,以指示位置是字符串类型 ❶。

一旦您执行了语句并遍历结果,表中显示的将是亚洲山脉的名称和高度。

结果显示在 图 15-4 中。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f15004.png)

图 15-4:*find_mountains_by_loc_mysqli_oo.php* 的结果

### 过程化 MySQLi

MySQLi 也可以作为过程式扩展使用。MySQLi 的过程式版本与面向对象的版本相似,但不同之处在于,调用方法时不使用`->`语法(如`$conn->close()`),而是使用以`mysqli_`开头的函数,如`mysqli_connect()`、`mysqli_query()`和`mysqli_close()`。

*过程式编程*将数据和过程视为两个不同的实体。它采用自上而下的方法,按照从头到尾的顺序编写代码,并调用包含处理特定任务代码的过程或函数。

#### 从表中查询数据

在清单 15-7 中,你编写了一个 PHP 程序,使用 MySQLi 的过程式版本从`mountain`表中查询数据。

' ); } mysqli_close($conn); ?>

清单 15-7:使用过程式 MySQLi 显示`mountain`表中的数据(*display_mountains_mysqli_procedural.php*)

你使用 MySQLi 的`mysqli_connect()`函数,使用数据库凭证连接到数据库。你定义一个名为`$sql`的变量,保存你的 SQL 语句。接下来,你使用 MySQLi 的`mysqli_query()`函数,通过连接运行查询,并将结果保存到`$result`变量中。

然后,使用`mysql_fetch_assoc()`函数获取结果,这样你就可以通过与数据库列名匹配的索引引用结果的`$row`变量,例如`$row['mountain_name']`。

你使用`echo`命令打印结果,并在值之间添加管道符号(`|`)作为分隔符。HTML 的`<br />`标签将在浏览器中每一行后添加换行符。

最后,使用`mysqli_close()`函数关闭连接。

查询结果显示在图 15-5 中。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f15005.png)

图 15-5:*display_mountains_mysqli_procedural.php*的结果

#### 向表中插入一行

现在,你将创建一个 PHP 程序,通过使用过程式 MySQLi(清单 15-8)将一行插入到你的`mountain`表中。


清单 15-8:使用过程式 MySQLi 向`mountain`表插入一行(*add_mountain_mysqli_procedural.php*)

该程序将一个名为`Lhotse`的新山插入到`mountain`表中。程序的逻辑与之前看到的程序相似:你使用数据库凭证创建连接,使用带有`?`占位符的预处理语句,将值绑定到占位符以替换,执行语句,并关闭连接。

#### 调用存储过程

使用过程式 MySQLi 执行存储过程的 PHP 代码显示在清单 15-9 中。

' ); } mysqli_close($conn); ?>

清单 15-9:使用过程式 MySQLi 调用存储的 MySQL 过程(*find_mountains_by_loc_mysqli_procedural.php*)

你使用预处理语句调用存储过程,并使用问号占位符表示存储过程的参数。你将`$location` PHP 变量绑定,并指定`s`(字符串)作为数据类型。然后,你执行该语句并获取结果,遍历每一行,打印出`mountain`表中位于亚洲的每座山的名称和高度。最后,你关闭连接。  

结果显示在图 15-6 中。  

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f15006.png)  

图 15-6:*find_mountains_by_loc_mysqli_procedural.php*的结果  

## Python  

Python 是一种开源编程语言,具有简洁且易读的语法。学习 Python 非常值得,因为它可以用于许多不同类型的编程——从数据科学和数学,到视频游戏、网页开发,甚至人工智能!  

Python 的语法独特,特别注重缩进。其他语言使用大括号来分组代码块,正如下面的 PHP 代码所示:

if ($temp > 70) {
echo "It's hot in here. Turning down the temperature.";
$new_temp = \(temp - 2; setTemp(\)new_temp);
}


因为你的 PHP 代码块是以`{`开始并以`}`结束,所以代码块内的缩进不影响运行,它仅仅是为了可读性。以下代码在 PHP 中同样能够正常运行:  

if (\(temp > 70) { echo "It's hot in here. Turning down the temperature."; \)new_temp = \(temp - 2; setTemp(\)new_temp);
}


另一方面,Python 不使用大括号来标识代码块。它依赖于缩进:  

if temp > 70:
print("It's hot in here. Turning down the temperature.");
new_temp = temp - 2
set_temp(new_temp)


如果温度超过 70 度,示例将打印`It's hot in here`,并将温度调低 2 度。  

但是,如果你更改了 Python 中的缩进,程序将会做出不同的响应:  

if temp > 70:
print("It's hot in here. Turning down the temperature.")
new_temp = temp - 2
set_temp(new_temp)


只有当温度超过 70 度时,消息`It's hot in here`才会打印,但现在不管怎样,温度都会调低 2 度。这可能不是你所期望的结果。  

### 从表中选择数据  

在列表 15-10 中,你编写了一个名为*display_mountains.py*的 Python 程序,从`mountain`表中选择数据并显示结果。  

import mysql.connector

❶ conn = mysql.connector.connect(
user='top_app',
password='pQ3fgR5u5',
host='localhost',
database='topography')

❷ cursor = conn.cursor()

cursor.execute('select mountain_name, location, height from mountain')

❸ for (mountain, location, height) in cursor:
print(mountain, location, height)

conn.close()


列表 15-10:使用 Python 显示`mountain`表中的数据(*display_mountains.py*)  

在代码的第一行,你通过`mysql.connector`导入了 MySQL Connector/Python。然后,你通过调用`connect()`方法,并提供数据库凭证来创建一个与 MySQL 数据库的连接 ❶。你将这个连接保存为一个名为`conn`的 Python 变量。  

你使用连接来创建一个游标,并将其保存为名为`cursor`的变量 ❷。接下来,你使用`cursor`的`execute()`方法来运行一个 SQL 查询,从`mountain`表中选择数据。`for`循环是一种允许你循环或遍历值的循环类型。在这里,你使用`for`循环 ❸遍历游标中的行,并打印每座山的名称、位置和高度。循环将持续进行,直到游标中没有更多的行可供遍历。  

最后,你使用`conn.close()`关闭连接。  

你可以进入操作系统的命令提示符并运行 Python 程序,查看结果:  

python display_mountains.py
Mount Everest Asia 29029
Aconcagua South America 22841
Denali North America 20310
Mount Kilimanjaro Africa 19341
K2 Asia 28252
Makalu Asia 27766
Lhotse Asia 27940


你的 Python 程序从 `mountain` 表中选择了所有行并显示了表中的数据。

虽然在这个示例中,你的数据库凭据包含在 Python 程序中,但通常你会将敏感信息放在名为 *config.py* 的 Python 文件中,将它们与其余代码分开。

### 向表中插入一行数据

现在,你将编写一个名为 *add_mountain.py* 的 Python 程序,将一行数据插入到 `mountain` 表中(列表 15-11)。

import mysql.connector

conn = mysql.connector.connect(
user='top_app',
password='pQ3fgR5u5',
host='localhost',
database='topography')

cursor = conn.cursor(prepared=True)
❶ sql = "insert into mountain(mountain_name, location, height) values (?,?,?)"
❷ val = ("Ojos Del Salado", "South America", 22615)
cursor.execute(sql, val)
❸ conn.commit()
cursor.close()


列表 15-11:使用 Python 向 `mountain` 表插入一行数据 (*add_mountain.py*)

使用你的连接,你创建了一个 `cursor`,使你能够使用预处理语句。

你创建了一个名为 `sql` 的 Python 变量,它包含了 `insert` 语句❶。Python 可以在预处理语句中使用 `?` 或 `%s` 作为占位符。(字母 *s* 与数据类型或值无关;也就是说,占位符 `%s` 不仅仅用于字符串。)

你创建了一个名为 `val` 的变量❷,它包含了你想插入到表中的值。然后,你调用 `cursor` 的 `execute()` 方法,将 `sql` 和 `val` 变量传入。`execute()` 方法绑定变量,将 `?` 占位符替换为对应的值,并执行 SQL 语句。

你需要通过调用 `connection` 的 `commit()` 方法❸来提交语句到数据库。默认情况下,MySQL Connector/Python 不会自动提交,因此如果你忘记调用 `commit()`,更改将不会应用到数据库中。

### 调用存储过程

列表 15-12 显示了一个名为 *find_mountains_by_loc.py* 的 Python 程序,该程序调用 `p_get_mountain_by_loc()` 存储过程,并传入 `Asia` 参数值,仅显示表中位于亚洲的山脉。

import mysql.connector

conn = mysql.connector.connect(
user='top_app',
password='pQ3fgR5u5',
host='localhost',
database='topography')

cursor = conn.cursor()

❶ cursor.callproc('p_get_mountain_by_loc', ['Asia'])

❷ for results in cursor.stored_results():
for record in results:
print(record[0], record[1])

conn.close()


列表 15-12:使用 Python 调用存储过程 (*find_mountains_by_loc.py*)

你调用 `cursor` 的 `callproc()` 方法来调用存储过程,并传入 `Asia` 的值❶。然后,你调用 `cursor` 的 `stored_results()` 方法来获取存储过程的结果,并使用 `for` 循环遍历这些结果,以获取每个山脉的记录❷。

Python 使用从零开始的索引,因此 `record[0]` 表示从存储过程返回的行中的第一列——在这个例子中是山脉名称。要打印第二列,也就是山脉的高度,你可以使用 `record[1]`。

从命令行运行 Python 程序查看结果:

python find_mountains_by_loc.py
Mount Everest 29029
K2 28252
Makalu 27766
Lhotse 27940


## Java

Java 是一种开源、面向对象的编程语言,广泛用于从移动应用开发到桌面应用程序,再到 Web 应用程序的各种场景。

Java 有许多构建工具和集成开发环境(IDEs),但在这些示例中,你将从命令行进行操作。在开始查看示例之前,让我们先了解一些基础知识。

你将创建一个以*.java*为文件扩展名的 Java 程序。要运行 Java 程序,你首先需要使用`javac`命令将它编译成*.class*文件。这个文件是字节码格式的。*字节码*是一个机器级格式,它在 Java 虚拟机(JVM)中运行。一旦程序被编译,你可以使用`java`命令来运行它。

这里你创建一个名为*MountainList.java*的 Java 程序并将其编译为字节码:

javac MountainList.java


该命令会创建一个名为*MountainList.class*的字节码文件。要运行它,你可以使用以下命令:

java MountainList


### 从表中选择数据

和其他编程语言一样,你将开始编写一个名为*MountainList.java*的 Java 程序,从 MySQL 的`mountain`表中选择一组山脉(列表 15-13)。

import java.sql.*; ❶

public class MountainList {
public static void main(String args[]) { ❷
String url = "jdbc:mysql://localhost/topography";
String username = "top_app";
String password = "pQ3fgR5u5";

try { ❸
  Class.forName("com.mysql.cj.jdbc.Driver"); ❹
  Connection ❺ conn = DriverManager.getConnection(url, username, password);
  Statement stmt = conn.createStatement(); ❻
  String sql = "select mountain_name, location, height from mountain";
  ResultSet rs = stmt.executeQuery(sql); ❼
  while (rs.next()) {
    System.out.println(
      rs.getString("mountain_name") + " | " +
      rs.getString("location") + " | " +
      rs.getInt("height");

);
}
conn.close();
} catch (Exception ex) {
System.out.println(ex);
}
}
}


列表 15-13:使用 Java 从`mountain`表中显示数据(*MountainList.java*)

首先,你导入`java.sql`包 ❶,以便访问用于操作 MySQL 数据库的 Java 对象,如`Connection`、`Statement`和`ResultSet`。

你创建一个名为`MountainList`的 Java 类,它有一个`main()`方法,当你运行程序时该方法会自动执行 ❷。在`main()`方法中,你通过提供数据库凭证创建到 MySQL 数据库的连接。你将这个连接保存为一个名为`conn`的 Java 变量 ❺。

你使用`Class.forName`命令 ❹加载 MySQL Connector/J 的 Java 类`com.mysql.cj.jdbc.Driver`。

使用`Connection`的`createStatement()`方法,你创建一个`Statement` ❻来执行 SQL 语句。`Statement`返回一个`ResultSet` ❼,你遍历它以显示数据库表中每个山脉的名称、位置和高度。完成后,你关闭连接。

请注意,许多 Java 命令都被包裹在`try`块中 ❸。这样,如果执行这些命令时出现问题,Java 将抛出一个*异常*(或错误),你可以在相应的`catch`语句中捕获该异常。在这种情况下,当抛出异常时,控制权会转移到`catch`块,你可以将异常信息显示给用户。

在 Python 和 PHP 中,将代码包裹在`try...catch`块中是最佳实践,但它是可选的。(Python 中的语法是`try/except`。)但在 Java 中,你*必须*使用`try...catch`块。如果你尝试在没有它的情况下编译 Java 代码,你会收到一个错误,提示异常`必须被捕获或声明抛出`。

从命令行编译并运行你的 Java 程序,查看结果:

javac MountainList.java
java MountainList
Mount Everest | Asia | 29029
Aconcagua | South America | 22841
Denali | North America | 20310
Mount Kilimanjaro | Africa | 19341
K2 | Asia | 28252
Makalu | Asia | 27766
Lhotse | Asia | 27940
Ojos Del Salado | South America | 22615


### 向表中插入一行数据

在列表 15-14 中,你将编写一个 Java 程序,将一行数据插入到`mountain`表中。

import java.sql.*;

public class MountainNew {
public static void main(String args[]) {
String url = "jdbc:mysql://localhost/topography";
String username = "top_app";
String password = "pQ3fgR5u5";

try {
  Class.forName("com.mysql.cj.jdbc.Driver");
  Connection conn = DriverManager.getConnection(url, username, password);
  String sql = "insert into mountain(mountain_name, location, height) " +
                "values (?,?,?)";
❶ PreparedStatement stmt = conn.prepareStatement(sql);
  stmt.setString(1, "Kangchenjunga");
  stmt.setString(2, "Asia");
  stmt.setInt(3, 28169);
❷ stmt.executeUpdate();
  conn.close();
} catch (Exception ex) {
  System.out.println(ex);
}

}
}


列表 15-14:使用 Java 向`mountain`表中插入一行数据(*MountainNew.java*)

你的 SQL 语句使用问号作为占位符。这次你使用`PreparedStatement`❶而不是`Statement`,这样可以发送参数值。你通过`setString()`和`setInt()`方法绑定参数值。然后,你调用`executeUpdate()`方法❷,该方法用于插入、更新或删除 MySQL 表中的行。

### 调用存储过程

Listing 15-15 展示了一个执行 MySQL 存储过程的 Java 程序。

import java.sql.*;

public class MountainAsia {
public static void main(String args[]) {
String url = "jdbc:mysql://localhost/topography";
String username = "top_app";
String password = "pQ3fgR5u5";

try {
  Class.forName("com.mysql.cj.jdbc.Driver");
  Connection conn = DriverManager.getConnection(url, username, password);
  String sql = "call p_get_mountain_by_loc(?)";
❶ CallableStatement stmt = conn.prepareCall(sql);
  stmt.setString(1, "Asia");
  ResultSet rs = stmt.executeQuery();

while (rs.next()) {
System.out.println(
rs.getString("mountain_name") + " | " +
rs.getInt("height")
);
}
conn.close();
} catch (Exception ex) {
System.out.println(ex);
}
}
}


Listing 15-15: 使用 Java 调用 MySQL 存储过程(*MountainAsia.java*)

这次,你使用`CallableStatement`❶而不是`Statement`来调用存储过程。你将第一个(也是唯一的)参数设置为`Asia`,并通过`CallableStatement`的`executeQuery()`方法执行查询。然后,你遍历结果,显示每个山脉的名称和高度。

结果如下:

Mount Everest | 29029
K2 | 28252
Makalu | 27766
Lhotse | 27940
Kangchenjunga | 28169


## 总结

在这一章中,你学习了如何从编程语言调用 MySQL。你了解到 SQL 语句通常会嵌入到程序中并执行。你还看到了,可以通过 MySQL Workbench 访问的同一数据库表,也可以使用 PHP、Python、Java 或其他各种工具或语言进行访问。

在下一章中,你将开始使用 MySQL 进行第一个项目:创建一个功能完整的天气数据库。你将编写脚本,每小时接收天气数据流并将其加载到 MySQL 数据库中。


# 第五部分

项目

恭喜你!你现在已经掌握了足够的 MySQL 知识,可以开始构建有意义的项目了。在本书的这一部分,你将完成三个项目,这些项目将教会你新技能,并帮助你更深入地理解到目前为止所学的内容。

以下列出的这些项目是相互独立的,可以按任意顺序完成:

**构建一个天气数据库**

1.  你将使用 cron、Bash 和 SQL 脚本等技术,为一家货运公司构建一个数据库,用于存储当前的天气数据。

**通过触发器跟踪选民数据的变化**

1.  你将构建一个数据库,用于保存选举数据,并在表上构建触发器,以跟踪选民数据的变化。

**通过视图保护薪资数据**

1.  你将构建一个数据库,用于保存公司数据,并仅在需要时访问薪资数据。你将把大多数用户的薪资信息隐藏起来。


# 第十六章:构建天气数据库

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在这个项目中,你将为一家卡车公司构建一个天气数据库。该公司在美国东海岸上下运输物品,需要一种方法来获取驾驶员前往的主要城市的当前天气情况。

公司已经设置了一个包含卡车数据的 MySQL 数据库,但是你需要添加一个新的数据库,详细描述卡车司机驾驶经过的当前天气情况。这将允许你将天气数据整合到现有的卡车应用程序中,以显示天气对调度的影响,并警告驾驶员有关黑冰、雪和极端温度等危险条件。

你将从一个第三方公司获取这些天气数据文件。该公司已同意每小时发送给你一个 CSV 文件。从 第十四章 回忆,CSV 文件是一个文本文件,包含数据,并使用逗号作为字段之间的分隔符。

提供天气数据的公司将使用 FTP(文件传输协议),这是一种标准的通信协议,允许在计算机之间传输文件,将 *weather.csv* 文件发送到你的 Linux 服务器的 */home/weather_load/ 目录。数据文件将大约每小时到达一次,但可能会有延迟,这意味着文件可能不会准确地每小时到达。因此,你将编写一个程序,每 5 分钟运行一次,检查文件是否可用,并在其可用时将其加载到你的数据库中。*

*一旦你审查了必要的技术,你将通过创建一个名为`weather`的新数据库开始你的项目,其中包含两个表:`current_weather_load`和`current_weather`。你将从文件中加载数据到`current_weather_load`表中。一旦确保数据加载没有任何问题,你将把数据从`current_weather_load`复制到`current_weather`表中,这是你的卡车应用程序将使用的表。你可以在 [`github.com/ricksilva/mysql_cc/tree/main/chapter_16`](https://github.com/ricksilva/mysql_cc/tree/main/chapter_16) 找到 *weather.csv* 数据文件。

## 你将使用的技术

对于这个项目,除了 MySQL,你还将使用 cron 和 Bash 等其他技术。这些技术允许你安排加载天气数据,检查数据文件是否可用,并创建包含任何加载错误的日志文件。

### cron

为了安排一个脚本每 5 分钟运行一次,你将使用 *cron*,这是一种在类 Unix 操作系统(Unix、Linux 和 macOS)上可用的调度程序。它也可以通过 Windows 子系统来在 Windows 上使用,该子系统允许你在 Windows 计算机上运行 Linux 环境。要安装 WSL,请在命令行中输入 `wsl --install`。

你在 cron 中安排的任务称为 *cron jobs*,它们在后台运行,而不是与终端连接。你可以通过将任务添加到名为 *crontab* (*cron 表*) 的配置文件中来安排任务。

你可以通过输入 `crontab -l` 获取已安排的 cron 任务列表。如果你需要编辑 crontab 配置文件,可以输入 `crontab -e`。`-e` 选项将打开文本编辑器,你可以在其中添加、修改或删除 crontab 文件中的任务。

要安排一个 cron 任务,你必须提供六项信息,按照以下顺序:

1.  分钟 (0–59)

1.  小时 (0–23)

1.  日期 (1–31)

1.  月份 (1–12)

1.  一周中的天数 (0–6) (星期天到星期六)

1.  要运行的命令或脚本

例如,如果你想安排一个名为 *pi_day.sh* 的脚本运行,你可以输入 `crontab -e` 并添加一个类似下面的 crontab 条目:

14 3 14 3 * /usr/local/bin/pi_day.sh


设置好这个 cron 任务后,位于 */usr/local/bin/* 目录中的 *pi_day.sh* 脚本将每年 3 月 14 日凌晨 3:14 执行一次。由于星期几已设置为 `*`(通配符),该任务将在每年 3 月 14 日所在的任意星期几执行。

### Bash

*Bash* 是一种在 Unix 和 Linux 环境中可用的 shell 和命令语言。你可以使用多种工具或语言,但我选择了 Bash,因为它的流行性和相对简洁。Bash 脚本通常以 *.sh* 为扩展名,像前面示例中的 *pi_day.sh*。在本章的项目中,你将编写一个名为 *weather.sh* 的 Bash 脚本,cron 每 5 分钟运行一次。这个脚本将检查是否有新数据文件到达,并在有新文件时调用 SQL 脚本将数据加载到数据库中。

### SQL 脚本

SQL 脚本是包含 SQL 命令的文本文件。对于这个项目,你将编写两个 SQL 脚本,分别是 *load_weather.sql* 和 *copy_weather.sql*。*load_weather.sql* 脚本将数据从 CSV 文件加载到 `current_weather_load` 表中,并在出现加载问题时提醒你。*copy_weather.sql* 脚本将把天气数据从 `current_weather_load` 表复制到 `current_weather` 表中。

## 项目概述

你将安排一个 cron 任务,每 5 分钟运行一次 *weather.sh* 脚本。如果存在新的 *weather.csv* 数据文件,它将被加载到 `current_weather_load` 表中。如果没有错误地加载数据,`current_weather_load` 表中的数据将被复制到 `current_weather` 表中,供你的应用使用。图 16-1 显示了该项目的流程。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f16001.png)

图 16-1:你的天气项目概览

如果没有新的 *weather.csv* 文件可用,*weather.sh* 脚本将退出,不会运行 Bash 脚本中剩余的加载数据和记录错误的命令。如果文件已加载且 *load_weather.log* 中没有错误,Bash 脚本将调用 *copy_weather.sql* 将你刚刚加载的 `current_weather_load` 表中的数据复制到 `current_weather` 表中。

## 数据文件

由于卡车公司在美国东海岸上下来回行驶,你已经请求以下地点的天气数据:

+   波特兰,缅因州

+   波士顿,马萨诸塞州

+   普罗维登斯,罗德岛州

+   纽约,纽约州

+   费城,宾夕法尼亚州

+   华盛顿特区

+   里士满,弗吉尼亚州

+   罗利,北卡罗来纳州

+   查尔斯顿,南卡罗来纳州

+   杰克逊维尔,佛罗里达州

+   迈阿密,佛罗里达州

CSV 数据文件将包括 表 16-1 中列出的字段。

表 16-1:CSV 数据文件中的字段

| **字段名称** | **描述** |
| --- | --- |
| `station_id` | 该数据来源的天气站 ID |
| `station_city` | 天气站所在的城市 |
| `station_state` | 天气站所在州的两位字符代码 |
| `station_lat` | 天气站的纬度 |
| `station_lon` | 天气站的经度 |
| `as_of_datetime` | 数据收集的日期和时间 |
| `temp` | 温度 |
| `feels_like` | 当前“体感温度” |
| `wind` | 风速(单位:千米每小时) |
| `wind_direction` | 风的方向 |
| `precipitation` | 过去一小时的降水量(单位:毫米) |
| `pressure` | 大气压力 |
| `visibility` | 能清晰看到的距离(单位:英里) |
| `humidity` | 空气中的相对湿度百分比 |
| `weather_desc` | 当前天气的文字描述 |
| `sunrise` | 今日该地点的日出时间 |
| `sunset` | 今日该地点的日落时间 |

大约每小时,你请求的位置的数据将通过 CSV 文件发送给你。CSV 文件的格式应类似于 图 16-2。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f16002.png)

图 16-2:*weather.csv* 数据文件

该文件为你请求的 11 个天气站的每一个生成一行,每个字段都用逗号分隔。

## 创建天气表

创建一个名为 `weather` 的 MySQL 数据库,用于存储天气数据:

create database weather;


现在你将创建一个名为 `current_weather_load` 的表格,用于加载 CSV 文件中的数据。`_load` 后缀表明该表格是用于加载当前天气数据的。

清单 16-1 显示了创建 `current_weather_load` 表的 SQL 语句。

create table current_weather_load
(
station_id int primary key,
station_city varchar(100),
station_state char(2),
station_lat decimal(6,4) not null,
station_lon decimal(7,4) not null,
as_of_dt datetime,
temp int not null,
feels_like int,
wind int,
wind_direction varchar(3),
precipitation decimal(3,1),
pressure decimal(6,2),
visibility decimal(3,1) not null,
humidity int,
weather_desc varchar(100) not null,
sunrise time,
sunset time,
constraint check(station_lat between -90 and 90),
constraint check(station_lon between -180 and 180),
constraint check(as_of_dt between (now() - interval 1 day) and now()),
constraint check(temp between -50 and 150),
constraint check(feels_like between -50 and 150),
constraint check(wind between 0 and 300),
constraint check(station_lat between -90 and 90),
constraint check(wind_direction in
(
'N','S','E','W','NE','NW','SE','SW',
'NNE','ENE','ESE','SSE','SSW','WSW','WNW','NNW'
)
),
constraint check(precipitation between 0 and 400),
constraint check(pressure between 0 and 1100),
constraint check(visibility between 0 and 20),
constraint check(humidity between 0 and 100)
);


清单 16-1:创建 `current_weather_load` 表

现在创建第二个表,名为 `current_weather`,其结构与 `current_weather_load` 相同:

create table current_weather like current_weather_load;


有了这两个表之后,你就可以将 CSV 文件加载到一个表中,同时在确保数据加载干净后,你还将把天气数据复制到一个最终的、面向用户的表格中。

让我们更详细地看一下 清单 16-1。

### 数据类型

你应该始终为列选择与 CSV 文件中的数据尽可能匹配的数据类型。例如,你将`station_id`、`temp`、`feels_like`、`wind`和`humidity`列定义为`int`数据类型,因为它们将作为不带小数点的数字值传入。你将`station_lat`、`station_lon`、`precipitation`、`pressure`和`visibility`定义为`decimal`数据类型,因为它们将包含小数点。

你还应该考虑列值的大小。例如,你将`station_lat`列定义为`decimal(6,4)`,因为纬度需要存储小数点前最多两位和小数点后四位的数字。你将`station_lon`定义为`decimal(7,4)`,因为经度需要存储小数点前最多*三*位和小数点后四位的数字。经度列需要能够存储比纬度列更大的数值。

你需要为`as_of_dt`列动动脑筋。它的数据以`YYYYMMDD hh:mm`的格式给你。MySQL 没有存储这种格式数据的数据类型,因此你创建了一个`as_of_dt`列,并为其设置了`datetime`数据类型。当你将数据文件加载到加载表时,你将把这个值转换为`datetime`格式。(我们将在下一节讨论如何处理这个问题。)

`station_state`列将始终包含两个字符,因此你将其定义为`char(2)`。由于`station_city`和`weather_desc`列的字符数是可变的,你将这两列定义为`varchar`类型,最多包含 100 个字符。没有城市或描述的字符数应该超过 100,所以如果你在这些列中获得超过 100 的值,你可以放心地认为数据是错误的。

`sunrise`和`sunset`值将以小时和分钟的格式提供给你。你为这些值使用`time`数据类型,即使数据文件中没有秒数。你将这些值加载到具有`time`数据类型的列中,并让秒数自动默认为零。例如,你将加载值`17:06`,它将被保存为`17:06:00`。这对你的用途来说完全可行,因为你的应用程序不需要精确到秒的日出和日落时间。

### 约束

你在`station_id`列上创建主键以确保唯一性。如果数据文件中有两条记录来自同一个气象站,你不希望加载这两条记录。将`station_id`设置为主键将阻止第二行数据的加载,并生成警告信息,提醒你数据文件存在问题。

你为列添加了一些其他约束,作为加载到表中的数据质量检查。

`station_lat`列必须在有效的纬度值范围内:–90.0000 到 90.0000。你已经将`station_lat`定义为`decimal(6,4)`数据类型,这样总共有六个数字,其中四个小数位,但这并不能阻止像`95.5555`这样的无效值被写入该列。添加`check`约束将强制要求该值在适当的范围内。这样可以确保只存储合法的纬度值,并拒绝任何超出该范围的值。同样,`station_lon`列必须在有效的经度值范围内:–180.0000 到 180.0000。

`wind_direction`列也有一个`check`约束,确保该列只包含你在列表中提供的 16 个可能值之一(例如`N`表示北,`SE`表示东南,`NNW`表示北北西,等等)。

其他`check`约束确保你的数据在天气数据的合理范围内。例如,温度超出-50 华氏度到 150 华氏度的范围可能是一个错误,所以你会拒绝它。湿度是百分比,因此你要求它必须在 0 到 100 之间。

你还在加载表中的一些列上声明了`not null`约束。这些列非常重要,若未提供这些数据,加载将会失败。`station_id`列必须是非空的,因为它是该表的主键。

你将`station_lat`和`station_lon`定义为`not null`,因为你希望在你的卡车运输应用中绘制天气站的位置地图。你希望在地图上显示每个天气站的当前温度、能见度和天气状况,如果没有提供该站的纬度和经度,就无法实现这一点。

`temperature`、`visibility`和`weather_desc`列也是本项目中的关键数据,因此你也将它们定义为`not null`。

## 加载数据文件

在你创建*weather.sh* Bash 脚本之前,该脚本检查是否有新的 CSV 天气文件可用,你将编写*load_weather.sql* SQL 脚本,将 CSV 文件加载到`current_weather_load`表中(请参见 Listing 16-2)。

use weather;

delete from current_weather_load;

load data local infile '/home/weather_load/weather.csv'
into table current_weather_load
❶ fields terminated by ','
(
station_id,
station_city,
station_state,
station_lat,
station_lon,
❷ @aod,
temp,
feels_like,
wind,
wind_direction,
precipitation,
pressure,
visibility,
humidity,
weather_desc,
sunrise,
sunset
)
❸ set as_of_dt = str_to_date(@aod,'%Y%m%d %H:%i');

❹ show warnings;

❺ select concat('No data loaded for ',station_id,': ',station_city)
from current_weather cw
where cw.station_id not in
(
select cwl.station_id
from current_weather_load cwl
);


Listing 16-2:*load_weather.sql*脚本

首先,你将当前数据库设置为`weather`数据库,并删除`current_weather_load`表中可能残留的上次加载的数据行。

然后你使用在第十四章中看到的`load data`命令,将*weather.csv*文件加载到`current_weather_load`表中。因为你正在加载一个以逗号分隔的文件,所以你需要指定`fields terminated by ','` ❶,这样`load data`就知道每个字段的结束位置,以及下一个字段的开始位置。你指定数据文件名为*weather.csv*,并且文件位于*/home/weather_load/*目录下。

在括号内,你列出了所有你希望加载数据字段的表格列,只有一个例外:你不是直接从文件将值加载到 `as_of_dt` 列,而是将其加载到一个名为 `@aod` 的变量中❷。该变量保存的是 CSV 文件中格式化的 `as of date` 值,如前所述,其格式为 `YYYYMMDD hh:mm`。你使用 MySQL 的 `str_to_date()` 函数❸将 `@aod` 变量的值从字符串转换为 `datetime` 数据类型。你使用格式说明符 `%Y`、`%m`、`%d`、`%H` 和 `%i` 来指定字符串的格式。通过指定 `str_to_date(@aod,'%Y%m%d %H:%i')`,你表示 `@aod` 变量由以下部分组成:

+   `%Y`,四位数的年份

+   `%m`,两位数的月份

+   `%d`,两位数的日期

+   一个空格

+   `%H`,两位数的小时(0–23)

+   一个冒号

+   `%i`,两位数的分钟(0–59)

有了这些信息,`str_to_date()` 函数就能够将 `@aod` 字符串转换为 `current_weather_load` 表中的 `as_of_date datetime` 字段。

接下来,你检查加载数据时是否存在任何问题。`show warnings` 命令❹列出你上次运行命令时的任何错误、警告或备注。如果数据文件中的问题导致 `load data` 命令失败,`show warnings` 会告诉你问题所在。

然后,你添加了一个查询作为第二次检查,确保数据已正确加载❺。在这个查询中,你列出所有上次你将数据写入 `current_weather` 表时的天气站。如果这些天气站中的任何一个不在你刚加载的 `current_weather_load` 表中,这可能意味着数据文件中缺少了该天气站的数据,或者该天气站的数据存在问题,导致数据未能加载。在任何一种情况下,你都希望能够收到通知。

你现在已经编写了 *load_weather.sql* 脚本,来通知你加载数据时是否有任何问题。如果 *load_weather.sql* 执行后没有任何输出,则表示数据已经成功加载到 `current_weather_load` 表中。

## 将数据复制到最终表

一旦数据从 CSV 数据文件成功加载到 `current_weather_load` 表中,你将运行另一个名为 *copy_weather.sql* 的 SQL 脚本,将数据复制到最终的 `current_weather` 表中(列表 16-3)。

use weather;

delete from current_weather;

insert into current_weather
(
station_id,
station_city,
station_state,
station_lat,
station_lon,
as_of_dt,
temp,
feels_like,
wind,
wind_direction,
precipitation,
pressure,
visibility,
humidity,
weather_desc,
sunrise,
sunset
)
select station_id,
station_city,
station_state,
station_lat,
station_lon,
as_of_dt,
temp,
feels_like,
wind,
wind_direction,
precipitation,
pressure,
visibility,
humidity,
weather_desc,
sunrise,
sunset
from current_weather_load;


列表 16-3:*copy_weather.sql* 脚本

该 SQL 脚本将当前数据库设置为 `weather` 数据库,删除 `current_weather` 表中的所有旧行,并从 `current_weather_load` 表加载数据到 `current_weather` 表中。

现在你已经编写了 SQL 脚本,可以编写调用它们的 Bash 脚本(列表 16-4)。

!/bin/bash ❶

cd /home/weather/ ❷

if [ ! -f weather.csv ]; then ❸
exit 0
fi

mysql --local_infile=1 -h 127.0.0.1 -D weather -u trucking -pRoger -s
< load_weather.sql > load_weather.log ❹

if [ ! -s load_weather.log ]; then ❺
mysql -h 127.0.0.1 -D weather -u trucking -pRoger -s < copy_weather.sql > copy_weather.log
fi

mv weather.csv weather.csv.$(date +%Y%m%d%H%M%S) ❻


列表 16-4:*weather.sh* 脚本

Bash 脚本的第一行❶称为 *shebang*。它告诉系统,这个文件中的命令应该使用位于 */bin/bash* 目录中的解释器。

接下来,使用`cd`命令切换到*/home/weather/*目录❷。

在第一个`if`语句❸中,检查*weather.csv*文件是否存在。在 Bash 脚本中,`if`语句以`if`开始,以`fi`结束。`-f`命令用于检查文件是否存在,`!`是表示`not`的语法。语句`if [ ! -f weather.csv ]`用于检查*weather.csv*文件是否不存在。如果不存在,意味着没有新的 CSV 数据文件可以加载,因此会退出 Bash 脚本并返回退出码`0`。按照惯例,退出码`0`表示成功,`1`表示错误。在这里退出 Bash 脚本,防止脚本继续执行;因为没有数据文件可供处理,所以不需要执行后续脚本。

使用 MySQL 命令行客户端❹(`mysql`命令,详见第十四章)运行*load_weather.sql* SQL 脚本。如果*load_weather.sql*脚本在加载数据到`current_weather_load`表时出现问题,您将把这些问题记录到一个名为*load_weather.log*的文件中。

在 Bash 中,左箭头(`<`)和右箭头(`>`)用于*重定向*,它们允许你从文件中获取输入并将输出写入另一个文件。语法`< load_weather.sql`告诉 MySQL 命令行客户端从*load_weather.sql*脚本中运行命令。语法`> load_weather.log`表示将任何输出写入*load_weather.log*文件。

`local_infile=1`选项允许你使用本地计算机上的数据文件(而不是安装 MySQL 的服务器上的数据文件)运行`load data`命令(该命令在*load_weather.sql*脚本中使用)。根据你的配置设置,这在你的环境中可能不是必须的。(你的 DBA 可以通过命令`set global local_infile = on`将此选项设置为配置参数。)

`-h`选项告诉 MySQL 命令行客户端 MySQL 安装的主机服务器。在这种情况下,`-h 127.0.0.1`表示 MySQL 主机服务器与当前正在运行脚本的计算机相同。也被称为*localhost*,127.0.0.1 是当前(本地)计算机的 IP 地址。你也可以在这里直接输入`-h` `localhost`。

接下来,提供数据库名称`weather`,你的 MySQL 用户 ID`trucking`,以及密码`Roger`。奇怪的是,MySQL 不允许`-p`后面有空格,因此输入密码时不要在前面加空格。

使用`-s`选项以*静默模式*运行 SQL 脚本。这可以防止脚本在输出中显示过多的信息。例如,如果波士顿气象站没有加载数据,您希望在*load_weather.log*文件中看到`No data loaded for 375: Boston`的消息。但如果没有`-s`,日志文件还会显示生成该消息的`select`语句的开始部分:

concat('No data loaded for ',station_id,': ',station_city)
No data loaded for 375: Boston


使用`-s`选项可以防止文本`concat('No data loaded for ',station_id,': ',station_city)`写入*load_weather.log*文件中。

在 Bash 中,反斜杠字符(`\`)允许你将命令继续到下一行。`-s`后,你使用反斜杠继续下一行,因为你的代码行太长了。

接下来,你的 Bash 脚本检查是否在*load_weather.log*文件中列出了任何加载问题❺。在`if`语句中,`-s`检查文件大小是否大于 0 字节。只有当数据没有问题加载到`current_weather_load`加载表格时,你才会将数据加载到最终表格`current_weather`中。换句话说,只有当*load_weather.log*文件为空或大小为 0 字节时,才会将数据复制到`current_weather`表格。你通过使用语法`if [ ! -s load_weather.log ]`来检查日志文件的大小是否大于 0。

最后,在*weather.sh* Bash 脚本的最后一行,你将重命名*weather.csv*文件,添加当前日期和时间作为后缀。例如,你将*weather.csv*重命名为*weather.csv.20240412210125*,这样下次运行 Bash 脚本时,它就不会再尝试重新加载相同的*weather.csv*文件❻。`mv`命令代表*move*,用于重命名或将文件移动到另一个目录。

现在让我们来看一下结果。如果你收到一个包含有效数据的*weather.csv*数据文件,运行*load_weather.sql*脚本将导致`current_weather_load`表格填充数据。这应该类似于图 16-3。

你在`current_weather_load`表中的数据看起来很好。CSV 数据文件中的所有 11 行现在都已存入表格,并且所有列的值看起来合理。

另一方面,如果你收到的*weather.csv*数据文件包含重复值,或者值格式不正确或超出范围,运行*load_weather.sql*脚本的结果是你的*load_weather.log*文件会包含问题列表。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f16003.png)

图 16-3:`current_weather_load`表格

假设你收到了有效数据并且*copy_weather.sql*已运行,`current_weather`表格应该与图 16-3 一致。

接下来,你将使用 cron 创建调度任务来运行这个 Bash 脚本。

## 在 cron 上调度 Bash 脚本

使用命令`crontab -e`,创建以下的 crontab 条目:

*/5 * * * * /home/weather/weather.sh


`*/5`在`minutes`列中告诉 cron 每 5 分钟运行一次这个任务。你可以对所有其他值(小时、月、日期和星期几)使用通配符(`*`)字符,因为你希望脚本在所有小时、月份、日期和星期几运行。图 16-4 展示了每个 crontab 条目部分的含义。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f16004.png)

图 16-4:将*weather.sh*脚本调度到 cron 上每 5 分钟运行一次

然后,你保存 crontab 文件并退出由`crontab -e`命令启动的文本编辑器。

## 替代方法

正如谚语所说,有很多种方法可以剥猫皮。同样,你可以使用到目前为止学到的知识,用许多其他方式来处理这个项目。

你本可以直接将数据从 CSV 文件加载到最终的`current_weather`表中,但使用临时加载表可以让你在后台修正任何数据问题,而不影响面向用户的数据。如果 CSV 文件中存在重复记录、格式不正确的列值或超出范围的值等数据问题,那么加载到`current_weather_load`表中的操作会失败。在你与 CSV 文件供应商合作获得修正文件的同时,你的应用程序将继续使用`current_weather`表中的现有数据,用户不会受到影响(尽管他们看到的天气数据不会像通常那样是最新的)。

如果你的天气数据提供者提供了*应用程序编程接口(API)*,你本可以通过 API 接收天气数据,而不是加载 CSV 数据文件。API 是两种系统之间交换数据的另一种方式,但深入讨论 API 超出了本书的范围。

你在`current_weather_load`表上创建了主键和其他约束。在需要从文件加载大量记录到表中的情况下,你不会这么做。出于性能考虑,你会选择将数据加载到没有约束的表中。因为在每一行写入表时,MySQL 需要检查约束是否被违反,这会消耗时间。然而,在你的天气项目中,只有 11 行数据被加载,所以即使有约束,加载时间几乎是瞬时的。

你本可以在 Bash 脚本*weather.sh*中添加一行代码,以便每当加载数据时出现问题时,通过电子邮件或短信通知你和数据提供者。这个功能没有包含在项目中,因为它需要一些设置。想了解更多,可以使用`man`命令查阅`mailx`、`mail`或`sendmail`命令(例如,`man mailx`)。

此外,你的数据库凭证被硬编码在*weather.sh* Bash 脚本中,以便脚本可以调用 MySQL 命令行客户端。当你加载数据时,MySQL 会给出警告`Using a password on the command line interface can be insecure`。值得重新构建代码,使其隐藏你的数据库用户 ID 和密码,或者使用第十四章中展示的`mysql_config_editor`工具。

## 总结

在这个项目中,你安排了一个 cron 作业来执行一个 Bash 脚本,该脚本检查包含当前天气数据的 CSV 数据文件是否已到达。当文件到达时,你将其加载到 MySQL 数据库中。你还检查了加载数据时是否存在问题,并且在数据成功加载后,将数据转移到最终的天气表中。

在下一个项目中,你将使用触发器来跟踪 MySQL 数据库中选民数据的变化。


# 第十七章:使用触发器跟踪选民数据的变化

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在本章中,你将构建一个用于存储选举数据的投票数据库。你将通过设计带有约束条件的数据库(包括主键和外键),并使用触发器防止错误数据的录入,从而提高数据的质量。你还将使用触发器来跟踪数据库的变化,以便在数据质量问题出现时,可以记录是谁在何时做了更改。

你将允许投票工作人员在适当时更改数据,因此构建一个防止错误发生的系统非常重要。本章中的技术可以应用于各种不同的应用和场景。数据质量至关重要,因此值得以一种尽可能保持数据准确性的方式来设置你的数据库。

## 设置数据库

首先,你将创建数据库并查看其表。你的选举选票包括市长、财务主管、学校委员会、卫生委员会和规划委员会等职位。图 17-1 显示了你将用于数据库的选票。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f17001.png)

图 17-1:你的选举选票

本次选举使用光学扫描投票机,这些投票机读取选票并将投票数据保存到你的 MySQL 数据库中。

创建 `voting` 数据库:

create database voting;


现在你可以开始添加表格了。

## 创建表

你将在 `voting` 数据库中创建以下表:

| `beer` | 一个包含关于啤酒数据的表。 |
| --- | --- |
| `voter` | 有资格在本次选举中投票的人 |
| `ballot` | 选民的选票 |
| `race` | 选票上的竞选职位(例如,市长、财务主管) |
| `candidate` | 竞选中的候选人 |
| `ballot_candidate` | 选民在选票上选择的候选人 |

图 17-2 中的 *实体关系图* *(ERD)* 显示了这些表及其列,以及它们之间的主键和外键关系。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f17002.png)

图 17-2:你 `voting` 数据库中的表

选民将投下他们为每个职位选择的候选人的选票。

### 选民表

`voter` 表将存储每个选民的信息,如姓名、地址和县。按如下方式创建该表:

use voting;

create table voter
(
voter_id int primary key auto_increment,
voter_name varchar(100) not null,
voter_address varchar(100) not null,
voter_county varchar(50) not null,
voter_district varchar(10) not null,
voter_precinct varchar(10) not null,
voter_party varchar(20),
voting_location varchar(100) not null,
voter_registration_num int not null unique
);


`voter_id` 列是该表的主键。创建此主键不仅可以加速使用 `voter` 表的连接操作,还能确保表中没有两行具有相同的 `voter_id` 值。

你将 `voter_id` 设置为 `auto_increment`,这样 MySQL 会在你每次向表中添加新选民时自动增加 `voter_id` 值。

你不能有两个选民具有相同的登记号,因此你将 `voter_registration_num` 列设置为 `unique`。如果向表中添加一个具有与现有选民相同的 `voter_registration_num` 的新选民,该行将被拒绝。

除了 `voter_party` 列之外,所有列都被定义为 `not null`。你允许一行在表中保存 `voter_party` 为 null 的情况,但如果其他任何列包含 null 值,该行将被拒绝。

### 选票表

`ballot` 表保存每张选票的信息,包括选票号码、完成选票的选民、选票投下的时间以及选票是亲自投下还是缺席投票。创建 `ballot` 表如下:

create table ballot
(
ballot_id int primary key auto_increment,
voter_id int not null unique,
ballot_type varchar(10) not null,
ballot_cast_datetime datetime not null default now(),
constraint foreign key (voter_id) references voter(voter_id),
constraint check(ballot_type in ('in-person', 'absentee'))
);


`ballot_id` 列是该表的主键,当你向表中插入新的选票行时,它的值会自动递增。

你为 `voter_id` 列使用了 `unique` 约束,以确保每个选民在表中只有一张选票。如果选民尝试投票超过一次,只有第一次选票会被计入,之后的选票将被拒绝。

`ballot_cast_datetime` 列保存选票投下的日期和时间。你设置了一个 `default`,以便如果该列没有提供值,`now()` 函数将自动写入当前日期和时间。

你在 `ballot` 表的 `voter_id` 列上设置了外键约束,拒绝任何来自 `voter` 表中没有记录的选民提交的选票。

最后,你在 `ballot_type` 列上添加一个 `check` 约束,只允许值为 `in-person` 或 `absentee`。任何其他类型的选票将被拒绝。

### 竞选表

`race` 表存储有关你选举中每个竞选的信息,包括竞选名称以及选民在竞选中可以投票的候选人数量。你将像这样创建它:

create table race
(
race_id int primary key auto_increment,
race_name varchar(100) not null unique,
votes_allowed int not null
);


`race_id` 列是该表的主键,并设置为自动递增。你为 `race_name` 列定义了 `unique` 约束,以确保相同名称的竞选(如 `Treasurer`)不会被重复插入到表中。

`votes_allowed` 列保存选民在该竞选中可以选择的候选人数量。例如,选民可以在市长竞选中选择一位候选人,在学校委员会竞选中选择两位候选人。

### 候选人表

接下来,你将创建 `candidate` 表,存储所有参与竞选的候选人信息:

create table candidate
(
candidate_id int primary key auto_increment,
race_id int not null,
candidate_name varchar(100) not null unique,
candidate_address varchar(100) not null,
candidate_party varchar(20),
incumbent_flag bool,
constraint foreign key (race_id) references race(race_id)
);


`candidate_id` 列是该表的主键。这不仅防止了重复行被误插入,而且还强制执行了一个 *业务规则*——即候选人只能参加一个竞选。例如,如果一个候选人试图同时竞选市长和财务主管,第二行将会被拒绝。你还将 `candidate_id` 列设置为自动递增。

`race_id` 列存储候选人参与的竞选的 ID。`race_id` 被定义为指向 `race` 表中 `race_id` 列的外键。这意味着 `candidate` 表中不能有一个 `race_id` 值,除非该值也出现在 `race` 表中。

你将`candidate_name`定义为唯一,以确保表中不会有两个候选人使用相同的名字。

### `ballot_candidate` 表

现在,你将创建最终的表格`ballot_candidate`。该表用于跟踪哪些候选人在哪些选票中获得了选票。

create table ballot_candidate
(
ballot_id int,
candidate_id int,
primary key (ballot_id, candidate_id),
constraint foreign key (ballot_id) references ballot(ballot_id),
constraint foreign key (candidate_id) references candidate(candidate_id)
);


这是一个关联表,引用了`ballot`和`candidate`表。该表的主键由`ballot_id`和`candidate_id`列组成。这样可以强制执行一个规则,即同一张选票上不能为同一候选人投超过一票。如果有人尝试插入一个重复的行,`ballot_id`和`candidate_id`相同,则该行会被拒绝。两个列也是外键。`ballot_id`列用于与`ballot`表连接,`candidate_id`则用于与`candidate`表连接。

通过在表格中定义这些约束,你可以提高数据库中数据的质量和完整性。

## 添加触发器

你将为表格创建多个触发器,以执行业务规则并跟踪数据变更以进行审计。这些触发器将在插入、更新或删除行之前或之后触发。

### `Before`触发器

你将使用触发器,这些触发器会在数据变动*之前*触发,以防止不符合业务规则的数据被写入表格中。在第十二章中,你创建了一个触发器,它会在数据保存到表格之前,将低于 300 的信用分数改为恰好 300。对于这个项目,你将使用`before`触发器来确保选民不会*超额投票*,即投票给比该竞选项目允许的更多的候选人。你还将使用`before`触发器来防止特定用户对某些表格进行更改。并不是所有表格都需要`before`触发器。

#### 业务规则

你将使用`before`触发器强制执行一些业务规则。首先,尽管所有选举工作人员都可以对`ballot`和`ballot_candidate`表进行更改,但只有州务卿可以更改`voter`、`race`和`candidate`表中的数据。你将创建以下`before`触发器来执行这个业务规则:

| `tr_voter_bi` | 防止其他用户插入选民 |
| --- | --- |
| `tr_race_bi` | 防止其他用户插入竞选项目 |
| `tr_candidate_bi` | 防止其他用户插入候选人 |
| `tr_voter_bu` | 防止其他用户更新选民信息 |
| `tr_race_bu` | 防止其他用户更新竞选项目 |
| `tr_candidate_bu` | 防止其他用户更新候选人信息 |
| `tr_voter_bd` | 防止其他用户删除选民 |
| `tr_race_bd` | 防止其他用户删除竞选项目 |
| `tr_candidate_bd` | 防止其他用户删除候选人 |

这些触发器将阻止用户进行更改,并显示一条错误消息,解释只有州务卿可以更改这些数据。

其次,选民可以为每个竞选选择一定数量的候选人。选民可以选择不为某个竞选选择任何候选人,或者选择少于最大允许数量的候选人,但他们不能选择超过最大允许数量的候选人。你将通过创建`tr_ballot_candidate_bi`触发器来防止选民过度投票。

这些就是你为这个项目所需的所有插入前触发器。记住,并不是所有表都需要插入前触发器。

#### 插入前触发器

你需要为你的项目创建四个*插入前*触发器。其中三个触发器将阻止除了州务卿以外的用户在`voter`、`race`和`candidate`表中插入数据。另一个插入前触发器将防止选民在某个竞选中投票给过多的候选人。

在列表 17-1 中,你编写了插入前触发器,防止除了州务卿以外的用户在`voter`表中插入新记录。

drop trigger if exists tr_voter_bi;

delimiter //

create trigger tr_voter_bi
before insert on voter
for each row
begin
if user() not like 'secretary_of_state%' then
❶ signal sqlstate '45000'
set message_text = 'Voters can be added only by the Secretary of State';
end if;
end//

delimiter ;


列表 17-1:定义`tr_voter_bi`触发器

首先,为了防止触发器已存在,你会在重新创建之前删除它。你将`tr_voter_bi`触发器定义为`before insert`触发器。对于每一行插入到`voter`表中的数据,你会检查插入新选民的用户姓名是否以`secretary_of_state`开头。

`user()`函数返回用户名和主机名,例如`secretary_of_state@localhost`。如果该字符串不以`secretary_of_state`开头,意味着是除了州务卿以外的其他人试图插入选民记录。在这种情况下,你将使用`signal`语句发送错误信息❶。

你可能还记得在第十二章中提到过,`sqlstate`是一个五字符代码,用于标识错误和警告。你使用的值`45000`表示一种错误状态,这会导致触发器退出,从而防止该行被写入`voter`表。

你可以使用`set message_text`语法来定义要显示的消息。注意,这一行是`signal`命令的一部分,因为`signal`行末没有分号。你本可以将这两行合并成一行,如下所示:

signal sqlstate '45000' set message_text = 'Voters can be added only...';


这个`tr_voter_bi`触发器防止除州务卿以外的用户插入选民记录。

现在,编写你的`tr_ballot_candidate_bi`触发器,以防止选民在竞选中投票给过多的候选人(列表 17-2)。

drop trigger if exists tr_ballot_candidate_bi;

delimiter //

create trigger tr_ballot_candidate_bi
before insert on ballot_candidate
for each row
begin
declare v_race_id int;
declare v_votes_allowed int;
declare v_existing_votes int;
declare v_error_msg varchar(100);
declare v_race_name varchar(100);

❶ select r.race_id,
r.race_name,
r.votes_allowed
❷ into v_race_id,
v_race_name,
v_votes_allowed
from race r
join candidate c
on r.race_id = c.race_id
where c.candidate_id = new.candidate_id;

❸ select count(*)
into v_existing_votes
from ballot_candidate bc
join candidate c
on bc.candidate_id = c.candidate_id
and c.race_id = v_race_id
where bc.ballot_id = new.ballot_id;

if v_existing_votes >= v_votes_allowed then
select concat('Overvoting error: The ',
v_race_name,
' race allows selecting a maximum of ',
v_votes_allowed,
' candidate(s) per ballot.'
)
into v_error_msg;

❹ signal sqlstate '45000' set message_text = v_error_msg;
end if;
end//

delimiter ;


列表 17-2:定义`tr_ballot_candidate_bi`触发器

在新记录插入到`ballot_candidate`表之前,你的触发器会查找该竞选中的投票数限制。然后,它会检查该投票和竞选对应的`ballot_candidate`表中现有的行数。如果现有的投票数大于或等于最大允许数,新记录将被阻止插入。(现有的投票数不应超过最大允许数,但你仍会检查以确保完整性。)

你在触发器中声明了五个变量:`v_race_id` 存储选举 ID,`v_race_name` 存储选举名称,`v_existing_votes` 存储该选票上已经投给该选举候选人的投票数,`v_votes_allowed` 存储选民可以在该选举中选择的候选人数量,`v_error_msg` 变量则存储错误消息,以便在选了太多候选人时显示给用户。

在第一个 `select` 语句 ❶ 中,你使用即将插入到表中的 `candidate_id`(即 `new.candidate_id`)来获取候选人参选的选举信息。你通过连接 `race` 表,获取 `race_id`、`race_name` 和 `votes_allowed`,并将它们保存到变量中 ❷。

在你的第二个 `select` 语句中,你会获取已经存在于 `ballot_candidate` 表中的投票数量,用于该选举和该选票 ❸。你通过连接 `candidate` 表,获取参选该选举的候选人列表。然后,你统计 `ballot_candidate` 表中与这些候选人之一和该选票 ID 相关的行数。

如果 `ballot_candidate` 表已经有该选票和选举的最大投票数,你将使用 `signal` 命令,并带上 `sqlstate` 代码 `45000` 来退出触发器,并防止新行被写入 `ballot_candidate` 表 ❹。你将显示存储在 `v_error_msg` 变量中的错误信息给用户:

Overvoting error: The Mayor race allows selecting a maximum of 1 candidate(s) per ballot.


#### 更新前触发器

你还需要通过编写 `tr_voter_bu` 触发器来防止除州务卿外的其他用户更新选民行,如 清单 17-3 所示。

drop trigger if exists tr_voter_bu;

delimiter //

create trigger tr_voter_bu
before update on voter
for each row
begin
if user() not like 'secretary_of_state%' then
signal sqlstate '45000'
set message_text = 'Voters can be updated only by the Secretary of State';
end if;
end//

delimiter ;


清单 17-3:定义 `tr_voter_bu` 触发器

这个触发器将在 `voter` 表中的一行被更新之前触发。

尽管插入前触发器和更新前触发器类似,但没有办法将它们合并成一个触发器。MySQL 没有办法编写 `before insert or update` 类型的触发器;它要求你编写两个单独的触发器。不过,你可以从触发器中调用存储过程。如果两个触发器有类似的功能,你可以将该功能添加到存储过程中,并让每个触发器调用该过程。

#### 删除前触发器

接下来,你将编写 `tr_voter_bd` 触发器,以防止除州务卿以外的任何用户删除选民数据(清单 17-4)。

drop trigger if exists tr_voter_bd;

delimiter //

create trigger tr_voter_bd
before delete on voter
for each row
begin
if user() not like 'secretary_of_state%' then
signal sqlstate '45000'
set message_text = 'Voters can be deleted only by the Secretary of State';
end if;
end//

delimiter ;


清单 17-4:定义 `tr_voter_bd` 触发器

### 更新后触发器

你将编写在数据插入、更新或删除后触发的触发器,以跟踪对表所做的更改。但由于 `after` 触发器的目的是将行写入审计表,因此你需要首先创建这些审计表。这些审计表保存对你表中数据所做更改的记录,类似于你在 第十二章 中看到的内容。

#### 审计表

为审计表命名时,使用`_audit`后缀。例如,你将跟踪对`voter`表所做的更改,并将其记录在`voter_audit`表中。你将以这种方式命名所有审计表,以便明确它们跟踪的数据。

按照列表 17-5 中的示例创建审计表。

create table voter_audit
(
audit_datetime datetime,
audit_user varchar(100),
audit_change varchar(1000)
);

create table ballot_audit
(
audit_datetime datetime,
audit_user varchar(100),
audit_change varchar(1000)
);

create table race_audit
(
audit_datetime datetime,
audit_user varchar(100),
audit_change varchar(1000)
);

create table candidate_audit
(
audit_datetime datetime,
audit_user varchar(100),
audit_change varchar(1000)
);

create table ballot_candidate_audit
(
audit_datetime datetime,
audit_user varchar(100),
audit_change varchar(1000)
);


列表 17-5:在定义后触发器之前创建审计表

所有审计表的结构都是一样的。每个表都有一个`audit_datetime`列,用于存储更改的日期和时间,一个`audit_user`列,用于存储进行更改的用户的姓名,以及一个`audit_change`列,用于存储更改的数据描述。当你在投票应用程序中发现数据似乎不对时,可以查看这些审计表,获取更多信息。

接下来,对于每个数据表,你将创建三个触发器,这些触发器在`insert`、`update`或`delete`操作后触发。触发器的名称显示在表 17-1 中。

表 17-1:后触发器名称

| **表** | **后插入触发器** | **后更新触发器** | **后删除触发器** |
| --- | --- | --- | --- |
| `voter` | `tr_voter_ai` | `tr_voter_au` | `tr_voter_ad` |
| `ballot` | `tr_ballot_ai` | `tr_ballot_au` | `tr_ballot_ad` |
| `race` | `tr_race_ai` | `tr_race_au` | `tr_race_ad` |
| `candidate` | `tr_candidate_ai` | `tr_candidate_au` | `tr_candidate_ad` |
| `ballot_candidate` | `tr_ballot_candidate_ai` | `tr_ballot_candidate_au` | `tr_ballot_candidate_ad` |

你将从每个表的`after_insert`触发器开始。

#### 后插入触发器

`tr_voter_ai`触发器将在向`voter`表中插入新行后触发,向`voter_audit`表添加行,以跟踪新数据(参见列表 17-6)。

drop trigger if exists tr_voter_ai;

delimiter //

create trigger tr_voter_ai
after insert on voter
❶ for each row
begin
insert into voter_audit
(
audit_datetime,
audit_user,
audit_change
)
values
(
❷ now(),
user(),
concat(
❸ 'New Voter added -',
' voter_id: ', new.voter_id,
' voter_name: ', new.voter_name,
' voter_address: ', new.voter_address,
' voter_county: ', new.voter_county,
' voter_district: ', new.voter_district,
' voter_precinct: ', new.voter_precinct,
' voter_party: ', new.voter_party,
' voting_location: ', new.voting_location,
' voter_registration_num: ', new.voter_registration_num
)
);
end//

delimiter ;


列表 17-6:定义`tr_voter_ai`触发器

要创建触发器,首先检查`tr_voter_ai`触发器是否已经存在。如果存在,则先删除它,然后再重新创建。由于 SQL `insert`语句可以插入一行或多行数据,因此你需要指定,对于每一行插入到`voter`表中的数据,你希望在`voter_audit`表中写入一行数据 ❶。

在`audit_datetime`列中,使用`now()`函数插入当前日期和时间 ❷。在`audit_user`列中,使用`user()`函数插入进行更改的用户名称。`user()`函数还会返回用户的主机名,因此用户名后面会跟随一个@符号和主机名,如`clerk_238@localhost`。

你在`audit_change`列中使用`concat()`函数构建一个字符串,显示被插入的值。你以文本`New voter added -` ❸开始,通过使用`insert`触发器中可用的`new`关键字获取插入的值。例如,`new.voter_id`显示刚刚插入到`voter`表中的`voter_id`。

当向`voter`表中添加一行新数据时,`tr_voter_ai`触发器会触发,并将像以下这样的值写入`voter_audit`表:

audit_datetime: 2024-05-04 14:13:04

audit_user: secretary_of_state@localhost

audit_change: New voter added – voter_id: 1 voter_name: Susan King
voter_address: 12 Pleasant St. Springfield
voter_county: Franklin voter_district: 12A voter_precinct: 4C
voter_party: Democrat voting_location: 523 Emerson St.
voter_registration_num: 129756


该触发器将新选民的日期时间、用户(和主机名)以及详细信息写入审计表。

#### 删除后触发器

在示例 17-7 中,你编写了删除后触发器`tr_voter_ad`,它将在`voter`表中的行被删除后触发,并追踪这些删除到`voter_audit`表中。

drop trigger if exists tr_voter_ad;

delimiter //

create trigger tr_voter_ad
❶ after delete on voter
for each row
begin
insert into voter_audit
(
audit_datetime,
audit_user,
audit_change
)
values
(
now(),
user(),
concat(
'Voter deleted -',
' voter_id: ', old.voter_id,
' voter_name: ', old.voter_name,
' voter_address: ', old.voter_address,
' voter_county: ', old.voter_county,
' voter_district: ', old.voter_district,
' voter_precinct: ', old.voter_precinct,
' voter_party: ', old.voter_party,
' voting_location: ', old.voting_location,
' voter_registration_num: ', old.voter_registration_num
)
);
end//

delimiter ;


示例 17-7:定义`tr_voter_ad`触发器

你将此触发器定义为`voter`表上的`after delete`触发器 ❶。你使用`user()`和`now()`函数获取删除`voter`行的用户及该行被删除的日期和时间。你构建一个字符串,使用`concat()`函数,显示已删除的值。

删除后触发器看起来与插入后触发器类似,但你使用`old`关键字代替`new`。你可以在列名之前加上`old`和句点来获取它们的值。例如,使用`old.voter_id`来获取刚删除的行中`voter_id`列的值。

当一行从`voter`表中删除时,`tr_voter_ad`触发器被触发,并将一行像以下这样的值写入`voter_audit`表:

audit_datetime: 2024-05-04 14:28:54

audit_user: secretary_of_state@localhost

audit_change: Voter deleted – voter_id: 87 voter_name: Ruth Bain
voter_address: 887 Wyoming St. Centerville
voter_county: Franklin voter_district: 12A voter_precinct: 4C
voter_party: Republican voting_location: 523 Emerson St.
voter_registration_num: 45796


该触发器将删除选民记录的日期时间、用户(和主机名)以及详细信息写入审计表。

#### 更新后触发器

现在你将编写更新后触发器`tr_voter_au`,该触发器将在`voter`表中的行被更新后触发,并将变更追踪到`voter_audit`表中(示例 17-8)。

drop trigger if exists tr_voter_au;

delimiter //

create trigger tr_voter_au
after update on voter
for each row
begin
set @change_msg = concat('Voter ',old.voter_id,' updated: ');

❶ if (new.voter_name != old.voter_name) then
❷ set @change_msg =
concat(
@change_msg,
'Voter name changed from ',
old.voter_name,
' to ',
new.voter_name
);
end if;

if (new.voter_address != old.voter_address) then
set @change_msg =
concat(
@change_msg,
'. Voter address changed from ',
old.voter_address,
' to ',
new.voter_address
);
end if;

if (new.voter_county != old.voter_county) then
set @change_msg =
concat(
@change_msg,
'. Voter county changed from ', old.voter_county, ' to ',
new.voter_county
);
end if;

if (new.voter_district != old.voter_district) then
set @change_msg =
concat(
@change_msg,
'. Voter district changed from ',
old.voter_district,
' to ',
new.voter_district
);
end if;

if (new.voter_precinct != old.voter_precinct) then
set @change_msg =
concat(
@change_msg,
'. Voter precinct changed from ',
old.voter_precinct,
' to ',
new.voter_precinct
);
end if;

if (new.voter_party != old.voter_party) then
set @change_msg =
concat(
@change_msg,
'. Voter party changed from ',
old.voter_party,
' to ',
new.voter_party
);
end if;

if (new.voting_location != old.voting_location) then
set @change_msg =
concat(
@change_msg,
'. Voting location changed from ',
old.voting_location, '
to ',
new.voting_location
);
end if;

if (new.voter_registration_num != old.voter_registration_num) then
set @change_msg =
concat(
@change_msg,
'. Voter registration number changed from ',
old.voter_registration_num,
' to ',
new.voter_registration_num
);
end if;

insert into voter_audit(
audit_datetime,
audit_user,
audit_change
)
values (
now(),
user(),
❸ @change_msg
);

end//

delimiter ;


示例 17-8:定义`tr_voter_au`触发器

因为更新后触发器会在行被更新后触发,所以它可以同时使用`new`和`old`关键字。例如,你可以通过检查`new.voter_name != old.voter_name` ❶来查看`voter`表中的`voter_name`列值是否已更新。如果选民的名字的新值与旧值不同,则表示该值已更新,你将保存此信息并写入`audit`表。

对于你的`insert`和`delete`触发器,你将`voter`表中*所有*列的值写入`voter_audit`表,但对于`update`触发器,你只会报告已更改的列值。

例如,如果你运行了这个`update`语句

update voter
set voter_name = 'Leah Banks-Kennedy',
voter_party = 'Democrat'
where voter_id = 5876;


你的`update`触发器将只把这些更改写入`voter_audit`表:

audit_datetime: 2024-05-08 11:08:04

audit_user: secretary_of_state@localhost

audit_change: Voter 5876 updated: Voter name changed from Leah Banks
to Leah Banks-Kennedy. Voter party changed from Republican
to Democrat


由于只有`voter_name`和`voter_party`这两个列值发生了变化,你将把这两个更改写入审计表。

为了捕捉所做的更改,你创建了一个名为`@change_msg`的变量❷。通过`if`语句,你检查每个列值是否发生了变化。当某列的值发生变化时,你使用`concat()`函数将该列变化的信息添加到现有`@change_msg`字符串变量的末尾。检查完所有列值的变化后,你将`@change_msg`变量的值写入审计表的`audit_change`列❸。你还会将做出更改的用户名写入`audit_user`列,并将更改的日期和时间写入`audit_datetime`列。

你已经成功构建了一个数据库,它不仅存储了选举数据,还包括了约束和触发器,确保数据保持高质量。

## 替代方法

就像上一章中的`weather`数据库项目一样,编写这个`voter`数据库有很多不同的方法。

### 审计表

在这个项目中,你创建了五个不同的审计表。你本可以只创建一个审计表,并将所有审计记录写入其中。或者,你本可以创建 15 个审计表:每个表三个。例如,与其审计`voter_audit`表中的选民插入、删除和更新,你本可以将新选民的记录审计到名为`voter_audit_insert`的表中,将选民更改记录审计到`voter_audit_update`,将删除操作审计到`voter_audit_delete`。

### 触发器与权限

与其使用触发器来控制哪些用户可以更新哪些表,数据库管理员本可以通过授予和撤销这些权限来控制数据库用户。使用触发器的优势在于,你可以向用户显示自定义消息,解释问题,例如`只有州务卿才能添加选民`。

### 用新表替代检查约束

当你创建`ballot`表时,使用了以下`check`约束,确保`ballot_type`列的值为`in-person`或`absentee`:

constraint check(ballot_type in ('in-person', 'absentee'))


另一种方法是创建一个`ballot_type`表,其中包含每种选票类型的行,像这样:

ballot_type_id ballot_type


   1        in-person
   2        absentee

你本可以添加一个名为`ballot_type`的表,并将`ballot_type_id`列设为主键。如果这样做,你将会在`ballot`表中保存`ballot_type_id`,而不是`ballot_type`。这将如下所示:图 17-3。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f17003.png)

图 17-3:创建一个`ballot_type`表以存储选票类型

这种方法的一个优点是,你可以添加新的选票类型,比如`military`或`overseas`,而无需更改`ballot`表的定义。对于`ballot`表的每一行来说,保存一个代表选票类型的 ID,比如`3`,而不是保存完整的名称,如`absentee`,也是更高效的。

你本可以对`voter`表使用类似的方法。与其创建包含`voter_county`、`voter_district`、`voter_precinct`和`voter_party`列的`voter`表,不如只保存 ID:`voter_county_id`、`voter_district_id`、`voter_precinct_id`和`voter_party_id`,然后引用名为`county`、`district`、`precinct`和`party`的新表来获取有效 ID 的列表。

在创建数据库时有足够的创意空间,因此不必觉得需要严格按照我在本项目中使用的方法来操作。尝试这些替代方法,看看它们对你是否有效!

## 总结

在本章中,你构建了一个投票数据库,用于存储选举数据。你通过使用约束和触发器防止了数据完整性问题,并通过审计表跟踪了数据的变化。你还看到了这个项目的一些可能替代方案。在第三个也是最后一个项目中,你将使用视图来隐藏敏感的薪资数据。


# 第十八章:使用视图保护工资数据

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

在这个项目中,你将使用视图来隐藏员工表中的敏感工资数据。该公司在每个部门(人力资源、营销、会计、技术和法律)都有一个数据库用户,他们被允许访问大多数员工数据。然而,只有人力资源的用户可以访问员工的工资数据。

视图可以隐藏敏感数据,但也可以用于简化对复杂查询的访问,或选择表中仅相关的数据——例如,只显示某个特定部门的表行。

## 创建员工表

首先创建你的`business`数据库:

create database business;


接下来,创建一个`employee`表,用于存储公司中每个员工的信息,包括全名、职位和工资:

use business;

create table employee
(
employee_id int primary key auto_increment,
first_name varchar(100) not null,
last_name varchar(100) not null,
department varchar(100) not null,
job_title varchar(100) not null,
salary decimal(15,2) not null
);


由于你将`employee_id`列设置为`auto_increment`,因此在向`employee`表中插入新行时,不需要提供`employee_id`的值。MySQL 会为你跟踪该值,并确保每插入一行,`employee_id`值会递增。将以下数据添加到你的表中:

insert into employee(first_name, last_name, department, job_title, salary)
values ('Jean',' Unger', 'Accounting', 'Bookkeeper', 81200);

insert into employee(first_name, last_name, department, job_title, salary)
values ('Brock', 'Warren', 'Accounting', 'CFO', 246000);

insert into employee(first_name, last_name, department, job_title, salary)
values ('Ruth', 'Zito', 'Marketing', 'Creative Director', 178000);

insert into employee(first_name, last_name, department, job_title, salary)
values ('Ann', 'Ellis', 'Technology', 'Programmer', 119500);

insert into employee(first_name, last_name, department, job_title, salary)
values ('Todd', 'Lynch', 'Legal', 'Compliance Manager', 157000);


现在,查询表以查看插入的行:

select * from employee;


结果如下:

employee_id first_name last_name department job_title salary


1 Jean Unger Accounting Bookkeeper 81200.00
2 Brock Warren Accounting CFO 246000.00
3 Ruth Zito Marketing Creative Director 178000.00
4 Ann Ellis Technology Programmer 119500.00
5 Todd Lynch Legal Compliance Manager 157000.00


`employee`表的数据看起来不错,但你希望隐藏`salary`列,除人力资源用户外,其他人不能访问同事的敏感信息。

## 创建视图

你将不允许所有数据库用户访问`employee`表,而是让他们访问一个名为`v_employee`的视图,该视图包含`employee`表中的所有列,除了`salary`列。如第十章所讨论,视图是基于查询的虚拟表。创建视图的方法如下:

create view v_employee as
select employee_id,
first_name,
last_name,
department,
job_title
from employee;


你在`select`语句中遗漏了`salary`列,因此当你查询视图时,结果中不应出现该列:

select * from v_employee;


结果如下:

employee_id first_name last_name department job_title


1 Jean Unger Accounting Bookkeeper
2 Brock Warren Accounting CFO
3 Ruth Zito Marketing Creative Director
4 Ann Ellis Technology Programmer
5 Todd Lynch Legal Compliance Manager


如预期的那样,`v_employee`视图包含除了`salary`以外的每一列。

接下来,你将更改`employee`数据库的权限,以允许人力资源部门在底层`employee`表中进行更改。由于`v_employee`是视图,`employee`表的更改将在视图中立即反映出来。

## 控制权限

为了调整数据库中的权限,你将使用`grant`命令,该命令授予 MySQL 数据库用户特权,并控制哪些用户可以访问哪些表。

每个部门有一个数据库用户:`accounting_user`、`marketing_user`、`legal_user`、`technology_user`和`hr_user`。通过输入以下命令,只授予`hr_user`对`employee`表的访问权限:

grant select, delete, insert, update on business.employee to hr_user;


你已授予`hr_user`在`business`数据库中选择、删除、插入和更新`employee`表的权限。你不会将这些权限授予其他部门的用户。例如,如果`accounting_user`尝试查询`employee`表,他们将收到以下错误消息:

Error Code: 1142. SELECT command denied to user 'accounting_user'@'localhost'
for table ‘employee’


现在,您将授予所有部门的用户对 `v_employee` 视图的查询访问权限:

grant select on business.v_employee to hr_user@localhost;
grant select on business.v_employee to accounting_user@localhost;
grant select on business.v_employee to marketing_user@localhost;
grant select on business.v_employee to legal_user@localhost;
grant select on business.v_employee to technology_user@localhost;


所有部门的用户都可以从 `v_employee` 视图中选择数据,以访问他们需要的员工信息。

对于这个项目,您可以使用安装 MySQL 时创建的 `root` 超级用户帐户授予权限(请参见 第一章)。在实际生产环境中,您的数据库管理员(DBA)通常会创建其他帐户,而不是使用具有所有权限并可以做任何事的 `root` 帐户。在专业环境中,很少有人知道 `root` 密码。DBA 还可以将权限定义为一个 *角色*,然后将用户添加或移除该角色的成员,但关于角色的详细讨论超出了本书的范围。

## 使用 MySQL Workbench 测试用户访问权限

您将在这个项目中使用 MySQL Workbench,并作为 `root` 连接以创建数据库、表格和部门用户。然后,您将分别创建 `hr_user` 和 `accounting_user` 的连接,查看他们的访问权限有何不同。

首先,为 `root` 用户创建一个连接,使用安装 MySQL 时创建的密码。要创建连接,请点击欢迎界面上 MySQL Connections 文字旁边的 `+` 图标,如 图 18-1 所示。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f18001.png)

图 18-1:创建 MySQL Workbench 连接

设置新连接窗口将会打开,如 图 18-2 所示。在这里,输入一个连接名称(我选择将连接命名为与用户名相同:`root`),并将 `root` 作为用户名输入。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f18002.png)

图 18-2:为 `root` 创建 MySQL Workbench 连接

要保存连接,点击 **OK**。现在您可以通过点击该连接来以 `root` 用户身份登录。

由于 `root` 是一个超级用户帐户,具有所有权限并可以授予其他用户权限,您将使用此连接运行脚本来为您的部门创建数据库、表格、视图和用户。图 18-3 显示了该脚本的结尾部分,但您需要运行完整的脚本,脚本位于 [`github.com/ricksilva/mysql_cc/blob/main/chapter_18.sql`](https://github.com/ricksilva/mysql_cc/blob/main/chapter_18.sql)。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f18003.png)

图 18-3:使用 MySQL Workbench 创建表格、视图和用户并授予访问权限

现在您已经运行脚本为您的部门创建了用户名,接下来将为 `hr_user` 和 `accounting_user` 创建 MySQL Workbench 连接。图 18-4 显示了如何为 `hr_user` 设置新连接。

要为 `hr_user` 创建连接,您输入了一个连接名称和用户名 `hr_user`。您将以相同的方式为 `accounting_user` 创建连接,使用 `accounting_user` 作为连接名称和用户名。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f18004.png)

图 18-4:为 `hr_user` 创建 MySQL Workbench 连接

现在,你在 MySQL Workbench 中有三个可以使用的连接,如图 18-5 所示。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f18005.png)

图 18-5:`root`、`hr_user` 和 `accounting_user` 的 MySQL Workbench 连接

连接会以你创建时使用的名称显示。你可以通过点击相应的连接登录到 MySQL。

你还可以同时打开多个连接。首先以 `hr_user` 身份打开一个连接,然后点击左上角的主页图标返回欢迎界面。在这里,点击 `accounting_user` 的连接,打开另一个连接。

现在你应该在 MySQL Workbench 中看到两个标签页,分别标注为 `hr_user` 和 `accounting_user`,如图 18-6 所示。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f18006.png)

图 18-6:你可以在 MySQL Workbench 中同时打开多个连接。

只需点击相应的标签页,以该用户身份运行查询。点击 `hr_user` 标签页,以 `hr_user` 身份查询 `employee` 表(见图 18-7)。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f18007.png)

图 18-7:以 `hr_user` 身份查询 `employee` 表

现在,点击 `accounting_user` 标签页,再次查询 `employee` 表,如图 18-8 所示。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f18008.png)

图 18-8:`accounting_user` 无法查看 `employee` 表。

因为你作为 `root` 用户没有为 `accounting_user` 授予 `employee` 表的访问权限,返回了错误信息 `SELECT command denied`。然而,`accounting_user` 可以从 `v_employee` 视图中进行选择,因此该用户可以查看员工数据,但不包括薪资(见图 18-9)。

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/f18009.png)

图 18-9:`accounting_user` 能够查询 `v_employee` 视图。

你的其他数据库用户与 `accounting_user` 拥有相同的权限,这意味着他们也无法查询 `employee` 表,因为你没有为他们授予访问权限。

## 另一种方法

还有另一种方法可以隐藏数据,防止特定用户访问。MySQL 允许你在列级别授予权限;例如,你可以在 `employee` 表的所有列上授予 `select` 权限,除了 `salary` 列:

grant select(employee_id, first_name, last_name, department, job_title)
on employee
to technology_user@localhost;


这允许 `technology_user` 从 `employee` 表中选择任何或所有的 `employee_id`、`first_name`、`last_name`、`department` 或 `job_title` 列,如下所示:

select employee_id,
first_name,
last_name
from employee;


结果是:

employee_id first_name last_name


 1       Jean        Unger
 2       Brock       Warren
 3       Ruth        Zito
 4       Ann         Ellis
 5       Todd        Lynch

由于你没有为 `salary` 列授予 `select` 权限,MySQL 将阻止 `technology_user` 选择该列:

select salary
from employee;


结果是一个错误信息:

SELECT command denied to user 'technology_user'@'localhost' for table 'employee'


如果 `technology_user` 尝试使用 `*` 通配符选择所有列,他们将收到相同的错误信息,因为他们无法返回 `salary` 列。因此,我不推荐这种方法,因为它可能会导致混淆。更直观的做法是通过视图允许用户访问所有允许的表。

## 总结

在这个项目中,你使用了视图来隐藏特定用户的薪资信息。这种技术可以用来隐藏表中任何类型的敏感数据。你还学习了如何授予和撤销数据库用户的权限,帮助通过将特定数据暴露给特定用户来创建安全的数据库。

完成这三个项目后,你将能够构建自己的数据库,从文件加载数据,创建触发器以维护数据质量,并使用视图来保护敏感数据。

祝你在 MySQL 之旅的下一阶段好运!


# 第十九章:后记

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/mysql-crs-crs/img/chapterart.png)

恭喜你!你已经学到了很多关于 MySQL 开发的知识,并将这些新知识应用到了实际项目中。

我希望本书中传达的一个概念是,操作 MySQL 有很多不同的方法。设计和开发数据库有充足的创意空间。你可以利用你对 MySQL 的知识,打造高度定制化的系统,满足你特定的需求和兴趣,从你最喜欢的棒球统计数据到你创业公司的客户列表,再到一个庞大的面向网络的企业收购数据库。

为了深入学习 MySQL 开发,你可以将感兴趣的公共数据集加载到你自己的 MySQL 数据库中。像[`data.gov`](https://data.gov)和[`www.kaggle.com`](https://www.kaggle.com)这样的网站提供免费使用的数据。(不过,记得查看你希望使用的数据集的具体条款。)看看你是否能够将来自不同来源的数据集加载到 MySQL 表中,并以一种能产生新颖或有趣见解的方式将它们连接起来。

我祝贺你已经取得的进展。学会在行列中思考可不是一件小事。希望你能继续花时间在一生中学习新技能。知识无疑是力量。
posted @ 2025-11-26 09:19  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报