迁移
位于 db/migrate/<version>_<name>.rb 的迁移是 Cybertrain::Migration::Base 的子类。它的 change 记录 schema 操作(create_table、add_column、add_index、rename_column、add_reference、remove_column、remove_index、drop_table);cybertrain db migrate 在各自的事务中执行每个待处理的版本,并根据数据库重写 db/schema.rb,cybertrain db rollback [N] 则运行 change 的逆操作。当 change 中包含没有自动逆操作的操作时,请编写 down(以及 up)。
需要数据步骤才能安全进行的 schema 变更使用 execute(sql, binds =
[])。它在迁移中自己所在的位置运行,使用迁移器自己的连接,并与该版本的 schema 操作处于同一个事务中,所以在它之前添加的列在它运行时已经存在,而一旦失败,整个版本都会撤销:
class AddRetentionDaysToBackups < Cybertrain::Migration::Base
def change
add_column "backups", "retention_days", :integer, null: false, default: "90"
execute "UPDATE backups SET retention_days = ?", [0] # 已有的行保持“永久”
end
def down
remove_column "backups", "retention_days"
end
end
不带绑定参数时,SQL 可以是多条用 ; 分隔的语句;带绑定参数时,它是一条带 ? 占位符的语句。execute 没有逆操作:回滚一个执行了 SQL 且没有自己的 down 的 change 时,会在撤销任何内容之前以 version N is irreversible: it executes SQL in change; write down 停止。数据语句永远不会出现在 db/schema.rb 中,因为它是从数据库导出的。记录迁移时不会运行任何东西,所以该语句在其版本每次 migrate 时恰好运行一次。
where 之外的查询
生成的 relation 涵盖 where / where_sql / order / limit / offset / count / exists? / delete_all。对于大多数应用还需要的计数器、统计和搜索框,提供的是带类型的补充方法(issue #74,docs/design.md D22),而不是查询构建器;每个方法接收 SQL 列名,会检查并为其加引号(其他内容会引发 ArgumentError),并绑定每个值:
# INSERT ... ON CONFLICT (project_id, key) DO UPDATE SET ...;返回写入的行数
Stat.upsert({ project_id: project.id, key: path, calls: 1, max_ms: ms, seen_at: Time.now },
conflict: [:project_id, :key],
update: { calls: "calls + excluded.calls", max_ms: "MAX(max_ms, excluded.max_ms)",
seen_at: "excluded.seen_at" })
Article.where(id: article.id).increment(:views) # UPDATE ... SET views = views + 1 (by: n for another step)
Comment.where(article_id: article.id).pluck_int(:id) # => [3, 7] (另有 pluck_str、pluck_float)
Comment.all.count_by(:article_id) # => {"1" => 2, "4" => 1}
Comment.all.count_by_sql("strftime('%Y-%m-%dT%H', created_at)") # 按小时分组
Stat.where(project_id: 1).sum_int(:calls) # 另有 sum_float
Article.where_like(:title, params[:q].to_s) # `title` LIKE '%...%' ESCAPE '\'
- 每个方法都带类型,因为 Spinel 的方法只有一种返回类型:
pluck_str/pluck_int/pluck_float只读取一列,sum_int/sum_float只对一列求和,count_by返回Hash{String => Integer}。 upsert的update值和count_by_sql的表达式都是 SQL 文本,与order_sql一样:绝不要用请求数据来构造它们。空的update相当于DO NOTHING。upsert和increment不加载模型,不运行验证或回调,也不填写时间戳。count_by的键是 String(经由Cast.str),按升序排列;NULL 和""共用键""。带有limit/offset时,count_by、sum_*和increment作用于那个窗口,与count和delete_all(通过子查询)一样。where_like会替你转义文本中的\、%和_,并且是子串匹配(对 ASCII 不区分大小写,与 SQLite 的 LIKE 相同)。前缀搜索需要自己写模式:Article.all.where_sql("title LIKE ? ESCAPE '\\'", [Cybertrain::Cast.like_escape(q) + "%"])。- 对于手写的 SQL,
Cybertrain::Cast.binds(a, b, c)构造execute所接收的绑定参数数组(Time 转为 UTC 的 ISO 8601 文本,布尔值转为 1/0):Cybertrain::DB.with { |c| c.execute(sql, Cybertrain::Cast.binds(id, since)) }。 Cybertrain::Cast.hash(v)/Cast.array(v)把解析得到的 JSON 值收窄为 Hash / Array;如果它不是,则返回一个新的空 Hash / Array(会先检查 Time,spikes/NOTES.md 规则 8)。它们返回带类型的浅拷贝,而不是v本身;Cast.empty_hash/Cast.empty_array是预置类型的空容器。
分页
列表页面从 ?page=N 读取页码,对 relation 取窗口,并渲染链接:
# app/controllers/articles_controller.rb
def index
@articles = Article.all.order("created_at DESC")
@page = @articles.page(params.page_number, per: 20)
end
<%# app/views/articles/index.html.erb %>
<% @articles.each do |article| %>
<p><%= link_to article.title, article %></p>
<% end %>
<%= paginate @page, articles_path %>
params.page_number 是从 1 开始的 page 参数,缺失或不是数字时为 1,并被限制在 1..max_page 范围内(config.max_page,默认 10000,CYBERTRAIN_MAX_PAGE);它从不把长度不受限的文本交给 String#to_i,后者在 Spinel 下超过 2**63-1 时会抛出 RangeError。params.int(:per, default: 20,
min: 1, max: 100) 对任意键进行同样有界的读取(可选的 - 加上最多 18 位数字,否则取默认值;限制在 min..max 范围内)。
relation.page(n, per: 50) 在 relation 上设置 LIMIT per OFFSET (n - 1) * per(会替换之前的 limit/offset,所以请最后调用它),以同样的方式限制 n,因此偏移量永远不会超过 (max_page - 1) * per,并返回一个 Cybertrain::Page:number、per、row_offset、next?(在窗口上用一条 SELECT 1 ... LIMIT per + 1 查询得出,不使用 COUNT(*))、prev?、next_number、prev_number。行仍然留在 relation 上,所以 @articles.each 产出带类型的 Article;per 超出 1..1000 时会引发 ArgumentError。
paginate @page, path 渲染 <nav class="pager">,其中有 “Newer” 链接(rel="prev")、“Page N” 和 “Older” 链接(rel="next"),没有对应页面时相应的链接会省略;这些链接保留当前的查询字符串,只替换 page(第 1 页时去掉它),并重新编码和转义,所以搜索或筛选条件在翻页后仍然保留;而带有自己查询字符串的路径("/search?q=x")则改用它自己的查询字符串。
仅支持偏移分页:适用于超大表的键集分页(after: id)尚未实现。脚手架生成的 index 视图会列出所有记录;加上上面三行即可对它分页。
事务
Cybertrain::DB.transaction { |c| ... }(或 Article.transaction,同一个东西换成 Rails 的名字)会取出一个连接,运行 BEGIN,把连接传给块,块返回时 COMMIT,块抛出异常时回滚并重新抛出。immediate: true 运行 BEGIN IMMEDIATE,它在开始时就获取 SQLite 的写锁:先读后写的事务应使用它,这样并发的写入者会让它在 BEGIN 处等待,而不是在第一次写入时以 “database is locked” 失败。块运行期间,该线程上所有经过连接池的操作(save、update、destroy、Relation 查询、DB.with)都使用事务的连接,因此都属于这个事务:
Article.transaction(immediate: true) do |c|
article.save!
c.execute("UPDATE authors SET articles_count = articles_count + 1 WHERE id = ?", [article.author_id])
end
它与 Rails 的 transaction 的不同之处,以及应避免的做法:
- 它返回 nil,而不是块的值。
- 通过抛出异常退出,回滚之后异常会被重新抛出(Rails 用
raise ActiveRecord::Rollback退出)。 - 嵌套在块中的
DB.transaction会加入外层事务:只有最外层提交,任何地方抛出异常都会回滚全部内容,没有保存点(没有requires_new: true)。 - 在 Spinel 下不能对传入的
c调用c.transaction { }:请嵌套DB.transaction,或使用下面不带块的形式。 - 在
DB.with块(它不是事务)中调用的DB.transaction会取出第二个连接:在只有一个连接的连接池上(包括:memory:),它会因此永远等待下去。请先开启事务,再在其中完成工作。
必须把 rescue 写在普通方法中的代码可以使用不带块的形式:c.begin!(immediate: true) ... c.commit!,并在 rescue 中调用 c.rollback!。
列名
列名必须是 ASCII 标识符([A-Za-z_][A-Za-z0-9_]*),而 Rails 接受任何列名。带有非 ASCII 列名(名前、prénom)的 schema 在 PR #10 之前可以在 CRuby 下生成,现在 spin run gen 会拒绝它,所以升级时请重命名该列(像 first-name 这样的名字从来都不是合法的方法名)。
如果列名与生成的类或框架在记录上调用的方法同名(errors、save、attributes、hash、to_s、to_ary、raise 等),或与 Ruby 关键字同名(end、class、begin),就会以 <column>_column 的名字生成(hash_column:读方法、写方法和实例变量),并在生成的文件中附上说明。它的两个名称这样使用:
- 它的 SQL 名称在
read_attribute、params 和attributes["hash"]中仍然有效。 - 模板可以用任一名称访问它(
post.hash、post.hash_column),但errors和to_param除外,模板会把它们解析为模型方法,所以在模板中要用post.errors_column。 - 查询 API(
where、order、find_by)始终使用 SQL 列名(Post.where(hash: 1));Ruby 名称只用于读写方法和模板。
与 Object 的其他方法同名的列(display、tap、methods)以原名生成,并附上说明。cybertrain generate scaffold 会拒绝关键字和保留名称(否则它就得调用改过名的读方法),对会遮蔽方法的名称则接受并附上说明,与生成器一致。
如果某个关联的普通名称已被某列、另一个关联或 Model 的方法占用(Rails 允许关联遮蔽 errors),就会以后备名称生成,并在生成的文件中附上注释:has_many 用 <table>_as_<column stem>(comments_as_article),belongs_to 用 <stem>_as_<column>(author_as_author_id)。
排序
Rails 的 order 接受任何 SQL(order("lower(title)")、order("posts.title")、order(params[:sort])),而这里的 order 只接受 column [ASC|DESC] 列表(每个列名都会加引号),遇到其他内容会抛出 ArgumentError:返回 500,因为这个字符串是开发者写的。
请求数据必须先经过允许列表,因为格式正确的项仍可能指向不存在的列(SQLite 返回 500),或客户端不应据以排序的列:
SORTS = { "title" => "title", "newest" => "created_at DESC" }
Post.order(SORTS.fetch(params[:sort].to_s, "id"))
原始的 ORDER BY 文本要通过 order_sql(Post.order_sql("lower(title)"),relation 上也有),它绝不能接触请求数据。