blob: 08b781804733749b045ed803b87f6e49e1cc1dfb [file]
#!/usr/bin/env python
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# /// script
# requires-python = ">=3.10"
# dependencies = ["rich>=13.6.0"]
# ///
"""Require an explicit statement-level limit for core's single-row lookups.
Track straight-line local assignments, without inferring limits across helper
calls or control-flow joins. Put ``.limit(1)`` at the execution site when a
query's bound cannot be established locally.
"""
from __future__ import annotations
import ast
import sys
from enum import Enum, auto
from pathlib import Path
from typing import NamedTuple
from common_prek_utils import console
class QueryState(Enum):
UNKNOWN = auto()
LIMITED = auto()
RESULT = auto()
LIMITED_RESULT = auto()
class ErrorLocation(NamedTuple):
line: int
column: int
class FirstLimitVisitor(ast.NodeVisitor):
def __init__(self) -> None:
self.bindings: dict[str, QueryState] = {}
self.errors: list[ErrorLocation] = []
def get_state(self, node: ast.AST | None) -> QueryState:
if isinstance(node, ast.Name):
return self.bindings.get(node.id, QueryState.UNKNOWN)
if isinstance(node, (ast.Await, ast.NamedExpr)):
return self.get_state(node.value)
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
return QueryState.UNKNOWN
method = node.func.attr
receiver = self.get_state(node.func.value)
if receiver in {QueryState.RESULT, QueryState.LIMITED_RESULT}:
if method in {"scalars", "unique", "mappings", "tuples", "columns", "yield_per"}:
return receiver
return QueryState.RESULT
if method in {"execute", "scalars", "stream", "stream_scalars"} and (
node.args or any(keyword.arg == "statement" for keyword in node.keywords)
):
statement = (
node.args[0]
if node.args
else next(keyword.value for keyword in node.keywords if keyword.arg == "statement")
)
return (
QueryState.LIMITED_RESULT
if self.get_state(statement) == QueryState.LIMITED
else QueryState.RESULT
)
if method == "limit":
if (
len(node.args) == 1
and not node.keywords
and isinstance(node.args[0], ast.Constant)
and type(node.args[0].value) is int
and node.args[0].value == 1
):
return QueryState.LIMITED
return QueryState.UNKNOWN
if method in {
"where",
"filter",
"filter_by",
"order_by",
"options",
"with_only_columns",
"offset",
"join",
"outerjoin",
"select_from",
"with_for_update",
"execution_options",
"distinct",
}:
return receiver
return QueryState.UNKNOWN
def visit_Call(self, node: ast.Call) -> None:
self.generic_visit(node)
if (
isinstance(node.func, ast.Attribute)
and node.func.attr == "first"
and self.get_state(node.func.value) not in {QueryState.LIMITED, QueryState.LIMITED_RESULT}
):
self.errors.append(ErrorLocation(node.lineno, node.col_offset + 1))
def visit_Assign(self, node: ast.Assign) -> None:
self.visit(node.value)
state = self.get_state(node.value)
for target in node.targets:
self.set_binding(target, state)
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
if node.value is not None:
self.visit(node.value)
self.set_binding(node.target, self.get_state(node.value))
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
self.visit(node.value)
self.set_binding(node.target, self.get_state(node.value))
def set_binding(self, target: ast.AST, state: QueryState) -> None:
if isinstance(target, ast.Name):
self.bindings[target.id] = state
else:
self.invalidate_bindings(target)
def invalidate_bindings(self, node: ast.AST) -> None:
for child in ast.walk(node):
if isinstance(child, ast.Name) and isinstance(child.ctx, (ast.Store, ast.Del)):
self.bindings.pop(child.id, None)
def visit_Name(self, node: ast.Name) -> None:
if isinstance(node.ctx, (ast.Store, ast.Del)):
self.bindings.pop(node.id, None)
def generic_visit(self, node: ast.AST) -> None:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
previous = self.bindings
self.bindings = {}
super().generic_visit(node)
self.bindings = previous
elif isinstance(
node,
(
ast.If,
ast.For,
ast.AsyncFor,
ast.While,
ast.Try,
ast.Match,
ast.ListComp,
ast.SetComp,
ast.DictComp,
ast.GeneratorExp,
),
):
self.invalidate_bindings(node)
previous = self.bindings.copy()
for field, value in ast.iter_fields(node):
self.bindings = previous.copy()
if isinstance(value, list):
for child in value:
if field in {"handlers", "cases"}:
self.bindings = previous.copy()
self.visit(child)
elif isinstance(value, ast.AST):
self.visit(value)
self.bindings = previous
else:
if isinstance(node, (ast.AugAssign, ast.Delete)):
self.invalidate_bindings(node)
super().generic_visit(node)
def check_source(source: str, filename: str = "<unknown>") -> list[ErrorLocation]:
visitor = FirstLimitVisitor()
visitor.visit(ast.parse(source, filename=filename))
return sorted(visitor.errors)
def main(filenames: list[str]) -> int:
failed = False
for filename in filenames:
try:
errors = check_source(Path(filename).read_text(encoding="utf-8"), filename)
except SyntaxError as error:
console.print(f"{filename}:{error.lineno}: {error.msg}", markup=False, highlight=False)
failed = True
continue
for location in errors:
console.print(
f"{filename}:{location.line}:{location.column}: .first() requires .limit(1) on the statement before execution.",
markup=False,
highlight=False,
)
failed = True
return int(failed)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))