Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 25 additions & 2 deletions src/internal/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export function createScopedUpdateBuilder<TScope, TTable extends ScopedTable>(
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);
},
};
},
Expand All @@ -79,7 +80,29 @@ export function createScopedDeleteBuilder<TScope, TTable extends ScopedTable>(
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<TResult1 = unknown, TResult2 = never>(
onfulfilled?: ((value: unknown) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2>;
} {
return {
// oxlint-disable-next-line unicorn/no-thenable -- Mutation results act as thenables for direct awaits.
then<TResult1 = unknown, TResult2 = never>(
onfulfilled?: ((value: unknown) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return Promise.resolve(rawResult).then(onfulfilled, onrejected);
},
};
}
26 changes: 23 additions & 3 deletions src/internal/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,8 @@ function createScopedFromBuilder<
return {
where(condition: SQL | undefined): ScopedWhereBuilder<TResult> {
assertWhereAllowed(condition, rootRule, options);
return builder.where(
scopeCondition(condition, rootRule, options),
) as ScopedWhereBuilder<TResult>;
const rawBuilder = builder.where(scopeCondition(condition, rootRule, options));
return createScopedWhereBuilder<TResult>(rawBuilder);
},

leftJoin<TJoinTable extends Table<TableConfig>>(joinTable: TJoinTable, on: SQL) {
Expand Down Expand Up @@ -122,3 +121,24 @@ function createScopedFromBuilder<
},
};
}

/** Build a scoped where-builder facade that prevents double-.where() scope overwrite. */
function createScopedWhereBuilder<TResult>(
// oxlint-disable-next-line typescript/no-explicit-any -- Drizzle builder internals are intentionally opaque.
rawBuilder: any,
): ScopedWhereBuilder<TResult> {
const promise = Promise.resolve(rawBuilder) as Promise<TResult>;
const facade = {
limit(n: number): ScopedWhereBuilder<TResult> {
return createScopedWhereBuilder<TResult>(rawBuilder.limit(n));
},
offset(n: number): ScopedWhereBuilder<TResult> {
return createScopedWhereBuilder<TResult>(rawBuilder.offset(n));
},
// oxlint-disable-next-line typescript/no-explicit-any -- Drizzle accepts PgColumn | SQL | SQL.Aliased.
orderBy(...columns: any[]): ScopedWhereBuilder<TResult> {
return createScopedWhereBuilder<TResult>(rawBuilder.orderBy(...columns));
},
};
return Object.assign(promise, facade) as ScopedWhereBuilder<TResult>;
}
143 changes: 132 additions & 11 deletions src/scoped-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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",
Expand Down
Loading