Skip to content

Commit a690c14

Browse files
fix(sql-schema-ir): normalize the btree default at SqlIndexIR construction
An authored type: "btree" drifted on verify: introspection dropped the default access method before node construction while contract derivation passed it through, so isEqualTo's strict type compare failed on the exact spelling the ciphers scenario uses. The btree-to-undefined normalization now lives in the SqlIndexIR constructor — every derivation path (contract tree, introspection, flat family tree) constructs through it, making both sides symmetric by definition; the adapter's pre-construction filter is removed so the rule has one home. The contract JSON and the wire-name content hash keep the authored spelling, so a btree-typed index and an untyped index remain distinct wire names, and the planner's DDL renders no USING clause for the default method. Phase-2 content pairing inherits the symmetry (pinned by a planner test), verify is pinned clean at integration level, and the ciphers e2e now authors the literal type: "btree" spelling alongside the hash-typed registry proof. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
1 parent 163aaa1 commit a690c14

11 files changed

Lines changed: 124 additions & 32 deletions

File tree

packages/2-sql/1-core/schema-ir/src/ir/sql-index-ir.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ export type SqlIndexElements =
3232

3333
/**
3434
* Every non-element field is a required key. Values that may legitimately
35-
* be absent (an exact-named index's prefix, a default-method type) are
36-
* typed `| undefined` instead of optional, so each construction site
35+
* be absent (an exact-named index's prefix, the btree→undefined type
36+
* normalization) are typed `| undefined` instead of optional, so each
37+
* construction site
3738
* states the absence explicitly rather than omitting the key silently.
3839
* Undefined values still produce an instance without the property.
3940
*/
@@ -132,7 +133,8 @@ export class SqlIndexIR extends SqlSchemaIRNode implements DiffableNode {
132133
if (input.columns !== undefined) this.columns = input.columns;
133134
if (input.expression !== undefined) this.expression = input.expression;
134135
if (input.where !== undefined) this.where = input.where;
135-
if (input.type !== undefined) this.type = input.type;
136+
const normalizedType = normalizeIndexType(input.type);
137+
if (normalizedType !== undefined) this.type = normalizedType;
136138
if (input.options !== undefined) this.options = input.options;
137139
if (input.annotations !== undefined) this.annotations = input.annotations;
138140
defineNonEnumerable(this, 'dependsOn', input.dependsOn);
@@ -221,12 +223,15 @@ export class SqlIndexIR extends SqlSchemaIRNode implements DiffableNode {
221223
}
222224

223225
/**
224-
* Comparison-side normalization seam: the default access method (`btree` in
225-
* every supported SQL target) compares as absent, so an authored
226-
* `type: "btree"` and a default-method introspected index (whose type the
227-
* adapter or constructor normalized away) are equal. Applied by
228-
* {@link SqlIndexIR.contentEquals} only — the wire-name hash keeps the
229-
* authored spelling.
226+
* The btree-default normalization seam: the default access method (`btree`
227+
* in every supported SQL target) normalizes to absent. Applied at
228+
* construction — every derivation path (contract tree, introspection, flat
229+
* family tree) builds through the class, so both compare sides are
230+
* symmetric by definition — and again inside
231+
* {@link SqlIndexIR.contentEquals} so the relation holds for any input.
232+
* The contract JSON and the wire-name content hash keep the authored
233+
* spelling: `@@index([a], type: "btree")` and `@@index([a])` are distinct
234+
* wire names whose shared content converges via a phase-2 rename.
230235
*/
231236
function normalizeIndexType(type: string | undefined): string | undefined {
232237
return type === 'btree' ? undefined : type;

packages/2-sql/1-core/schema-ir/test/sql-index-ir.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,25 @@ describe('SqlIndexIR', () => {
104104
});
105105
});
106106

