审视 Prisma-Guard 的 Shape:所有权、投影与写操作契约
学会将 Prisma-Guard 定义的架构视为 API 合同来审查,需明确每个值的归属者、哪些数据可以出现在响应中,以及哪些生成的端点能够被调用。
来源: Define Every Prisma Endpoint’s Inputs and Outputs in One Place
Prisma 的参数语言表达能力很强,而在受信任的服务器代码中这正是人们所期望的。然而,一旦通过 HTTP 接受相同的参数结构,该语言的所有功能都会转化为产品层面的决策:调用者可以编写什么内容、服务器可以选择哪些过滤器、必须固定哪些条件、可以返回哪些关联数据,以及一个响应最多能扩展出多少条子记录。prisma-guard 的 shape 功能允许你在 Prisma 操作旁直接记录这些规则。本指南将通过三个问题,展示如何将 shape 视为控制、过滤、投影和修改操作的简洁契约,以便在处理任何新增生成端点的拉取请求时使用。
为何 shape 不仅仅是验证工具
形状确实能够验证输入,但其更大的优势在于将边界判定集中到一处。通过生成的操作,可以依赖该形状,而无需每个处理程序都手动重新构建输入架构和字段允许列表。这不仅消除了重复代码,还避免了不同处理程序在允许的内容上出现隐性差异的风险。
这种便利的代价就是精确性。只有当某个结构与库实际执行的规则一致时,它才能成为可靠的契约,这包括必须明确顶级租户作用域的终止位置。以下内容反映的是代码维护者在编写时所进行的测试结果,而非业界的既定规范,因此请务必参照您所用版本的文档确认详细信息。如果某个受保护的请求已经失败且您想了解原因,可以首先参考我们的基于阶段的 Prisma-Guard 失败诊断模型。
第一个问题:每个值的所有权归属是谁?
结构的每个叶子节点都会将一个值传递给客户端或服务器。相关的词汇量很小,示例中将这四种形式并列展示。
const values = {
clientChooses: true,
serverChooses: 'published',
serverChoosesTrue: force(true),
clientChoosesWithinRule: (base) => base.min(1).max(120),
}
请仔细阅读相关说明:
- 单独的
true并非布尔值,而是一种表示“由客户端决定”的特殊标记。 - 像
'published'这样的字面量属于服务器端。 force(value)也属于服务器端。当您希望固定的值为true时需要使用它,因为单独的true会被视为客户端使用的特殊标记。- 优化函数会将决策权交还给客户端,但会用您直接编写的规则替换该字段生成的 Zod 链。
放置位置会影响 force 的含义
这些词汇本身很简单,令人惊讶的是它们的位置。在where结构中,强制指定的标量值会被放入操作符对象内部。下面的事件过滤器会固定发布状态,同时仍允许客户端搜索标题:
const eventShape = {
where: {
isPublished: { equals: force(true) },
title: { contains: true },
},
}
当where结构的最顶层为强制指定的标量谓词时,该库会对客户端在该字段输入的内容采取宽松的处理方式。无论调用者重复使用已固定的值、发送其他值,还是添加其他操作符,服务器的谓词都会优先生效,不会仅因为提到了该字段就拒绝请求。这与默认情况不同——默认情况下一旦客户端提供了内容,默认设置就会让步;而强制指定则永远不会让步。
在其他所有情况下,强制规则都十分严格。比如客户端控制的操作符旁边存在强制修饰符、关系过滤器中的固定字段、嵌套包含关系中的固定过滤器,或是变更数据中的固定值。在所有这些情形下,该键都会从用于验证的架构中消失,因此即便数值完全一致,发送时也会失败验证。
这对客户端代码而言非常重要。客户端可能不得不完全舍弃由服务器管理的写入字段。即便将其“修正”为服务器的数值,仍然会失败,因为验证的是谁拥有该键,而非数值是否相等。
默认值、异常处理及优化措施也会改变所有权
Zod 的默认值处理和异常捕获机制同样需要仔细审查。在客户端控制的字段中,当缺少值时,默认值会保持其正常行为;而异常捕获机制则可能用备用值替换无效输入,而非直接拒绝请求。创建操作则带来了更多变化:生成器生成的默认值或异常捕获指令甚至可以填充结构定义中未提及的必填字段,其生成的值会被当作服务器直接指定的值来使用。因此,默认值的设置方式决定了该值的归属,只有在 API 规范要求时才应使用替换机制。
这些优化措施其实隐藏着另一个陷阱:它们并不会在节点处扩展已生成的链,而是直接替换它。因此,你的函数必须重新列出端点所依赖的所有约束条件,而不仅仅是你想添加的额外最小值或最大值。当 Prisma 类型比业务规则更为宽松时,这一点尤为重要,比如某个字符串列就绝不能为空。
强制更新与强制析取
被固定的更新数据会在每次匹配的调用中都被写入。假设某个更新结构强制要求 status: 'active':通过该结构进行的任何更新,即便只是修改标题,也会同时重置状态。如果某个操作必须保留某个字段不变,那就不要将该字段包含在更新结构中。
布尔结构会增加一条规则。OR下的强制条件会被提升为根层的必需条件。因此,某种结构无法表达真正的服务器级“或”规则,比如属于该账户的记录或与该组共享的记录。这类规则需要在服务器端编写查询、使用一个用于定义策略的作用域列,或是在数据库本身中进行强制执行。
因此,第一个审查问题应比“这是否已通过验证?”更为具体。要明确是谁决定每个值、每项强制条件被放置在哪里,以及究竟是哪种参数结构最终传递给了Prisma。
第二个问题:响应可以暴露什么信息?
过滤和投影用于回答不同类型的问题。租户过滤器决定哪些记录符合条件,而投影则决定返回这些记录中的哪些字段及关联数据。两者缺一不可,且检查其中一个无法得知另一个的情况。
对于受保护的读取操作,形状定义中声明的select或include既可作为允许列表,也可作为备用方案。客户端可以请求更窄范围的节点结构,但不得超出该范围;当他们未指定投影时,受保护的代理将使用已声明的投影。
下方的票证形状允许客户端通过ID获取票证,返回固定的根字段集,并提供一个带有独立过滤器、字段及页面大小的嵌套客户备注列表。
const ticketShape = {
where: {
id: { equals: true },
},
select: {
id: true,
subject: true,
description: true,
customerNotes: {
where: {
accountId: { equals: force(accountId) },
},
select: {
id: true,
body: true,
},
take: { max: 20, default: 10 },
},
},
}
该节点结构承担四项独立功能:
- 未列出的根字段将无法被访问。
- 仅能返回已列出的备注字段。
嵌套关系不在自动范围之内
备注过滤器是刻意如此设计的。自动租户范围限制仅适用于查询根节点处的支持操作;它不会遍历通过select或include获取的关系并在此添加条件。即便忽略嵌套的where子句,正确设置范围的查询仍可能返回属于其他账户的备注。
多对多关系支持嵌套的where条件、自定义投影以及行范围限制。而一对一关系则不支持嵌套的Prismawhere条件,因此需要采用其他策略:要么省略该关系,仅暴露其中无害的标量字段;要么通过受保护的根级查询来加载数据;或者利用数据库策略对其进行保护。
选择投影方式
应根据架构扩展后的表现来选择投影形式:
- 使用
select时,后续添加到模型中的列会一直保持隐藏状态,直到有人手动查看并将其列出,因此这是面向公共端点的最安全选择。 - 使用
include时,虽然可以获得关联数据,但根模型仍会返回所有标量列,从而导致敏感的根级数据仍然可见。
omit 时,你需要维护一个拒绝列表,其是否可用取决于所使用的生成器及 Prisma 版本。这些是不同的选项,而非不同层级。绝不能在树的同一层级同时使用 select、include 和 omit。
计数、嵌套默认值与变更响应
关系计数是一个独立的投影节点,而非与你加载的关系相关的汇总信息。如果返回的多对多关系经过了按账户过滤,那么当客户端认为这两个数值代表相同的行时,其 _count 也必须包含相同的过滤条件。应分别测试生成的关联条件与计数条件。
嵌套投影的默认设置源自已声明的结构。如果客户端请求使用布尔简写形式的已声明关系,该关系仍可保留其固定的筛选条件和页面绑定。不过,授予客户端的权限并不会自动补充缺失的选项:允许按某个字段排序并不会为客户端未指定的方向提供支持。测试时应涵盖三种情况:省略根投影、显式使用嵌套投影及关系简写,而不能假设其中一种情况即可推导出其他情况。
变更操作被刻意设计为非对称的。变更操作的格式可以检查客户端请求的投影,但除非启用了enforceProjection选项,否则未请求任何内容的客户端将不会收到格式化的投影,此时将遵循Prisma的常规行为。每当需要将变更操作的响应限制在已声明的字段范围内时,就应启用该强制选项。
最后,要确保每个测试都能运行其声称要测试的阶段。调用 guard.query(...).parse() 是检查读取过滤条件和边界的一种好方法,但读取投影仅在受保护的委托运行时才会显示,同样地,修改投影也需要对应的受保护修改路径。
因此第二个问题不是“是否有 select 操作?”,而应是每个调用变体可能收到哪些根字段、关联字段、关联行以及计数信息。
第三个问题:哪些写操作可以执行?
生成的写端点会以 Prisma 自有的格式接收参数。数据结构决定了客户端提供哪些值、服务器提供哪些值,以及是否可以进行任何嵌套操作。
创建用例的结构必须涵盖所有必填字段
如果某个必填字段没有来源,创建用例的结构就会被拒绝。可接受的来源包括:
- 当字段被标记为客户端控制时,由客户端决定
- 通过字面值或
force方式,由服务器决定 - Prisma模式中定义的默认值
- Zod支持的默认值或错误处理方式
- 租户外键的作用域注入
- 允许的关系写入操作以填充该字段
订阅示例允许客户端选择外部键和计划,通过整数规则限制席位数量,在服务器端固定初始状态和版本,并返回精简后的数据。
const createSubscriptionShape = {
data: {
externalKey: (base) => base.min(1).max(100),
planId: true,
status: 'trialing',
seatCount: (base) => base.int().min(1).max(500),
version: 0,
},
select: {
id: true,
externalKey: true,
status: true,
seatCount: true,
},
}
注意缺少了什么:租户外键。它绝不应由客户端在创建时设置。作用域根值来自可信的请求上下文,而扩展模块会为其支持的最高层级写入操作自行添加对应的外键。
批量操作与批量创建
批量操作必须通过两项检查。首先,updateMany及其返回的变体以及deleteMany都需要能够指定where条件的结构。其次,传入的请求必须提供有效的条件;缺少请求体、操作符对象为空或逻辑分支匹配所有记录的情况均不符合要求,这有助于防止意外对整个表进行修改。
受保护的批量操作要求输入为数组形式,不会自动将单个对象包装起来。至于是否返回批量操作结果以及是否支持跳过重复记录等功能,仍取决于所使用的数据库提供商。
插入或更新操作涉及三项约定
插入或更新操作的格式实际上包含三种结构:唯一的筛选条件、用于创建数据的负载,以及用于更新数据的负载。
const upsertSubscriptionShape = {
where: {
externalKey: true,
},
create: {
externalKey: true,
planId: true,
status: 'trialing',
seatCount: true,
version: 0,
},
update: {
planId: true,
seatCount: true,
},
}
在创建操作中,所有必填字段都必须在请求数据中包含,而更新操作则可以省略任意字段。create操作仅在插入新行时设置对应值,而update操作在找到匹配的现有行时会直接覆盖该值。在示例中,status和version字段在创建时会被设置,但在更新时故意被省略,这样现有的订阅记录就能保持原有状态。
唯一性约束需使用Prisma的普通唯一选择器形式来实现。运算符对象用于常规过滤,不适合用在根级别的唯一性where条件中。
关系型数据的写入与返回规范
关系型模型要求对所有权问题作出独立决策。某个模型可能允许使用connect方法,限制客户端可使用的选择器键,并拒绝任何未列出的嵌套操作。不过,这些措施都不会将租户范围扩展到选择器中。当需要确定哪条记录应被连接时,必须由应用程序代码或数据库来检查调用方是否具有使用该记录的权限。
返回格式也因方法而异。仅返回计数的批量操作方法createMany、updateMany和deleteMany会返回计数信息,同时拒绝处理投影请求。而对应的返回型方法在Prisma和你的数据提供方都允许的情况下,可以接受投影请求。如果生成器设置将某条路由更改为返回型方法,则应将该新的响应视为API变更,而非简单的实现细节而忽略它。
第三个问题不是“此方法是否已启用?”,而应询问该方法提供了什么:数据字段、选择器表单、批量条件、关系操作以及响应字段。
分层实施策略
当这些结构的限制与其他机制一同被记录下来时,它们的作用才能得到最佳发挥。每一层都有其特定的职责:
- 经过身份验证的请求能提供可信赖的上下文。
- 范围映射为受支持的根级操作添加租户相关条件。
- 调用者变体负责确定哪种已声明的结构适用于特定的受众群体。
- 钩子功能可执行有限的应用程序检查,并有权拒绝不符合条件的请求。
- 手动编写的处理程序负责处理那些结构无法描述的查询。
- 数据库策略则用于保护在任何访问路径下都必须保持不变的规则。
这些方法都不应试图替代其他方法的功能。如果您正在决定哪些路由由代码自动生成,哪些需要手动编写,我们的文章关于如何在Prisma CRUD生成与有针对性的路由控制之间取得平衡详细介绍了如何进行这种区分。
审查与测试清单
当拉取请求添加了自动生成的端点时,可通过以下三个问题对其进行审查:
- 谁来决定该端点接受的每个参数值?
- 响应中可以包含哪些字段和关联数据?
- 调用方可以使用哪些变异操作和选择器表单?
然后让每个测试针对其需要验证的具体断言来设计。解析读取参数以确认过滤器的行为。执行受保护的委托方法以验证数据投影和写入操作。检查扩展路径以确认作用域注入是否正确。当断言涉及返回哪些行时,使用多租户数据库测试用例;而当断言涉及传输或生成的路由时,则使用HTTP测试。
要对负面测试给予同等重视。针对不同的测试场景,尝试读取未列出的根字段、敏感的嵌套字段,超出关系约束范围,以及执行格式不允许的写入操作。正面测试用于验证预期请求能够成功;负面测试则用于确认在模型或生成器发生变化后,相关功能仍能正常封闭。
总结
只有当一个结构既明确了其允许的操作,也明确了无法强制执行的规则时,才能被称为“契约”。分别检查所有权、投影和写入操作,就能及早发现常见的缺陷:过宽松的根级规则、范围未定义的嵌套关系、忽略关系过滤条件的计数方式,以及绕过租户范围的连接操作。一旦将这些缺陷对应到相应的钩子、处理程序或数据库策略中,代码生成工具就能消除重复的边界处理代码,同时避免将 Prisma 的查询语言变成本不应公开的公共 API。如需完整的语法规范、精确的错误信息、不同版本的行为差异以及可复现的实验示例,请参阅 prisma-generator-express 使用指南。