Skip to content

Commit ea0af4f

Browse files
sonar-nigel[bot]Vibe Bot
authored andcommitted
SONARPY-4111 Fix S3457 false positive for injected Marimo mo.sql calls (#1073)
Co-authored-by: Vibe Bot <vibe-bot@sonarsource.com> GitOrigin-RevId: 7bdde59ec9c6b50dfd3d7e32d6e61c1cabd0c302
1 parent aad7033 commit ea0af4f

3 files changed

Lines changed: 141 additions & 3 deletions

File tree

python-checks/src/main/java/org/sonar/python/checks/StringFormatCorrectnessCheck.java

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.HashSet;
2121
import java.util.List;
2222
import java.util.Locale;
23+
import java.util.Objects;
2324
import java.util.Optional;
2425
import java.util.Set;
2526
import java.util.stream.IntStream;
@@ -34,15 +35,19 @@
3435
import org.sonar.plugins.python.api.tree.DictionaryLiteral;
3536
import org.sonar.plugins.python.api.tree.DictionaryLiteralElement;
3637
import org.sonar.plugins.python.api.tree.Expression;
38+
import org.sonar.plugins.python.api.tree.FunctionDef;
3739
import org.sonar.plugins.python.api.tree.KeyValuePair;
3840
import org.sonar.plugins.python.api.tree.Name;
41+
import org.sonar.plugins.python.api.tree.Parameter;
3942
import org.sonar.plugins.python.api.tree.QualifiedExpression;
4043
import org.sonar.plugins.python.api.tree.RegularArgument;
4144
import org.sonar.plugins.python.api.tree.StringElement;
4245
import org.sonar.plugins.python.api.tree.StringLiteral;
4346
import org.sonar.plugins.python.api.tree.Token;
4447
import org.sonar.plugins.python.api.tree.Tree;
4548
import org.sonar.python.checks.utils.Expressions;
49+
import org.sonar.python.checks.utils.MarimoUtils;
50+
import org.sonar.python.tree.TreeUtils;
4651

4752
@Rule(key = "S3457")
4853
public class StringFormatCorrectnessCheck extends AbstractStringFormatCheck {
@@ -126,19 +131,49 @@ private static void checkFStringLiteral(SubscriptionContext ctx) {
126131
}
127132

128133
private static boolean isMarimoSqlArgument(StringLiteral literal, SubscriptionContext ctx) {
134+
return enclosingCallExpression(literal)
135+
.filter(callExpression -> MARIMO_SQL_MATCHER.isTrueFor(callExpression.callee(), ctx)
136+
|| isInjectedMarimoSqlCall(callExpression, literal, ctx))
137+
.isPresent();
138+
}
139+
140+
private static Optional<CallExpression> enclosingCallExpression(StringLiteral literal) {
129141
Tree parent = literal.parent();
130142
if (!(parent instanceof RegularArgument)) {
131-
return false;
143+
return Optional.empty();
132144
}
133145
Tree argList = parent.parent();
134146
if (!(argList instanceof ArgList)) {
135-
return false;
147+
return Optional.empty();
136148
}
137149
Tree callParent = argList.parent();
138150
if (!(callParent instanceof CallExpression callExpression)) {
151+
return Optional.empty();
152+
}
153+
return Optional.of(callExpression);
154+
}
155+
156+
private static boolean isInjectedMarimoSqlCall(CallExpression callExpression, StringLiteral literal, SubscriptionContext ctx) {
157+
if (!isMoSqlCall(callExpression) || !MarimoUtils.isTreeInMarimoCell(literal, ctx)) {
158+
return false;
159+
}
160+
return Optional.ofNullable(TreeUtils.firstAncestorOfClass(literal, FunctionDef.class))
161+
.map(FunctionDef::parameters)
162+
.stream()
163+
.flatMap(parameters -> parameters.nonTuple().stream())
164+
.map(Parameter::name)
165+
.filter(Objects::nonNull)
166+
.anyMatch(name -> "mo".equals(name.name()));
167+
}
168+
169+
private static boolean isMoSqlCall(CallExpression callExpression) {
170+
if (!(callExpression.callee() instanceof QualifiedExpression qualifiedExpression)) {
171+
return false;
172+
}
173+
if (!"sql".equals(qualifiedExpression.name().name())) {
139174
return false;
140175
}
141-
return MARIMO_SQL_MATCHER.isTrueFor(callExpression.callee(), ctx);
176+
return qualifiedExpression.qualifier() instanceof Name qualifier && "mo".equals(qualifier.name());
142177
}
143178

144179
private static void checkLoggerLog(SubscriptionContext ctx, CallExpression callExpression) {

python-checks/src/test/java/org/sonar/python/checks/StringFormatCorrectnessCheckTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,57 @@
1616
*/
1717
package org.sonar.python.checks;
1818

19+
import java.lang.reflect.Method;
20+
import java.util.Optional;
1921
import org.junit.jupiter.api.Test;
22+
import org.sonar.plugins.python.api.tree.ArgList;
23+
import org.sonar.plugins.python.api.tree.CallExpression;
24+
import org.sonar.plugins.python.api.tree.RegularArgument;
25+
import org.sonar.plugins.python.api.tree.StringLiteral;
26+
import org.sonar.plugins.python.api.tree.Tree;
2027
import org.sonar.python.checks.utils.PythonCheckVerifier;
2128

29+
import static org.assertj.core.api.Assertions.assertThat;
30+
import static org.mockito.Mockito.mock;
31+
import static org.mockito.Mockito.when;
32+
2233
class StringFormatCorrectnessCheckTest {
2334

2435
@Test
2536
void test() {
2637
PythonCheckVerifier.verify("src/test/resources/checks/stringFormatCorrectness.py", new StringFormatCorrectnessCheck());
2738
}
39+
40+
@Test
41+
void enclosing_call_expression_returns_empty_when_regular_argument_parent_is_not_arg_list() throws Exception {
42+
StringLiteral literal = mock(StringLiteral.class);
43+
RegularArgument argument = mock(RegularArgument.class);
44+
Tree nonArgListParent = mock(Tree.class);
45+
46+
when(literal.parent()).thenReturn(argument);
47+
when(argument.parent()).thenReturn(nonArgListParent);
48+
49+
assertThat(invokeEnclosingCallExpression(literal)).isEmpty();
50+
}
51+
52+
@Test
53+
void enclosing_call_expression_returns_empty_when_arg_list_parent_is_not_call_expression() throws Exception {
54+
StringLiteral literal = mock(StringLiteral.class);
55+
RegularArgument argument = mock(RegularArgument.class);
56+
ArgList argList = mock(ArgList.class);
57+
Tree nonCallParent = mock(Tree.class);
58+
59+
when(literal.parent()).thenReturn(argument);
60+
when(argument.parent()).thenReturn(argList);
61+
when(argList.parent()).thenReturn(nonCallParent);
62+
63+
assertThat(invokeEnclosingCallExpression(literal)).isEmpty();
64+
}
65+
66+
@SuppressWarnings("unchecked")
67+
private static Optional<CallExpression> invokeEnclosingCallExpression(StringLiteral literal) throws Exception {
68+
Method method = StringFormatCorrectnessCheck.class.getDeclaredMethod("enclosingCallExpression", StringLiteral.class);
69+
method.setAccessible(true);
70+
return (Optional<CallExpression>) method.invoke(null, literal);
71+
}
2872
}

python-checks/src/test/resources/checks/stringFormatCorrectness.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,62 @@ def marimo_sql():
142142
engine=engine
143143
)
144144

145+
146+
import marimo
147+
148+
app = marimo.App()
149+
150+
151+
@app.cell
152+
def _():
153+
import marimo as mo
154+
155+
engine = None
156+
return engine, mo
157+
158+
159+
# Previously flagged FP: marimo injects `mo` into a later cell parameter.
160+
@app.cell
161+
def _(engine, mo):
162+
mo.sql(
163+
f"""
164+
SELECT id
165+
FROM users
166+
""",
167+
engine=engine
168+
)
169+
170+
171+
@app.cell
172+
def _(engine, mo):
173+
mo.query(
174+
f"SELECT id FROM users" # Noncompliant {{Add replacement fields or use a normal string instead of an f-string.}}
175+
)
176+
177+
178+
@app.cell
179+
def _(engine, session):
180+
session.sql(
181+
f"SELECT id FROM users" # Noncompliant {{Add replacement fields or use a normal string instead of an f-string.}}
182+
)
183+
184+
185+
def not_marimo_cell(engine, mo):
186+
mo.sql(
187+
f"SELECT id FROM users" # Noncompliant {{Add replacement fields or use a normal string instead of an f-string.}}
188+
)
189+
190+
191+
@app.cell
192+
def _(engine, mo):
193+
alias = mo
194+
alias.sql(
195+
f"SELECT id FROM users" # Noncompliant {{Add replacement fields or use a normal string instead of an f-string.}}
196+
)
197+
198+
199+
@app.cell
200+
def _(engine, mo):
201+
sql(
202+
f"SELECT id FROM users" # Noncompliant {{Add replacement fields or use a normal string instead of an f-string.}}
203+
)

0 commit comments

Comments
 (0)