df.createOrReplaceTempView("lines") names a DataFrame for SQL, and spark.sql() returns a DataFrame. Up to the end of Partitioning and Caching, listings start with Loading Order History's tables loaded as orders, lines, customers and books, each also registered as a temporary view of that name.
import datetime
top = spark.sql("""
SELECT b.title, sum(l.qty) AS copies, sum(l.qty * l.unit_price) AS revenue
FROM lines l JOIN books b ON l.book_id = b.id
WHERE l.status = :status AND l.order_ts >= :since
GROUP BY b.title ORDER BY revenue DESC LIMIT 3""",
args={"status": "delivered", "since": datetime.date(2026, 1, 1)})
top.show(truncate=False)
print(type(top).__name__, top.where("copies > 50000").count()) # an ordinary DataFrameOutput
+------------------------+------+----------+ |title |copies|revenue | +------------------------+------+----------+ |Patterns of the Deep Web|67828 |2679206.00| |Salt and Saffron |85708 |2056992.00| |The Quiet Harbor |133290|1998017.10| +------------------------+------+----------+ DataFrame 3
Pass values through args (:name markers, or ? with a list), never by formatting strings: they arrive as typed literals, so SQL injection is impossible. Use a datetime.date here: a naive datetime.datetime is read in the machine's time zone, which on this UTC+8 host moved the cut-off back eight hours on a first run.