107+
describe('btree normalization at construction', () => {
108+
it("normalizes type 'btree' to absent (both derivation paths construct here)", () => {
109+
const authored = managed({ name: NAME, columns: ['email'], type: 'btree' });
110+
expect(authored.type).toBeUndefined();
111+
expect(Object.hasOwn(authored, 'type')).toBe(false);
112+
});
113+
114+
it('keeps non-default types', () => {
115+
const hashTyped = managed({ name: NAME, columns: ['email'], type: 'hash' });
116+
expect(hashTyped.type).toBe('hash');
117+
});
118+
119+
it("two 'btree' nodes are equal", () => {
120+
const a = managed({ name: NAME, columns: ['email'], type: 'btree' });
121+
const b = exact({ name: NAME, columns: ['email'], type: 'btree' });
122+
expect(a.isEqualTo(b)).toBe(true);
123+
});
124+
});
125+
107126
describe('contentEquals — the single node-owned relation', () => {
108127
it("a boolean option value equals its catalog reprint ('on'/'off')", () => {
109128
const authored = managed({ name: NAME, columns: ['email'], options: { fastupdate: true } });
@@ -120,7 +139,7 @@ describe('SqlIndexIR', () => {
120139
expect(authoredOff.isEqualTo(reprint)).toBe(false);
121140
});
122141

123-
it("an authored type 'btree' equals a normalized-away type through the comparison seam", () => {
142+
it("an authored type 'btree' equals a normalized-away type through the seam", () => {
124143
const authored = managed({ name: NAME, columns: ['email'], type: 'btree' });
125144
const live = exact({ name: NAME, columns: ['email'] });
126145
expect(authored.isEqualTo(live)).toBe(true);

packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,8 +1015,8 @@ function buildRelationField(
10151015

10161016
/**
10171017
* `indexType` carries a non-default index access method (e.g. `hash`) —
1018-
* introspection already drops `btree` (the Postgres default) to `undefined`
1019-
* (see the postgres control adapter), so this only ever fires for a real
1018+
* `SqlIndexIR`'s constructor drops `btree` (the default) to `undefined`,
1019+
* so this only ever fires for a real
10201020
* non-default index, matching the same `@@index(type: "...")` argument
10211021
* `contract-to-schema-ir.ts` reads back into the FK-backing-index
10221022
* expectation `db verify` checks against the live database.

packages/3-targets/3-targets/postgres/test/migrations/index-rename-planner.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,16 @@ describe('phase 2 — content pairing (exact→managed convergence)', () => {
301301
]);
302302
});
303303

304+
it("an authored 'btree' index content-pairs with a typeless live index", async () => {
305+
const contract = buildContract([
306+
managedIndex('items_email_idx', 'ab12cd34', { type: 'btree' }),
307+
]);
308+
const schema = actualSchema([{ name: 'legacy_email_idx', columns: ['email'] }]);
309+
310+
const opIds = await planOpIds(contract, schema, ALL_CLASSES_POLICY);
311+
expect(opIds).toEqual([`index.public.${TABLE_NAME}.legacy_email_idx.rename`]);
312+
});
313+
304314
it('an exact-named missing index never content-pairs (managed only)', async () => {
305315
const contract = buildContract([
306316
{ name: 'items_email_exact', columns: ['email'], unique: false },

packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,10 +1164,9 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
11641164
if (existing) {
11651165
existing.elements.push({ attname: idxRow.attname, elementDef: idxRow.element_def });
11661166
} else {
1167-
// Drop btree (the Postgres default) so a contract index without an
1168-
// explicit type matches a default-method introspected index without
1169-
// forcing DROP+CREATE on every plan.
1170-
const indexType = idxRow.amname && idxRow.amname !== 'btree' ? idxRow.amname : undefined;
1167+
// SqlIndexIR's constructor normalizes the btree default to absent
1168+
// for both derivation paths.
1169+
const indexType = idxRow.amname ?? undefined;
11711170
const indexOptions = parsePgReloptions(idxRow.reloptions, idxRow.indexname);
11721171
indexesMap.set(idxRow.indexname, {
11731172
name: idxRow.indexname,

test/integration/test/cli-journeys/expression-index-migration.e2e.test.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
* and a body edit under the same name converges as create + drop.
55
*
66
* A `.prisma` contract carries the Cipherstash-style index
7-
* (`@@index(expression: "eql_v3.eq_term(email)", name: "users_email_eq")`),
7+
* (`@@index(expression: "eql_v3.eq_term(email)", name: "users_email_eq",
8+
* type: "btree")` — the default access method normalizes away in the schema
9+
* IR, so the DDL carries no USING clause and verify is clean),
810
* a partial index, a unique expression index, and a registry-typed
911
* (`USING hash`) index; a `.ts` twin authors the same schema via
1012
* `constraints.index`. Both variants: emit → plan (DDL byte-asserted, the
@@ -39,7 +41,7 @@ const EQL_V3_SETUP = `
3941

4042
const EXPECTED_INDEX_DDL = [
4143
'CREATE INDEX "users_email_active_0cd7caf9" ON "public"."user" ("email") WHERE ((archived_at IS NULL))',
42-
'CREATE INDEX "users_email_eq_8b80d85b" ON "public"."user" (eql_v3.eq_term(email))',
44+
'CREATE INDEX "users_email_eq_adef23ad" ON "public"."user" (eql_v3.eq_term(email))',
4345
'CREATE INDEX "users_email_hash_239baf6b" ON "public"."user" USING "hash" ("email")',
4446
'CREATE UNIQUE INDEX "users_email_lower_key_f84d49fd" ON "public"."user" (lower(email))',
4547
];
@@ -80,18 +82,18 @@ async function runInitialFlow(ctx: JourneyContext, connectionString: string): Pr
8082
expect(verify.exitCode, `db verify clean\n${stripAnsi(verify.stderr)}`).toBe(0);
8183

8284
await withClient(connectionString, (client) =>
83-
client.query('DROP INDEX "public"."users_email_eq_8b80d85b"'),
85+
client.query('DROP INDEX "public"."users_email_eq_adef23ad"'),
8486
);
8587
const verifyFail = await runDbVerify(ctx, ['--schema-only']);
8688
expect(verifyFail.exitCode, 'verify fails after out-of-band drop').toBe(1);
8789
expect(
8890
stripAnsi(verifyFail.stderr) + stripAnsi(verifyFail.stdout),
8991
'verify names the dropped index',
90-
).toContain('users_email_eq_8b80d85b');
92+
).toContain('users_email_eq_adef23ad');
9193

9294
await withClient(connectionString, (client) =>
9395
client.query(
94-
'CREATE INDEX "users_email_eq_8b80d85b" ON "public"."user" (eql_v3.eq_term(email))',
96+
'CREATE INDEX "users_email_eq_adef23ad" ON "public"."user" (eql_v3.eq_term(email))',
9597
),
9698
);
9799
const verifyRestored = await runDbVerify(ctx);
@@ -127,14 +129,17 @@ withTempDir(({ createTempDir }) => {
127129
'rename: the widening plan is exactly one rename',
128130
).toEqual([
129131
{
130-
id: 'index.public.user.users_email_eq_8b80d85b.rename',
131-
sql: 'ALTER INDEX "public"."users_email_eq_8b80d85b" RENAME TO "users_email_eq_v2_8b80d85b"',
132+
id: 'index.public.user.users_email_eq_adef23ad.rename',
133+
sql: 'ALTER INDEX "public"."users_email_eq_adef23ad" RENAME TO "users_email_eq_v2_adef23ad"',
132134
},
133135
]);
134136
const applyRename = await runMigrate(ctx);
135137
expect(applyRename.exitCode, `rename: apply\n${stripAnsi(applyRename.stderr)}`).toBe(0);
136138
const verifyRename = await runDbVerify(ctx);
137-
expect(verifyRename.exitCode, `rename: verify clean\n${stripAnsi(verifyRename.stderr)}`).toBe(0);
139+
expect(
140+
verifyRename.exitCode,
141+
`rename: verify clean\n${stripAnsi(verifyRename.stderr)}`,
142+
).toBe(0);
138143

139144
// The expression changes under the same name:, so the
140145
// hash moves and the plan is create + drop — never a rename.
@@ -143,14 +148,20 @@ withTempDir(({ createTempDir }) => {
143148
expect(emitEdited.exitCode, `body-edit: emit\n${stripAnsi(emitEdited.stderr)}`).toBe(0);
144149
const planEdit = await runMigrationPlanAndEmit(ctx, ['--name', 'edit-search-index-body']);
145150
expect(planEdit.exitCode, `body-edit: plan\n${stripAnsi(planEdit.stderr)}`).toBe(0);
146-
expect(indexSqlOf(readPlannedOps(ctx)).sort(), 'body-edit: create + drop, byte-exact').toEqual([
147-
'CREATE INDEX "users_email_eq_v2_b1fbd0db" ON "public"."user" (eql_v3.eq_term(lower(email)))',
148-
'DROP INDEX "public"."users_email_eq_v2_8b80d85b"',
151+
expect(
152+
indexSqlOf(readPlannedOps(ctx)).sort(),
153+
'body-edit: create + drop, byte-exact',
154+
).toEqual([
155+
'CREATE INDEX "users_email_eq_v2_449c97be" ON "public"."user" (eql_v3.eq_term(lower(email)))',
156+
'DROP INDEX "public"."users_email_eq_v2_adef23ad"',
149157
]);
150158
const applyEdit = await runMigrate(ctx);
151159
expect(applyEdit.exitCode, `body-edit: apply\n${stripAnsi(applyEdit.stderr)}`).toBe(0);
152160
const verifyEdit = await runDbVerify(ctx);
153-
expect(verifyEdit.exitCode, `body-edit: verify clean\n${stripAnsi(verifyEdit.stderr)}`).toBe(0);
161+
expect(
162+
verifyEdit.exitCode,
163+
`body-edit: verify clean\n${stripAnsi(verifyEdit.stderr)}`,
164+
).toBe(0);
154165
},
155166
timeouts.spinUpPpgDev,
156167
);

test/integration/test/family.schema-verify.index-drift.integration.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,49 @@ describe('index drift', () => {
7979
);
8080
});
8181

82+
describe("authored type: 'btree' is the default access method, not drift", () => {
83+
it(
84+
'verifies clean against a live index created with the default method',
85+
async () => {
86+
await withClient(getConnectionString(), async (client) => {
87+
await client.query('DROP TABLE IF EXISTS "user"');
88+
await client.query(`
89+
CREATE TABLE "user" (
90+
id INTEGER PRIMARY KEY,
91+
email TEXT NOT NULL
92+
)
93+
`);
94+
await client.query('CREATE INDEX "user_email_btree_73653512" ON "user" ("email")');
95+
});
96+
97+
const contract = defineContract({}, ({ field: packField, model: packModel }) => ({
98+
models: {
99+
User: packModel('User', {
100+
fields: {
101+
id: packField.column(int4Column).id(),
102+
email: packField.column(textColumn),
103+
},
104+
}).sql(({ cols, constraints }) => ({
105+
table: 'user',
106+
indexes: [
107+
constraints.index([cols.email], {
108+
name: 'user_email_btree',
109+
type: 'btree',
110+
options: {},
111+
}),
112+
],
113+
})),
114+
},
115+
}));
116+
117+
const result = await runSchemaVerify(getConnectionString(), contract);
118+
expect(result.schema.issues).toEqual([]);
119+
expect(result.ok).toBe(true);
120+
},
121+
timeouts.spinUpPpgDev,
122+
);
123+
});
124+
82125
describe('scenario H — out-of-band storage-parameter change on a managed index', () => {
83126
it(
84127
'verifies clean before the ALTER and reports the index not-equal after it',

test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-expression-authored-editedbody.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ model User {
55
email String
66
archivedAt String? @map("archived_at")
77
8-
@@index(expression: "eql_v3.eq_term(lower(email))", name: "users_email_eq_v2")
8+
@@index(expression: "eql_v3.eq_term(lower(email))", name: "users_email_eq_v2", type: "btree")
99
@@index([email], where: "(archived_at IS NULL)", name: "users_email_active")
1010
@@index(expression: "lower(email)", unique: true, name: "users_email_lower_key")
1111
@@index([email], type: "hash", name: "users_email_hash")

test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-expression-authored-renamed.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ model User {
55
email String
66
archivedAt String? @map("archived_at")
77
8-
@@index(expression: "eql_v3.eq_term(email)", name: "users_email_eq_v2")
8+
@@index(expression: "eql_v3.eq_term(email)", name: "users_email_eq_v2", type: "btree")
99
@@index([email], where: "(archived_at IS NULL)", name: "users_email_active")
1010
@@index(expression: "lower(email)", unique: true, name: "users_email_lower_key")
1111
@@index([email], type: "hash", name: "users_email_hash")

test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-expression-authored.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ model User {
55
email String
66
archivedAt String? @map("archived_at")
77
8-
@@index(expression: "eql_v3.eq_term(email)", name: "users_email_eq")
8+
@@index(expression: "eql_v3.eq_term(email)", name: "users_email_eq", type: "btree")
99
@@index([email], where: "(archived_at IS NULL)", name: "users_email_active")
1010
@@index(expression: "lower(email)", unique: true, name: "users_email_lower_key")
1111
@@index([email], type: "hash", name: "users_email_hash")

0 commit comments

Comments
 (0)