diff --git a/CHANGELOG.md b/CHANGELOG.md index d98ffca..fdbdc34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable user-visible changes to this project are documented in this file. ### Fixed +- Scoped select, update, and delete builders now return facades that hide scope-unsafe methods, preventing a second `.where()` from overwriting the injected scope predicate. - Scoped join protection is now applied even when the root table is unscoped, preventing scope leaks when starting queries from unscoped tables and joining scoped ones. - Strict-mode scope detection now matches the table's original (pre-alias) name in addition to the column name, preventing false positives when a joined table shares a column name with the scoped table while remaining safe for aliased self-joins. diff --git a/src/internal/mutations.ts b/src/internal/mutations.ts index 9c3ddd2..b350346 100644 --- a/src/internal/mutations.ts +++ b/src/internal/mutations.ts @@ -59,7 +59,8 @@ export function createScopedUpdateBuilder( return { where(condition: SQL | undefined) { assertWhereAllowed(condition, rule, options); - return setBuilder.where(scopeCondition(condition, rule, options)); + const rawResult = setBuilder.where(scopeCondition(condition, rule, options)); + return createScopedMutationResult(rawResult); }, }; }, @@ -79,7 +80,29 @@ export function createScopedDeleteBuilder( return { where(condition: SQL | undefined) { assertWhereAllowed(condition, rule, options); - return deleteBuilder.where(scopeCondition(condition, rule, options)); + const rawResult = deleteBuilder.where(scopeCondition(condition, rule, options)); + return createScopedMutationResult(rawResult); + }, + }; +} + +/** Wrap a mutation result in a thenable facade that hides scope-unsafe builder methods. */ +function createScopedMutationResult( + // oxlint-disable-next-line typescript/no-explicit-any -- Drizzle mutation results are dialect-specific. + rawResult: any, +): { + then( + onfulfilled?: ((value: unknown) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise; +} { + return { + // oxlint-disable-next-line unicorn/no-thenable -- Mutation results act as thenables for direct awaits. + then( + onfulfilled?: ((value: unknown) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise { + return Promise.resolve(rawResult).then(onfulfilled, onrejected); }, }; } diff --git a/src/internal/select.ts b/src/internal/select.ts index 2e4a3c9..8bdffba 100644 --- a/src/internal/select.ts +++ b/src/internal/select.ts @@ -87,9 +87,8 @@ function createScopedFromBuilder< return { where(condition: SQL | undefined): ScopedWhereBuilder { assertWhereAllowed(condition, rootRule, options); - return builder.where( - scopeCondition(condition, rootRule, options), - ) as ScopedWhereBuilder; + const rawBuilder = builder.where(scopeCondition(condition, rootRule, options)); + return createScopedWhereBuilder(rawBuilder); }, leftJoin>(joinTable: TJoinTable, on: SQL) { @@ -122,3 +121,24 @@ function createScopedFromBuilder< }, }; } + +/** Build a scoped where-builder facade that prevents double-.where() scope overwrite. */ +function createScopedWhereBuilder( + // oxlint-disable-next-line typescript/no-explicit-any -- Drizzle builder internals are intentionally opaque. + rawBuilder: any, +): ScopedWhereBuilder { + const promise = Promise.resolve(rawBuilder) as Promise; + const facade = { + limit(n: number): ScopedWhereBuilder { + return createScopedWhereBuilder(rawBuilder.limit(n)); + }, + offset(n: number): ScopedWhereBuilder { + return createScopedWhereBuilder(rawBuilder.offset(n)); + }, + // oxlint-disable-next-line typescript/no-explicit-any -- Drizzle accepts PgColumn | SQL | SQL.Aliased. + orderBy(...columns: any[]): ScopedWhereBuilder { + return createScopedWhereBuilder(rawBuilder.orderBy(...columns)); + }, + }; + return Object.assign(promise, facade) as ScopedWhereBuilder; +} diff --git a/src/scoped-db.test.ts b/src/scoped-db.test.ts index 046abca..0b0b015 100644 --- a/src/scoped-db.test.ts +++ b/src/scoped-db.test.ts @@ -53,9 +53,11 @@ type RelationalProjectWhere = type FakeWhereResult = { condition: SQL | undefined; - limit(): FakeWhereResult; - offset(): FakeWhereResult; - orderBy(): FakeWhereResult; + where(condition: SQL | undefined): FakeWhereResult; + limit(n?: number): FakeWhereResult; + offset(n?: number): FakeWhereResult; + // oxlint-disable-next-line typescript/no-explicit-any -- Drizzle accepts PgColumn | SQL | SQL.Aliased. + orderBy(...columns: any[]): FakeWhereResult; }; type FakeFromBuilder = { @@ -205,20 +207,27 @@ function createSelectBuilder(state: FakeDbState): FakeSelectBuilder { /** Creates a minimal from builder that records the final where predicate. */ function createFromBuilder(state: FakeDbState): FakeFromBuilder { const builder = { - where(condition: SQL | undefined) { + where(condition: SQL | undefined): FakeWhereResult { state.selectCondition = condition; - return { + const result: FakeWhereResult = { condition, - limit(): FakeWhereResult { - return this; + // Drizzle's where() overwrites config.where (does not AND). + where(condition2: SQL | undefined): FakeWhereResult { + state.selectCondition = condition2; + return result; + }, + limit(_n?: number): FakeWhereResult { + return result; }, - offset(): FakeWhereResult { - return this; + offset(_n?: number): FakeWhereResult { + return result; }, - orderBy(): FakeWhereResult { - return this; + // oxlint-disable-next-line typescript/no-explicit-any -- Drizzle accepts PgColumn | SQL | SQL.Aliased. + orderBy(..._columns: any[]): FakeWhereResult { + return result; }, }; + return result; }, leftJoin(_table: unknown, on: SQL): FakeFromBuilder { state.joinConditions = [...(state.joinConditions ?? []), on]; @@ -720,6 +729,118 @@ describe("createScopedDb", () => { ); }); + it("prevents double .where() from overwriting the injected scope predicate", () => { + const rawDb = createFakeDb(); + const scopedDb = createScopedDb(rawDb, { + scopeName: "workspace", + scopeValue: "workspace-1", + strict: false, + rules: [scopeByColumn(projectsTbl, projectsTbl.workspaceId)], + }); + + const result = scopedDb + .select() + .from(projectsTbl) + .where(eq(projectsTbl.id, "project-1")) as unknown as { + where: (condition: SQL | undefined) => unknown; + }; + + // A second .where() must not be callable — Drizzle overwrites (not ANDs), + // so allowing it would silently drop the injected scope predicate. + expect(() => result.where(eq(projectsTbl.id, "project-2"))).toThrow(); + + // Scope predicate was injected and not overwritten. + expect(rawDb._state.selectCondition).toBeDefined(); + expect(containsColumnFilter(rawDb._state.selectCondition, "workspace_id")).toBe(true); + expect(containsColumnFilter(rawDb._state.selectCondition, "id")).toBe(true); + }); + + it("preserves scope predicate through .limit(), .offset(), and .orderBy() chaining", () => { + const rawDb = createFakeDb(); + const scopedDb = createScopedDb(rawDb, { + scopeName: "workspace", + scopeValue: "workspace-1", + strict: false, + rules: [scopeByColumn(projectsTbl, projectsTbl.workspaceId)], + }); + + const result = scopedDb + .select() + .from(projectsTbl) + .where(eq(projectsTbl.id, "project-1")) + .limit(10) + .offset(5) + .orderBy(projectsTbl.id) as unknown as { + where: (condition: SQL | undefined) => unknown; + }; + + // .where() still not reachable after chaining terminal methods. + expect(() => result.where(eq(projectsTbl.id, "project-2"))).toThrow(); + expect(rawDb._state.selectCondition).toBeDefined(); + expect(containsColumnFilter(rawDb._state.selectCondition, "workspace_id")).toBe(true); + }); + + it("prevents double .where() on scoped update results", () => { + const rawDb = createFakeDb(); + const scopedDb = createScopedDb(rawDb, { + scopeName: "workspace", + scopeValue: "workspace-1", + strict: false, + rules: [scopeByColumn(projectsTbl, projectsTbl.workspaceId, { insertKey: "workspaceId" })], + }); + + const result = scopedDb + .update(projectsTbl) + .set({ name: "Updated" }) + .where(eq(projectsTbl.id, "project-1")) as unknown as { + where: (condition: SQL | undefined) => unknown; + }; + + expect(() => result.where(eq(projectsTbl.id, "project-2"))).toThrow(); + expect(containsColumnFilter(rawDb._state.updateCondition, "workspace_id")).toBe(true); + }); + + it("allows awaiting scoped update and delete results as promises", async () => { + const rawDb = createFakeDb(); + const scopedDb = createScopedDb(rawDb, { + scopeName: "workspace", + scopeValue: "workspace-1", + strict: false, + rules: [scopeByColumn(projectsTbl, projectsTbl.workspaceId, { insertKey: "workspaceId" })], + }); + + const updateResult = await scopedDb + .update(projectsTbl) + .set({ name: "Updated" }) + .where(eq(projectsTbl.id, "project-1")); + expect(updateResult).toEqual({ + condition: rawDb._state.updateCondition, + values: { name: "Updated" }, + }); + + const deleteResult = await scopedDb.delete(projectsTbl).where(eq(projectsTbl.id, "project-1")); + expect(deleteResult).toEqual({ condition: rawDb._state.deleteCondition }); + }); + + it("prevents double .where() on scoped delete results", () => { + const rawDb = createFakeDb(); + const scopedDb = createScopedDb(rawDb, { + scopeName: "workspace", + scopeValue: "workspace-1", + strict: false, + rules: [scopeByColumn(projectsTbl, projectsTbl.workspaceId)], + }); + + const result = scopedDb + .delete(projectsTbl) + .where(eq(projectsTbl.id, "project-1")) as unknown as { + where: (condition: SQL | undefined) => unknown; + }; + + expect(() => result.where(eq(projectsTbl.id, "project-2"))).toThrow(); + expect(containsColumnFilter(rawDb._state.deleteCondition, "workspace_id")).toBe(true); + }); + it("throws on relational queries without where when strict mode is enabled", () => { const scopedDb = createScopedDb(createFakeDb(), { scopeName: "workspace",