Skip to content

driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030

Description

@xuyushun441-sys

未认领。#4986 的调研中实测发现,与该 issue 的决策面相邻但独立,单独归档。

现象(已实测复现,非推断)

packages/plugins/driver-sql 里跑一个探针:对象带 organization_id 列(就是 applySystemFields 在单租户栈上注入的那一根),email 字段声明 unique: true,然后连写两行、都不带 organization_id:

PROBE RESULT >>> threw= null  second= {"id":"HjLkz4HD9FF4Zk3X", ... ,"organization_id":null,"email":"dup@example.com"}

第二行创建成功。字段级 unique: true 在这条路径上一条约束都没有兑现

成因链(四步,每一步单独看都正确)

  1. packages/objectql/src/registry.tsapplySystemFields:wantTenant = sf?.tenant !== false && !tenancyDisabled —— multiTenant 开关无关。注释自己写明了这次解耦的意图:「organization_id 列无条件供给……多租户开关现在只决定这列是否建索引」。所以单租户栈上这列存在
  2. SqlDriver.computeTenantField:只要 fields 里有 organization_id 且没显式关租户,就返回 'organization_id' —— 单租户栈上非 null
  3. uniqueIndexesFromFields(schema-drift.ts):scoped = !isGlobalUnique && tenantField != null && tenantField !== name → 成立,物化成 UNIQUE (organization_id, email)
  4. 单租户栈上没有租户上下文,organization_id 每行都是 NULL。而 SQL 的 UNIQUE 是 NULL-distinct 的 —— 这一点 sql-driver.tssyncDeclaredIndexes 文档块自己就写明了:「NULL-distinct semantics are the default across SQLite/Postgres/MySQL」。于是 (NULL, 'dup@example.com') 写多少行都不冲突。

与规范陈述的冲突(declared ≠ enforced)

packages/spec/src/data/field.zod.tsUniqueScopeSchema 文档块声称:

On an object with no tenant column (tenancy.enabled: false, or simply no tenant field) both spellings materialize identically — single-column unique. Single-tenant deployments are therefore unaffected: the tenant column is constant, so the composite index degenerates to the single-column one.

「租户列是常量,所以复合索引退化成单列索引」这句推理在常量取值为 NULL 时不成立:它退化成的不是单列索引,而是没有索引约束。而第 1 步已经保证了单租户栈上这列存在(不是「no tenant field」),所以这段豁免说明覆盖不到真实形状。

sql-driver-unique-tenancy.test.ts 的头注也写着 unique: true, no tenant column → single-column (single-tenant: unchanged);对应的用例(materializes a SINGLE-column unique when the object has no tenant column)用的是一个手写、完全没有 organization_id 字段的 schema —— 它绕开了 applySystemFields,因此测的不是单租户栈上对象的真实形状。真实形状(有列、值为 NULL)没有任何用例覆盖。

影响面

复现脚本

探针文件已在调研中写过又删除(未提交),内容就是上面第 1 段描述的三十行 vitest:SqlDriver + better-sqlite3 :memory:,initObjects 一个带 organization_idemail: { type: 'text', unique: true } 的对象,连 create 两次同 email、不传 organization_id。

可能方向(未定,交维护者裁)

  • A. 写入期给 organization_id 落一个非 NULL 的 sentinel(驱动里已经有 GLOBAL_TENANT = '__global__' 这个常量,但目前只用于 autonumber 序列表,sql-driver.ts:1831 反而显式把它转回 null 再写列)。改动面涉及 RLS 读谓词与既有数据迁移,不小。
  • B. computeTenantField 在多租户未启用时返回 null,让单租户栈退回单列 UNIQUE —— 与规范文档块的原意一致,但会让多租户栈上的 platform-global NULL 行仍然漏。
  • C. 在 Postgres 上用 NULLS NOT DISTINCT(PG 15+),SQLite/MySQL 无对应语法,会造成方言语义分裂。
  • D. 索引列上用 COALESCE(organization_id, '') —— schema-drift.ts 已经有一整套 COALESCE 索引列的识别与归因逻辑([schema-drift] Fresh DB boots "drifted": detector can't read COALESCE index columns, then tells the operator to --allow-destructive away a legitimate unique index #4884 作业过),基础设施是现成的。

我倾向 D 或 A:两者都让约束在所有方言上真正生效,且 D 的 drift 侧识别代码已经存在。但这条链跨 registry / driver / spec 文档三处,应由维护者定方向后再动手。

关联

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions