Skip to content

Commit

Permalink
feat: add support for the in operator with static scalar values
Browse files Browse the repository at this point in the history
With this change we now mutate the parameter AST directly instead of
just replacing the whole `right` chunk, which allows us to swap values from
expression lists without a bunch of complex search logic. This is
because the value of an `expr_list` AST fragment is a list of values
that we want to substitute, and performing direct replacement of the
`right` chunk creates broken SQL.

Signed-off-by: Lucian Buzzo <[email protected]>
  • Loading branch information
LucianBuzzo committed Jan 16, 2024
1 parent 6876957 commit 7cf7903
Show file tree
Hide file tree
Showing 2 changed files with 104 additions and 28 deletions.
67 changes: 39 additions & 28 deletions src/expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,28 +209,32 @@ const tokenizeWhereExpression = (
break;

case isInStatement:
// This is a bit hokey, but we are going to assume that each value here is static, and
// perform tokenization on each value in the `in` array.
// The ideal solution is to rework this tokenization function so that it recurses until it
// finds a scalar value, and then tokenizes that value, with checking for row/context values.
if (Array.isArray(value.in)) {
const values = [];
for (const item in value.in) {
values.push({
type: "single_quote_string",
value: item,
});
const tokenList = [];

for (const item of value.in) {
let inToken;
do {
inToken = getLargeRandomInt();
} while (tokens[int]);

tokens[inToken] = {
astFragment: {
type: "parameter",
value: escapeLiteral(item),
},
};

tokenList.push(isNumeric ? inToken : `${inToken}`);
}
astFragment = {
type: "binary_expr",
operator: "IN",
left: {
type: "column_ref",
schema: "public",
table: table,
column: field,
},
right: {
type: "expr_list",
value: values,
},
where[field] = {
in: tokenList,
};
continue;
} else {
// If the value of `in` is a context value, we assume that it is an array that has been JSON encoded
// We create an AST fragment representing a function call to `jsonb_array_elements_text` with the context value as the argument
Expand Down Expand Up @@ -329,23 +333,30 @@ export const expressionToSQL = async (getExpression: Expression, table: string):
let param = params[i];
const token = tokens[param];

// If there is no token, we can skip this. The most likely cause of this is that the parameter is for a limit or offset, which we cull from the SQL anyway
if (!token) {
continue;
}

const parameterizedStatement = deepFind(ast, {
right: {
type: "var",
name: i + 1,
prefix: "$",
},
type: "var",
name: i + 1,
prefix: "$",
});

if (!parameterizedStatement) {
continue;
// If we found a matching parameterized statement, we can replace it with the AST fragment.
// This will replace the parameter with the original value.
// We do this by mutating the object returned from the deepfind function.
if (parameterizedStatement) {
// First, scrub all the keys from the parameterized statement
for (const key of Object.keys(parameterizedStatement)) {
Reflect.deleteProperty(parameterizedStatement, key);
}
// Second, add all the keys from the AST fragment to the parameterized statement
for (const key of Object.keys(token.astFragment)) {
parameterizedStatement[key] = token.astFragment[key];
}
}

parameterizedStatement.right = token.astFragment;
}

if (isSubselect) {
Expand Down
65 changes: 65 additions & 0 deletions test/integration/expressions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,71 @@ describe("expressions", () => {
expect(post.id).toBeDefined();
});

it("should be able to allow access using static values and the `in` keyword", async () => {
const initial = new PrismaClient();

const role = `USER_${uuid()}`;

const label1 = `test-label-${uuid()}`;
const label2 = `test-label-${uuid()}`;

const client = await setup({
prisma: initial,
customAbilities: {
Post: {
customCreateAbility: {
description: "Read where tag label exists with a specific value",
operation: "INSERT",
expression: (client: PrismaClient) => {
return client.tag.findFirst({
where: {
label: {
in: [label1, label2],
},
},
});
},
},
},
},
getRoles(abilities) {
return {
[role]: [abilities.Post.customCreateAbility, abilities.Post.read, abilities.Tag.read, abilities.Tag.create],
};
},
getContext: () => ({
role,
context: {
"tag.title": "test",
},
}),
});

const testTitle = `test_${uuid()}`;

await expect(
client.post.create({
data: {
title: testTitle,
},
}),
).rejects.toThrow();

await client.tag.create({
data: {
label: label1,
},
});

const post = await client.post.create({
data: {
title: testTitle,
},
});

expect(post.id).toBeDefined();
});

it("should be able to allow access using textual row values", async () => {
const initial = new PrismaClient();

Expand Down

0 comments on commit 7cf7903

Please sign in to comment.