Repository navigation
Read a qualified name in create_view and to_view, and lift the three part limit on relation writes - #764
Read a qualified name in create_view and to_view, and lift the three part limit on relation writes#764el-marvin wants to merge 5 commits into
Conversation
…part limit on relation writes
el-marvin
left a comment
There was a problem hiding this comment.
Changes requested.
I reviewed 1544acb, the one commit on top of the merge base 5b8e7e4; this is the first round. The head was built in Release by the review script; for the base I ran the merge base's Python sources over the same compiled extension, which is identical because the range changes no C++; and I ran the reporter's case on the duckdb 2.0.0 wheel. The premise holds: on 2.0.0 and on the base, rel.create_view("s.v") and rel.create_view("other.main.v") create views literally named s.v and other.main.v in memory.main; on the head they land in memory.s and other.main, and every one to five part name I tried on the five write methods lands where the same SQL puts it, with the engine's own errors, apart from the spellings the reader deliberately takes verbatim where SQL would not (spaces around a part, a bare keyword). The layer is right: the client already renders these names into statement text, so splitting the view name and lifting the three part limit belong here, not in the engine, and the description's account of why the limit existed checks out against the history. What must change before merge: a tuple with an empty part is not refused by the client, so it aborts an open transaction on the table writes; the name reader is a Python copy of the engine's qualified name reader beside the engine reader the package already calls, and the two disagree; con.table and con.view still join a tuple into one name, the same join this change fixes for the writes; and four rules the reader documents have no test. The evidence for each finding is in its inline comment.
Findings
1. A tuple with an empty part is not refused by the client, so create, to_table and insert_into send it to the parser and abort an open transaction, while the same mistake as a string, or on create_view, leaves the transaction usable (duckdb/_relation.py:3183).
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
Refuse an empty part of a tuple in _name_path with the string form's ParserException, and add the tuple form to test_refusal_keeps_the_transaction_usable.
2. _split_qualified is a Python copy of the engine's qualified name reader, beside the engine reader the package already calls for a table name in the appender, and the copy disagrees with it on a trailing dot and on the messages for an unterminated quote and an empty quoted part (duckdb/_relation.py:369).
Severity: should fix. Category: design. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: widened.
Read a string name through one seam entry over QualifiedName::ParseComponents (or TryParse), called from _name_path, _name_parts and the pragma "show" branch of query(), and decide the trailing dot once, with a test.
3. con.table, con.view and duckdb.table still join a tuple's parts into one name and silently read another table, the join this change fixes for the writes; the fix sits in _name_path and not in _name_parts, the other caller of _split_qualified (duckdb/_relation.py:3177).
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: widened.
This holds the pull request because the change makes a tuple the documented form of a name and fixes the join at one of its two callers. Read a tuple as its parts in _name_parts, as the write methods now do, or refuse it, and test that con.table(("s", "t")) reads s.t.
4. Four rules the reader documents as its contract have no test: a trailing dot is refused, a part is not trimmed, case is not folded, a bare keyword is a name; reverting any one of them leaves all 490 touched tests green (tests/fast/relational_api/test_rapi_write_qualified_name.py:521).
Severity: should fix. Category: missing coverage. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: widened.
Add those spellings to the string and tuple cases of all five methods, asserting the spelling of the created entry, not only that a read finds it.
Behaviour changes to note
Not blocking; the issue asks for the first, and the maintainer decides how to announce it.
create_viewandto_viewread a dotted string as a qualified name. A name whose first part is neither a schema nor a database used to create a view named with its dots in the default schema and now raisesCatalogException("Schema with name a does not exist!"). With a schemaxand an existing viewmain."x.y",create_view("x.y")used to replace that view and now createsx.ybeside it, leavingmain."x.y"as it was. Such a view is reached with the quoted name'"x.y"'or the one part tuple("x.y",).con.tableandcon.viewstill read a dotted string whose first part is neither a schema nor a database as one literal name, and now also a string the reader refuses, such asa"b"c; the writers refuse both. That is the readers' documented fall back for file paths, so a reader and a writer may read one string differently.- A four or five part name on
create,to_tableorinsert_intonow runs; a missing schema inside an open transaction aborts it as the same SQL does, where the old refusal left it usable. The description says so.
Smaller things
_name_text_sql(duckdb/_relation.py:3186) has the body ofduckdb._expression.identifier, which takes the same tuple and returns the same text, andTable.name()renders a third copy; importingidentifierremoves one._name_path's docstring says "a string split as SQL reads it", while the class notes say these are "the engine's rules for a qualified name string, not the SQL lexer's"; the class notes are right.- The annotations say
str | tuple[str, ...]and the message says "as a tuple of its parts", but a list is accepted too (rel.insert_into(["s", "t"])writess.t); annotate what is accepted, or refuse a list. - A part that is a
strsubclass is read by its value increateandinsert_intobut by__str__increate_view, becauseNameFromPartscallsnb::str(part): withclass Names(str, enum.Enum),create((Names.SCHEMA, Names.TABLE))writess.twhilecreate_view((Names.SCHEMA, Names.VIEW))raises "Schema with name Names.SCHEMA does not exist!". Returning plain strings from_name_path(str.__str__(part)) gives one reading. - In
test_name_with_a_stray_quote_reads_the_literal_name(tests/fast/api/test_table_name_resolution.py) the secondwith connect(STRAY_QUOTES)block uses neither parameter, so itscon.view('v"w')check runs three times unchanged; it reads better as a test of its own. - Six refusal tests in
test_rapi_write_qualified_name.py(lines 482, 490, 500, 508, 536 and 622) open a secondconnect(FLAT_SETUP)only to read a table count that never changes; takingbefore = table_count(con)ahead of the call, asassert_refused_as_sql_refusesdoes, saves a database setup per case. - The Spark shim keeps a dotted temporary view name as one name, so with a schema
xpresentspark.table("x.y")cannot read back whatcreateOrReplaceTempView("x.y")made ("Table with name y does not exist!", base and head alike). The new test's skip reason says PySpark refuses such a name; refusing it here too would keep the two halves of the shim consistent. This predates the change.
Questions
- The description's statement that PySpark refuses a multi part temporary view name was not run here (no Spark on this machine); the skip in
test_temp_view_with_a_dotted_name_is_one_namerests on it.
Outside findings not taken
- A regular expression form of
_split_qualified: style, and the second finding removes the copy. - Moving the empty part refusal into
_split_qualified: where that rule lives follows from the first two findings. - The tuple checks being in both
_name_pathand the C++NameFromParts, the C++ copy unreachable fromcreate_view: the C++ copy still serves_table_kind, and the two agree word for word. - Duplicated helpers and parameter lists in the new test file: style.
- The second
_ensure_openin thepragma "show"branch and the character at a time splitter: a few microseconds against a write of about 290 microseconds, on no hot path. - Two renderers of a quoted path, the Python
quoteand the C++QuotedName: they agree on every name probed. _SHOW_PRAGMAmatchingſhowwithoutre.ASCII, and a lone surrogate in a name escaping as a nanobindTypeError: adversarial input, and the base does the same.- Sharing
_expression.name_partsas the tuple check: it raisesTypeErrorandValueErrorfor expression names, another contract; taken only as evidence for the first finding. - A string
con.tablereads literally that the writers refuse, such asa"b"c: the readers' documented fall back for file paths; listed under behaviour changes. - Refusals of four part names inside
BEGINnow aborting the transaction: as SQL does, and the description says so.
What I verified
- Head 1544acb against merge base 5b8e7e4, one commit, seven files; no C++, stub, CMake or pyproject change in the range.
- Builds: the head's Release build (
BUILD OK); the base as the merge base's Python sources over the head's compiled extension; the release as the duckdb 2.0.0 wheel. - The reporter's script on 2.0.0, the base and the head.
- Probes, each connection at
memory_limit8GB and 8 threads:create_viewagainstCREATE OR REPLACE VIEWof the same text for 49 names (one to five parts, attached databases, quoted dots and doubled quotes, a missing catalog or schema, the temp catalog, a table in the way, empty, dots only, leading and trailing dots, stray and unterminated quotes, case, spaces, keywords); the five methods with four and five parts; tuples, lists,None, an int, bytes and empty parts; each refusal inside an open transaction against the same SQL; a closed connection; a Python object into file and memory databases by path; held rows; replace on one to four part names;USE;con.table,con.view,con.appenderandpragma "show"against the engine's readers; the Spark shim; astrEnum part; the reader's cost. - Tests: the four touched files on the head, 490 passed in 8.26s; the head's tests against the base's sources, 125 failed, 365 passed. In a clone of the head I reverted each changed area in turn and ran the four files: the old splitter, its check for a quote inside a bare part, its check for text after a closing quote, its doubled quote rule, the string empty part check, the type check, the empty tuple check, the non string part check,
create_viewpassing the path, a three part limit on the table writes and on the views, and the Spark line each turn tests red; dropping_ensure_openturnstests/fast/relational_api/test_rapi_close.pyred; thepragma "show"rewrite is a refactor and stays green; the rules of the fourth finding stay green.tests/fast/relational_api,tests/fast/apiandtests/fast/sparkon the head: 5242 passed, 42 skipped, 13 xfailed in 105.00s. - Outside reviews: the code-review skill at high effort (nine finder reports: simplification, altitude, reuse, efficiency, line scan, wrapper and proxy, language pitfalls, removed behaviour, cross file) and Codex at xhigh, read only. Every finding taken was reproduced here.
- CI at the time of review: every finished check green (Linux and Windows seeds and sanity jobs, the sdist, the full API build with assertions, code quality, submodule sanity); the two macOS seed jobs were still pending.
What held
- The reporter's case, and every one to five part name I tried on all five methods (other than the verbatim spellings above), in the default and attached databases and in nested schemas, with quoted parts holding dots and doubled quotes, land where SQL puts them, also with
USEin effect. - A missing catalog or schema, an ambiguous qualifier, the temp catalog, a table where the view should go, and
replace=Falseon an existing view give the engine's errors, with SQL's kind and text. - A view over a DataFrame or with parameters reads after the relation is gone; a Python object is refused for a file database and allowed for an attached in-memory one, decided by the path's catalog; held rows are refused, naming the quoted path.
- Every string refusal happens before anything runs and leaves an open transaction usable; a closed connection is refused first;
None, an int and bytes are refused withInvalidInputException. - The three stray quote spellings now match the engine's reader, in
con.tableand inpragma "show". - The reader costs about 2.6 microseconds per name against about 290 microseconds for a
create_view; no hot path is touched.
| raise _error(_duckdb.InvalidInputException, "A name must have at least one part") | ||
| if not all(isinstance(part, str) for part in name): | ||
| raise _error(_duckdb.InvalidInputException, "Every part of a name must be a string") | ||
| return tuple(name) |
There was a problem hiding this comment.
A tuple with an empty part is not refused here, so create, to_table and insert_into send it to the parser and abort an open transaction, while the same mistake as a string, or on create_view, leaves the transaction usable.
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
if not all(isinstance(part, str) for part in name):
raise _error(_duckdb.InvalidInputException, "Every part of a name must be a string")
return tuple(name)The string branch above refuses an empty part before anything runs. The tuple branch returns it, create renders CREATE TABLE "s"."" AS ..., and the parser refuses that after the statement was submitted, which invalidates the transaction. create_view parses its own CREATE VIEW head in DuckDBPySeam::ViewStatement before it runs anything, so there the transaction survives. Inside BEGIN; CREATE TABLE sentinel(i INT), then the call, then INSERT INTO sentinel VALUES (1), on the head:
rel.create ('s', '') ParserException: Parser Error: zero-length delimited identifier | after: TransactionException: TransactionContext Error: Current transaction is aborted (please ROLLBACK)
rel.insert_into ('s', '') ParserException: Parser Error: zero-length delimited identifier | after: TransactionException: TransactionContext Error: Current transaction is aborted (please ROLLBACK)
rel.create_view ('s', '') ParserException: Parser Error: zero-length delimited identifier | after: ok [(1,)]
rel.create 's.""' ParserException: Parser Error: Zero-length identifier in qualified name! (input: s."") | after: ok [(1,)]
('',) and ('', 't') behave the same. The base had no tuple form on the table writes (it joined ("s", "t") into st), so this is new here. test_empty_part_refused (line 506) runs outside a transaction and matches the parser's message, so it passes either way. The package already holds this rule for tuple names elsewhere: duckdb._expression.name_parts refuses an empty part.
Ask: refuse an empty part of a tuple here with the same ParserException as the string form, and add the tuple form to test_refusal_keeps_the_transaction_usable.
| @@ -373,29 +367,45 @@ def _stand_in_type(type_: DuckDBPyType) -> str: | |||
|
|
|||
|
|
|||
| def _split_qualified(text: str) -> list[str]: | |||
There was a problem hiding this comment.
_split_qualified is a Python copy of the engine's qualified name reader, beside the engine reader the package already calls for a table name, and the copy disagrees with it on a trailing dot and on the messages for an unterminated quote and an empty quoted part.
Severity: should fix. Category: design. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: widened.
The engine's reader is TryParseComponents in external/duckdb/src/parser/qualified_name.cpp, behind QualifiedName::ParseComponents and QualifiedName::TryParse. The appender already reads its table name with it, in src/pyappender.cpp:
static ResolvedTable ResolveTable(ClientContext &context, const string &text) {
QualifiedName parsed;
string parse_error;
if (!QualifiedName::TryParse(text, parsed, parse_error)) {
throw ParserException(parse_error);
}and _column_name in this file reads a column reference through the engine with _seam.column_reference. On the same strings, on the head (query_table and SQL's pragma "show" are the engine's readers of a name string):
con.appender("s.a.") opens the appender on s.a
rel.insert_into("s.a.") ParserException: Zero-length identifier in qualified name! (input: s.a.)
con.sql("pragma \"show\"('s.')") CatalogException: Table with name s does not exist! (it read s)
rel.query("v", "pragma \"show\"('s.')") ParserException: Zero-length identifier in qualified name! (input: s.)
query_table('"'), pragma "show"('"') Unterminated quote in qualified name! (input: ")
rel.create('"'), rel.query(pragma "show") Unterminated quoted identifier in qualified name! (input: ")
query_table('s.""'), pragma "show"('s.""') Zero-length delimited identifier in qualified name! (input: s."")
rel.create('s.""'), rel.query(pragma "show") Zero-length identifier in qualified name! (input: s."")
The base already had a Python splitter, but this change rewrites it whole, makes it the reader of create_view and to_view, and documents it as the engine's rules with "the one difference" a trailing dot; the two message differences are not mentioned, and the pragma "show" branch of query(), which exists to answer as SQL's pragma does, still refuses s. and words the other two unlike SQL. The new parity tests cover only the two messages the copy took over.
Ask: read a string name through one seam entry over the engine's reader that returns the parts and raises the engine's ParserException, and call it from _name_path, _name_parts (keeping its fall back to one literal name) and the pragma "show" branch. If the writes must still refuse a trailing dot the engine drops, make that one explicit check in _name_path, say so in the class notes, and test it. The pins on the old wording (line 535 of the new test file, and the '""' and 's.""' cases at line 521) then take the engine's.
| if any(not part for part in parts): | ||
| raise _error(_duckdb.ParserException, f"Zero-length identifier in qualified name! (input: {name})") | ||
| return parts | ||
| if not isinstance(name, (tuple, list)): |
There was a problem hiding this comment.
con.table, con.view and duckdb.table still join a tuple's parts into one name and silently read another table: the join this change fixes for the writes, fixed here in _name_path but not in _name_parts, the other caller of _split_qualified.
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: widened.
This holds the pull request: the description fixes rel.create(("s", "t")) writing to st as a silent wrong target and makes a tuple the documented form of a name, but the helper that joins is still reached with a tuple from the readers, at line 3530:
def _name_parts(name: str) -> tuple[str, ...]:
"""The parts of a dotted name, or the whole name as one part when it cannot be one (a path such as ./x.csv)."""
try:
parts = tuple(_split_qualified(name))_split_qualified walks whatever sequence it is given, so the elements of a tuple become the characters of one part. With tables s.t and st and views s.v and sv, on the head and on the base alike:
con.table(("s", "t")) [('wrong st',)]
con.table(["s", "t"]) [('wrong st',)]
con.view(("s", "v")) [('wrong sv',)]
duckdb.table(("s", "t"), connection=con) [('wrong st',)]
So rel.create(("s", "t")) followed by con.table(("s", "t")) reads a different table without an error. The stubs annotate table_name: str, but nothing refuses a tuple at run time.
Ask: give _name_parts the type step this function has: a tuple is the parts of the name, as on the five write methods (or refuse it, if con.table should take strings only), and anything else is refused; never join. Put the check where both callers pass through (the shared reader, or the seam entry the finding on _split_qualified asks for), and add a test that con.table(("s", "t")) reads s.t or raises.
|
|
||
| class TestEmptyViewNamePart: | ||
| @pytest.mark.parametrize("method", VIEW_METHODS) | ||
| @pytest.mark.parametrize("name", ["", "s..v", ".v", '""', 's.""']) |
There was a problem hiding this comment.
Four rules the reader documents as its contract have no test: a trailing dot is refused, a part is not trimmed, case is not folded, and a bare keyword is a name; reverting any one of them leaves every touched test green.
Severity: should fix. Category: missing coverage. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: widened.
The class notes say "Every part is then one identifier taken verbatim: no case folding, no trimming of whitespace, a keyword or a name with a space accepted quoted or not", and the docstring of _split_qualified makes the trailing dot "the one difference" from the engine. In a clone of the head I changed _split_qualified one way at a time and ran the four touched test files (490 tests):
drop a trailing dot, as the engine does 490 passed
strip whitespace from each part 490 passed
lower case each part 490 passed
Every name in the new tests is lower case without leading or trailing spaces and none ends in a dot, and a read that succeeds cannot show the spelling, because the catalog compares names without regard to case. The head does follow the rules (create_view("s. v") makes memory.s." v", create_view("S.V") makes memory.s."V", create_view("select") makes memory.main."select"), so this is coverage, not behaviour; at the base the trailing dot refusal on create and insert_into was untested too.
Ask: add "s.", "s. v", "S.V" and "select" (and the tuple forms where they apply) to the cases of all five methods, asserting the created entry's spelling from duckdb_views() or duckdb_tables(), so that changing a rule, including the change the finding on _split_qualified asks for, has to change a test.
…e reader, take a tuple as parts in con.table and con.view
el-marvin
left a comment
There was a problem hiding this comment.
Changes requested.
I reviewed a7a998a, one commit on top of 1544acb, which the first round reviewed; it answers that round's four requests and adds C++: a seam entry over the engine's qualified name reader and a change to how the seam reads a str subclass. The head was built in Release by the review script. Because the range now holds C++, this round I built the merge base 5b8e7e4 in Release the same way, instead of running the base's Python over the head's extension as the first round did, and reran every first round probe on the 2.0.0 wheel, the base build and the head. The premise still holds: on 2.0.0 and the base rel.create_view("s.v") makes memory.main."s.v", on the head memory.s.v. All four first round findings are addressed, each checked by rerunning its probe and by reverting the fix in a clone of the head, which turns the new tests red. The layer is right, and better than the first round's version: a name string now goes through QualifiedName::TryParse, the reader the appender and SQL's pragma "show" use, so the writers, the appender and query()'s pragma "show" branch agree with SQL on every spelling I tried, messages included, and the readers do too, apart from their documented fall back to one literal name. Two things must change before merge: createOrReplaceTempView in the Spark shim now unquotes a backtick-quoted name, but the shim's spark.table still reads the same string by DuckDB's rules, so a view made under a backtick-quoted name cannot be read back with spark.table, which worked on the base; and no test pins that a view of a Python object is refused or allowed by the database its dotted name names, a rule the change's main feature now reaches from every dotted string (I missed this in the first round; it was already in the range). One nit remains on the pragma "show" pattern. The evidence for each is in its inline comment.
Since the last review
- 1, a tuple with an empty part aborting an open transaction: addressed. Inside
BEGIN,("s", ""),("",)and("", "t")on all five write methods raiseParserException: Parser Error: Zero-length identifier in qualified name! (input: "s"."")(and the two other inputs) before anything runs, and the sentinel insert after each one succeeds (15 of 15).con.tableandcon.viewrefuse the same tuples in an open transaction that stays usable. Removing the check turns 22 tests red. - 2, a Python copy of the engine's reader: addressed.
_split_qualifiedis gone;_name_path,_name_partsand thepragma "show"branch read a string through_duckdb._name_components, which callsQualifiedName::TryParseand raises itsParserExceptionunchanged. On"",s."",",s."v,s.,s..t,a"b"c,"a"b,s."a"b,"s.t"ands.ab,rel.query(name, 'pragma "show"(...)')now gives exactly what SQL'spragma "show"gives. The trailing dot is decided once: dropped, as the engine reads it;con.appenderandinsert_intoagree ona.,s.t.,"a,"s,s."",s..tand the stray quote spellings. Refusing the trailing dot again in the writers turns 12 tests red, keeping it as a literal name in the readers turns one red. - 3,
con.tableandcon.viewjoining a tuple: addressed. With tabless.tandstand viewss.vandsv,con.table(("s", "t")),con.table(["s", "t"]),con.view(("s", "v"))andduckdb.table(("s", "t"), connection=con)reads.tands.v. Joining the tuple again turns 11 tests red. - 4, four reader rules without a test: addressed.
TestReaderRulesasserts the stored spelling; in a clone of the head, refusing a trailing dot turns 12 tests red, trimming each part 10, lower casing each part 8. - Smaller things from the first round:
_name_text_sqlis gone in favour of_expression.identifier;_name_path's docstring is corrected; astrsubclass part is read by its value in Python and in the seam (create_view((Names.SCHEMA, Names.VIEW))now makess.v; reverting the C++ half in a rebuilt clone turns the fivetest_str_subclass_part_is_read_by_its_valuecases red, reverting the Python half turns the same five red); the repeated test block and the second connections are gone. A list as a name: addressed by decision; the public contract isstr | tuple[str, ...]and a list keeps working undocumented, becausecreate_viewtook one on 2.0.0 through the seam; that reason holds. The Spark shim's dotted temporary view name: now refused as PySpark refuses it; see the finding below for what that change left behind. - The first round's question, whether PySpark refuses a multi part temporary view name: settled. Spark's
Dataset.createTempViewraisesTEMP_VIEW_NAME_TOO_MANY_NAME_PARTSwith the name as given, sqlState 428EK inerror-conditions.json; the shim's error class, message and sqlState match.
Findings
1. A temporary view name in backticks is unquoted by createOrReplaceTempView but not by SparkSession.table, so spark.table no longer finds a view the shim just made under that name, where the base found it (duckdb/experimental/spark/sql/dataframe.py:748).
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
Read the name in SparkSession.table with the same _name_parts and pass its parts to conn.table, and read the view back with spark.table in the new test.
2. No test pins that a view of a Python object is refused or allowed by the database its qualified name names; the check now decides it for every dotted string given to create_view, and making it look at the default database alone leaves every touched test green (tests/fast/relational_api/test_rapi_write_qualified_name.py:610, the check at src/pyconnection/seam.cpp:630).
Severity: should fix. Category: missing coverage. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: widened.
Add a frame view into an attached file database by a dotted string and a tuple (refused, quoted path, no view left) and from a file backed connection into an attached ':memory:' database (created, readable after the frame is gone).
3. re.ASCII on _SHOW_PRAGMA narrows \s too, so a pragma "show" with a no-break or ideographic space between the words is no longer rewritten and rel.query describes the catalog entry instead of the relation, unlike the base (duckdb/_relation.py:77).
Severity: minor. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
Drop the flag, or confine it to the keyword with (?a:show); a nit.
Behaviour changes to note
Not blocking; the maintainer decides how to announce them. The description lists each one.
create_viewandto_viewread a dotted string as a qualified name; a first part that is neither a schema nor a database now raisesCatalogException, andcreate_view('"a.b"')makesa.bwhere it kept the quotes.con.tableandcon.viewstill read a string the engine's reader refuses (a"b"c,"a"b,"s,./x.csv) as one literal name, which the writers and the appender refuse; that is the readers' fall back for file paths.- A trailing dot is dropped by every reader and writer: with a table literally named
a.,con.table("a.")now readsa. - A four or five part name on
create,to_tableorinsert_intoruns, and a missing schema in an open transaction aborts it, as the same SQL does. - The Spark shim refuses a multi part temporary view name with PySpark's error class and unquotes backticks; a name its reader refuses (
x.,a..b, the empty name) raises the shim'sINVALID_ATTRIBUTE_NAME_SYNTAX, where PySpark raises its own "Invalid view name" class.
Smaller things
- The tuple form skips the readers' fall back to a literal name (
isinstance(name, str)in_catalog_relation,duckdb/_relation.py:3550), but no test has a tuple whose first part is neither a schema nor a database; with the check removed,con.table(("x", "y"))beside a table"x.y"fails withAttributeError: 'tuple' object has no attribute 'replace'and all touched tests stay green. A case inTestTupleNames(a table"x.y", no schemax, the tuple raisingCatalogException) would pin the rule. - The empty name check in
createOrReplaceTempView(if not parts[0]:,dataframe.py:753) has no test: removing it leaves every touched test green, andcreateOrReplaceTempView("")then raises the relation API'sParserExceptioninstead of theINVALID_ATTRIBUTE_NAME_SYNTAXthe description names. test_temp_view_with_a_dotted_name_is_refusedchecks the error class but not the sqlState 428EK the change adds toerror_classes.py.DataFrameWriter.saveAsTable(duckdb/experimental/spark/sql/readwriter.py:393) passes its Spark name torelation.createas DuckDB reads it:saveAsTable("`x.y`")looks for a schema named`x(base and head alike), andsaveAsTable("t."), which Spark's parser refuses and the base refused, now createst. Reading the name with_name_partsthere too, with the fix of the first finding, would give the shim one rule for its names.con.tableandcon.vieware the functions_connection_tableand_connection_view(duckdb/_relation.py:4358and4363), still annotatedtable_name: strandview_name: str, soinspect.signature(con.table)shows(table_name: 'str')while the stubs andduckdb.tablesaystr | tuple[str, ...].- The class notes say a string the reader refuses, "such as a file path, is one name"; but the reader takes
people.csvas['people', 'csv'], and the file is read becausepeoplenames no schema or database (with a schemapeopleholding a tablecsv,con.table("people.csv")reads the table, base and head alike). The notes would say what holds by naming that second fall back, which_catalog_relation's docstring already states. - The module docstring of
test_rapi_write_qualified_name.pysays the writes "land where SQL puts the same name, and refuse a name as SQL does", whichTestReaderRulesin the same file contradicts on purpose (s. new, a trailing dot and a bare keyword are taken where SQL reads or refuses them differently). - The description's "a tuple is now the parts of the name everywhere" reads as if
con.appendertook one too; it takes a string only (TypeErroron a tuple, as before). Naming the nine functions instead of "everywhere" says what holds.
Outside findings not taken
- Whitespace around a temporary view name (
" v "keeps its spaces,"`v` "is refused, where Spark's parser makes bothv): adversarial input; the shim also accepts names Spark refuses ("q",my view), which is leniency, not a wrong target. - No test with a list as a name: a list is accepted undocumented by the author's decision, which this review accepts; it is not a contract to pin.
- The tuple checks in
_name_tupleduplicatingNameFromPartsin C++: no public method hands the C++ reader an unchecked tuple (create_viewpasses the parts_name_pathreturned,table_kindgets a checked step's parts or the query name), the same reason as in the first round. - A str subclass becoming the alias when
con.tablereads a string whole: the base does the same, and it needs astrEnum whose value is a dotted name with no schema. - An embedded NUL and a lone surrogate in a name: the seam keeps a NUL (length aware on both sides), and a lone surrogate fails nanobind's conversion with a
TypeErrorbefore the seam runs, on writers and readers alike; adversarial input, and the base fails on it too.
What I verified
- Head a7a998a against merge base 5b8e7e4, two commits, ten files, 791 lines added and 162 removed; the new commit 1544acb..a7a998a changes 352 and 104. The commit holds what the author's reply lists and nothing beyond it.
- Builds: the head's Release build (
BUILD OK); a Release build of the merge base with the same twouv syncsteps, Python 3.13; the duckdb 2.0.0 wheel. Probes run each build without an import time rebuild. - Every first round probe rerun on all three builds: the reporter's case, 49 names on
create_viewagainstCREATE OR REPLACE VIEW, the readers againstquery_table,pragma "show"andpragma_table_info, four and five part writes, the tuple refusals insideBEGIN, storage, replace andUSE, the messages, the siblings and the appender, the Spark shim, thestrEnum parts. - The first round's outside review probes (42 scripts, copied to the round folder) on base and head: the differences are the intended behaviour changes; two scripts import helpers this commit removes (
_split_qualified,_name_text_sql), and the fuzz of the removed Python reader and two timing scripts of the test setup were not rerun. - New probes:
_duckdb._name_componentson 26 inputs (empty, dots only, leading, trailing and doubled dots, empty and unterminated quotes, stray quotes, quoted dots and doubled quotes, NUL, lone surrogates, non ASCII, spaces, case, a keyword, six parts, a 10,000 character part,%sin the input) against the engine'squery_table: same messages and exception class (duckdb.ParserException), a NUL kept, astrEnum and astrsubclass with its own__str__read by value,None, an int, bytes and a tuple refused by nanobind withTypeError. The appender,insert_into,con.tableandquery_tableon 14 strings and three tuples. The Spark shim's create and read back on 14 names, on base, head, and a clone withSparkSession.tablereading Spark names. - Limited API:
src/pyconnection/seam.cppcompiled with the build's own command fromcompile_commands.jsonplus-DPy_LIMITED_API=0x030C0000 -fsyntax-only, head and base: exit 0, no errors, 26 warnings each. - Tests: the four touched files on the head, 586 passed in 12.26s; the same files on the base build, 196 failed, 390 passed; every test function this round adds fails there in at least one case, except
test_view_with_a_stray_quote_reads_the_literal_name, which pins a reading the base already had. Reverts in a clone of the head, each run against the touched files plustest_rapi_close.pyandtest_seam.py(962 tests): the writers refusing a trailing dot (12 failed), the readers keeping it (1), trimming (10), lower casing (8), no tuple empty part check (22), tuple parts not cast tostr(5), the reader checking the name before the connection (4), the tuple joined in the readers (11),query()not casting its name (1),pragma "show"refusing a trailing dot (1), the Spark multi part refusal removed (1), the Spark view made from the raw name (1), the C++NameFromPartschange reverted in a rebuilt clone (5 of 960); green: the tuple skipping the readers' fall back, the Spark empty name check, andre.ASCIIon thepragma "show"pattern (adversarial input only). - Full
tests/faston the head, eight workers and a 600 second timeout per test: 16999 passed, 83 skipped, 223 xfailed in 868.26s, the figure the author reports. - The two new findings' probes on base and head (
pragma "show"with an ASCII, a no-break and an ideographic space;saveAsTableon three names; frame views into attached file and memory databases from memory and file connections), and the storage rule's revert in a rebuilt clone of the head (963 passed with the check looking at the default database only). - Outside reviews: Codex at xhigh, read only, and the code-review skill, which ran at max effort rather than the high it was given, because it did not read the level from its arguments. Every finding taken was reproduced here.
- CI on a7a998a when this was written: every finished check passed (submodule sanity, code quality, the sdist, the full API build with assertions, the seed jobs on Windows amd64 and ARM64, Linux x86_64 and aarch64 and macOS x86_64, and both cp311 Linux sanity jobs); the macOS arm64 seed job was still queued. Nothing is red.
What held
- The reporter's case, and every one to five part name of the first round's matrix on all five write methods, land where SQL puts them, with the engine's errors.
- Every string the engine's reader refuses is refused by every writer before anything runs, with the engine's message, and leaves an open transaction usable; so is every bad tuple.
- The writers, the appender and
pragma "show"read every string I tried the same way as SQL; the readers differ only on strings the engine refuses, as documented. - A tuple is read as its parts on all nine functions and is never joined; a
strsubclass counts by its value in Python and in C++, also inquery(). - The new seam entry passes the engine's exception and message through unchanged, owns its result strings, keeps a NUL, runs under the GIL for a parse that needs no release, and compiles under the limited API.
con.tableandcon.viewrefuse a closed connection before reading the name.- The Spark shim's refusal of a multi part temporary view name matches PySpark's error class, message and sqlState.
- No hot path is touched: the readers run once per
con.tablecall and the writes once per statement.
| True | ||
|
|
||
| """ | ||
| parts = _name_parts(name) |
There was a problem hiding this comment.
A temporary view name in backticks is now unquoted on the way in but not on the way out: createOrReplaceTempView reads it with the shim's Spark name reader, while SparkSession.table still passes the same string to con.table, which reads it by DuckDB's rules, so spark.table no longer finds a view the shim just made under that name; on the base it did.
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
The new reading here:
parts = _name_parts(name)
...
relation.create_view((parts[0],), True)and the shim's reader of the same name, unchanged, in duckdb/experimental/spark/sql/session.py:297:
def table(self, tableName: str) -> DataFrame: # noqa: D102
relation = self.conn.table(tableName)
return DataFrame(relation, self)df.createOrReplaceTempView(name) followed by spark.table(name), each connection at 8 threads and an 8GB memory limit:
name base 5b8e7e4 head a7a998a
`v` makes `v` (backticks kept), reads it makes v, CatalogException: Table with name `v` does not exist!
`x.y` makes `x.y`, reads it makes x.y, CatalogException: Table with name `x.y` does not exist!
`a``b` makes `a``b`, reads it makes a`b, CatalogException: Table with name `a``b` does not exist!
`select` makes `select`, reads it makes select, CatalogException: Table with name `select` does not exist!
The base was wrong the other way (it kept the backticks in the view's name, so spark.sql("SELECT a FROM `x.y`") missed it), but its two halves agreed. In PySpark both halves read the string with the same SQL identifier parser (Dataset.createTempView calls parseMultipartIdentifier, DataFrameReader.table calls parseTemporalTableIdentifier), so spark.table("`x.y`") reads the view x.y there, and backticks are how a Spark user names a temporary view with a dot, a space or a hyphen. test_temp_view_name_is_read_with_spark_quoting reads the view back only through spark.sql, whose text DuckDB's parser unquotes, so it passes either way.
In a clone of the head I changed SparkSession.table to self.conn.table(_name_parts(tableName)) (the connection's table() takes a tuple since this change): every name above, and every other name createOrReplaceTempView accepts in the probe, reads back with spark.table, and tests/fast/spark passes (1828 passed, 7 skipped, 13 xfailed).
Ask: read the name in SparkSession.table with the same _name_parts and pass its parts to conn.table, and read the view back with spark.table in test_temp_view_name_is_read_with_spark_quoting.
| target = name if isinstance(name, str) else ".".join(name) | ||
| assert con.sql(f"FROM {target}").fetchall() == [(42,)] | ||
|
|
||
| def test_held_rows_refused(self): |
There was a problem hiding this comment.
No test pins that a view of a Python object is refused or allowed by the database its qualified name names: the check in src/pyconnection/seam.cpp:630 now decides this for every dotted string given to create_view, and making it ignore the named database leaves every touched test green.
Severity: should fix. Category: missing coverage. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: widened.
The check, unchanged, which this change makes reachable from a dotted string (on the base create_view("f.v") made main."f.v" in the default database, so only a tuple reached it):
if (!temporary && inliner.HoldsProcessLocalInput() && StoredInAFile(context, name)) {The only test of the refusal is tests/fast/api/test_seam.py::test_a_python_object_cannot_be_stored_in_a_file, which calls the private _create_view("p", ...) with an unqualified name on a file database. TestViewOwnsItsSources covers a frame view into db2, which is ':memory:'. On the head, a relation over a DataFrame:
memory default, file database f attached: create_view("f.v") refused: Cannot create the view "f"."v": it reads a Python object ...
create_view(("f","main","v2")) refused, the same
create_view("v3") created
file default, ':memory:' attached as m: create_view("m.v") created
create_view(("m","main","v2")) created
create_view("v3") refused
That is right. In a clone of the head I changed the call to StoredInAFile(context, QualifiedName(name.Name())), so the check looks only at the default database, and rebuilt: the touched test files and test_seam.py and test_relation_to_view.py all pass (963 passed), while create_view("f.v") from a memory connection now fails at commit with TransactionException: Failed to commit: Cannot serialize a table and create_view("m.v") from a file connection is refused. The description also names this refusal among the messages that now quote every part of the path, which no test checks either.
Ask: add to TestViewOwnsItsSources a frame view into an attached file database by a dotted string and by a tuple, refused with the quoted path and leaving no view, and a frame view from a file backed connection into an attached ':memory:' database, created and readable after the frame is gone.
| _LOWER_NAME = re.compile(r"^[a-z_][a-z0-9_]*$") | ||
| _SHOW_PRAGMA = re.compile(r"""^\s*pragma\s+(?:"show"|show)\s*\(\s*'((?:[^']|'')*)'\s*\)\s*;?\s*$""", re.IGNORECASE) | ||
| _SHOW_PRAGMA = re.compile( | ||
| r"""^\s*pragma\s+(?:"show"|show)\s*\(\s*'((?:[^']|'')*)'\s*\)\s*;?\s*$""", re.IGNORECASE | re.ASCII |
There was a problem hiding this comment.
re.ASCII also narrows \s to ASCII whitespace, so a pragma "show" written with a no-break or ideographic space, which the engine's parser reads as a space, is no longer rewritten and rel.query(name, ...) describes the catalog entry instead of the relation, as it did on the base.
Severity: minor. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
With a table t(o VARCHAR) and con.sql("select 1 as a").query("t", text):
text base head
pragma "show"('t') [('a', 'INTEGER', ...)] [('a', 'INTEGER', ...)]
pragma\u00a0"show"('t') [('a', 'INTEGER', ...)] [('o', 'VARCHAR', ...)]
pragma\u3000"show"('t') [('a', 'INTEGER', ...)] [('o', 'VARCHAR', ...)]
The flag was added for ſhow, which the first round left out as adversarial input; this trades that corner for another and has no test either way. Dropping the flag restores the base's reading; (?a:show) around the keyword alone would keep both. A nit; take it or leave it.
…e, pin the file database view check per named database
el-marvin
left a comment
There was a problem hiding this comment.
Changes requested.
I reviewed 1d10a6e, one commit on top of the last round's head a7a998a, Python and tests only: eight files, 78 lines added and 11 removed, no C++ in the diff of a7a998a..1d10a6e. I ran it on a Release build of the head, on the Release build of the merge base 5b8e7e4 from the last round, and on the released duckdb 2.0.0, and I rebuilt a clone of the head with the last round's mutation of the file database check. The premise still holds: 2.0.0 and the merge base put create_view("s.v") in the default schema under the dotted name, the head puts it in schema s. The approach sits at the right layer, and the three items of the last round are fixed at their root: spark.table and saveAsTable now read a name with the shim's own Spark reader and hand the parts to the tuple form, the file database check is pinned in both directions with a string and a tuple, and the pragma "show" pattern keeps Unicode whitespace with ASCII keywords. One thing must change before merging: the new saveAsTable test fails when the suite runs against PySpark, because PySpark refuses the table names it creates. A minor coverage gap and three smaller things follow.
Since the last review
spark.tablecould not read back a backtick quoted temporary view: addressed. The last round's probe on the head:`v`,`x.y`,`ab``, ``select`` and'"q"'all read back throughspark.table, where the last head raisedCatalogException. `` saveAsTable("x.y") `` makesmain."x.y"and reads back, andsaveAsTable("t1.")raisesINVALID_ATTRIBUTE_NAME_SYNTAXagain where the last head createdt1. RevertingSparkSession.tableto the raw string fails two tests, revertingsaveAsTable` fails one.- No test pinned that a view of a Python object follows the database its name names: addressed. With
StoredInAFile(context, QualifiedName(name.Name()))atsrc/pyconnection/seam.cpp:630in a rebuilt clone, all four cases oftest_frame_into_an_attached_file_database_refusedandtest_frame_into_an_attached_memory_database_from_a_filefail (4 failed, 904 passed) and they pass on the head. On the merge base the two string cases fail and the two tuple cases pass, as expected: the C++ side already read a tuple as parts. re.ASCIInarrowed the whitespace of the pragma pattern: addressed.pragmaand"show"('t')separated by a no-break space (U+00A0) or an ideographic space (U+3000) inrel.querydescribe the relation again, as 2.0.0 does;pragma ſhow('t')andpragma "ſhow"('t')now raise the engine's "Pragma Function with name ſhow does not exist!" exactly as SQL does, where 2.0.0 and the base rewrote them. The last round's pattern fails the new test. Using(?a:...)around the keywords alone is the right shape.- The last round's smaller things: all taken and verified. The tuple that skips the literal fall back is pinned (removing the
isinstancecheck atduckdb/_relation.py:3553failstest_parts_are_not_one_literal_name), the empty temporary view name is pinned (removing the check atdataframe.py:753failstest_temp_view_with_a_dotted_name_is_refused), sqlState 428EK is asserted,saveAsTablereads Spark names,_connection_tableand_connection_viewcarrystr | tuple[str, ...](the runtime signature shows it), the class notes and the test module docstring are corrected, and the description names the nine functions. - The four findings of the first round stay addressed: its probes give the same output on the head as on the last head, apart from object addresses and file paths in tracebacks.
Findings
-
The new
saveAsTabletest fails against PySpark, which refuses the table names it creates (tests/fast/spark/test_spark_writer.py:139).
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
Keep the names PySpark accepts in the shared loop, pin the shim's wider acceptance of`x.y`and`ab`` underif not USE_ACTUAL_SPARK` (or assert PySpark's refusal there), and say in the description that a dot or a backtick in a table name is the shim's leniency. -
No test tells reading a backtick quoted dotted name as one part apart from passing the joined string, because no schema
xexists in the test (tests/fast/spark/test_spark_dataframe.py:940).
Severity: minor. Category: missing coverage. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
Create a schemaxbefore the loop, so`x.y`must be read as one part.
Smaller things
- The comment over
_SHOW_PRAGMA(duckdb/_relation.py:76) says a look-alike letter is not a keyword, but no test pins it: with the(?a:...)groups dropped and no ASCII flag at all, every touched test passes. Apragma "ſhow"('t')case beside the Unicode space test would pin what the comment states. pytest.raises(Exception, match=...)attests/fast/spark/test_spark_writer.py:142accepts any exception; the temporary view test names(AnalysisException, ParseException), which fits here too.- A behaviour change the description implies with "double quotes are ordinary characters" but does not spell out for existing callers:
spark.table('"orders"')read the tableorderson 2.0.0 and the base and now raisesCatalogExceptionfor a table whose name holds the quotes;saveAsTable('"new2"')creatednew2and now creates a table named"new2"with the quotes;saveAsTable('"orders"')beside an existingordersraised "already exists" and now creates a second table. PySpark 4.3.0 raisesParseExceptionfor all three, so refusing a double quote in a table name would be closer to Spark than either behaviour. That is the maintainer's call; the head applies one rule to temporary views,spark.tableandsaveAsTable, which the last round accepted for temporary views. Listing it among the behaviour changes is enough for this pull request.
Outside findings not taken
- Codex, "existing double quoted names now refer to different objects": verified (the probe above) and listed as a behaviour change rather than a fix request, because PySpark itself refuses these names and the head reads all three Spark entry points by one rule.
Codex's other finding, the saveAsTable test under PySpark, is finding 1, confirmed by running the test against PySpark 4.3.0 rather than by reading Spark's bytecode.
What I verified
- Head 1d10a6e; range 5b8e7e4..1d10a6e (13 files, 864 added, 168 removed); since the last round a7a998a..1d10a6e (8 files, Python and tests only).
- Builds: the head (Release, the review script's build); the merge base (Release, unchanged since the last round, the merge base did not move); duckdb 2.0.0 from PyPI; a clone of the head rebuilt with the C++ mutation above.
- Probes on 2.0.0, the base and the head: the first round's eleven probes and the last round's six rerun (including the appender against relation writes,
con.tablewith tuples, Enum member parts, empty tuple parts insideBEGIN); new ones forspark.tableandsaveAsTableover 26 names,pragma "show"with Unicode spaces, case and the long s, the same names on real PySpark 4.3.0 (Temurin 17,local[2]), and the joined string weaker fix. - Tests on the head,
tests/fast/relational_api,tests/fast/api/test_table_name_resolution.py,tests/fast/api/test_seam.pyandtests/fast/sparkat-n 8: 4061 passed, 40 skipped, 13 xfailed in 63.61s. The new and changed tests on the merge base: 6 failed, 4 passed (the two tuple storage cases and the two Unicode space cases pass, as they should). Against PySpark 4.3.0 withUSE_ACTUAL_SPARK=true, the two touched Spark files: 2 failed, 106 passed, 1 skipped; the failures are the newsaveAsTabletest andtest_df_from_pandas, which fails on PySpark's pandas 3FutureWarningand is not touched here. - One area reverted at a time on the head, touched tests (1073 pass at the head):
SparkSession.tablewith the raw string, 2 failed;saveAsTablewith the raw string, 1 failed;spark.tablewith the joined parts, 0 failed (finding 2); the last round's pragma pattern, 2 failed; the pattern without ASCII groups, 0 failed (smaller thing); the empty temporary view check removed, 1 failed; a tuple taking the literal fall back, 1 failed; the C++ check by the default database, 4 failed. - Outside: Codex at xhigh, read only, on the delta: two findings, one taken as finding 1, one listed as a behaviour change. The code-review skill was not run for a delta of this size.
- CI on 1d10a6e at review time: 7 checks passed, 5 pending (two macOS seed jobs, the sdist and two manylinux sanity jobs), none failed.
What held
- Every name
createOrReplaceTempViewaccepts reads back throughspark.table, with and without a schema named by its first part;spark.tablekeeps readings.t,S.T,memory.s.t, an attachedf.ftandf.main.ft, and the CSV filepeople.csvas 2.0.0 did, and now reads`my table`and`orders`as PySpark does. - The file database check follows the database the name names, both directions, by string and by tuple, and leaves no view behind when it refuses.
- The
pragma "show"rewrite agrees with the engine on every spelling probed: ASCII, no-break and ideographic spaces, upper case, the long s, a trailing semicolon, surrounding spaces.
| # Backticks quote a name part, as in Spark, and spark.table reads the same spelling back. | ||
| df = spark.createDataFrame([(1,)], ["a"]) | ||
| spark.sql("CREATE SCHEMA IF NOT EXISTS s") | ||
| for name in ["`x.y`", "s.t", "`a``b`"]: |
There was a problem hiding this comment.
The new saveAsTable test fails against PySpark, which refuses the table names it creates.
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
for name in ["`x.y`", "s.t", "`a``b`"]:
df.write.saveAsTable(name)
assert [tuple(r) for r in spark.table(name).collect()] == [(1,)]The loop runs under USE_ACTUAL_SPARK=true too; only the catalog listing below it is guarded. Spark's session catalog checks a permanent table name against ([\w_]+) (SessionCatalog.validateName, called from createTable). With pyspark 4.3.0 and a Java 17 runtime:
USE_ACTUAL_SPARK=true pytest tests/fast/spark/test_spark_writer.py::TestRepeatedColumnNames
E pyspark.errors.exceptions.captured.AnalysisException: [INVALID_SCHEMA_OR_RELATION_NAME] `x`.`y` is not a valid name for tables/schemas. Valid names only contain alphabet characters, numbers and _. SQLSTATE: 42602
1 failed, 8 passed
PySpark also refuses saveAsTable("`ab")`` (INVALID_ATTRIBUTE_NAME_SYNTAX) and accepts s.tand ``ok_name``. The temporary view test passes against PySpark, since Spark does not check temporary view names this way. The shim creatingmain."x.y"is a leniency it may keep, but the test states it as Spark's behaviour, and so does the description's "a view or table made under ``x.y` `` reads back".
Keep Spark-valid names in the shared loop (s.t, `t1`, `s`.`t2`), move `x.y` and `ab`` underif not USE_ACTUAL_SPARK` (or assert PySpark's refusal there), and adjust that sentence of the description.
| def test_temp_view_name_is_read_with_spark_quoting(self, spark): | ||
| # Backticks quote a name part, as in Spark; double quotes are ordinary characters of the name. | ||
| df = spark.createDataFrame([(1,)], ["a"]) | ||
| for name in ["`v`", "`x.y`", "`a``b`", "`select`", "plain"]: |
There was a problem hiding this comment.
No test tells reading a backtick quoted dotted name as one part apart from passing the joined string, because no schema x exists here.
Severity: minor. Category: missing coverage. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
With SparkSession.table changed to self.conn.table(".".join(_name_parts(tableName))), every touched test passes (1073 passed): con.table("x.y") falls back to the literal name x.y while no schema x exists. Once a schema x exists, the joined form reads x.y as schema and table, and the head still reads the view:
== joined variant
no schema x: ok [Row(a=1)]
with schema x: CatalogException: Catalog Error: Table with name y does not exist!
== head
no schema x: ok [Row(a=1)]
with schema x: ok [Row(a=1)]
A spark.sql("CREATE SCHEMA IF NOT EXISTS x") before the loop (it runs under PySpark as well) makes the dotted name have to be read as one part.
…eniency and the long s rule apart
el-marvin
left a comment
There was a problem hiding this comment.
Changes requested.
I reviewed f8f26ab, one commit on top of the last round's head 1d10a6e that changes tests only: three test files, 32 lines added and 5 removed, and nothing under duckdb/ or src/ (git diff 1d10a6e f8f26ab on those two paths is empty). I ran it on the Release build of the head, the Release build of the merge base 5b8e7e4 and the released duckdb 2.0.0, and ran the Spark tests both against the shim and against PySpark 4.3.0 on a Java 17 runtime. The premise still holds: 2.0.0 and the merge base put create_view("s.v") in the default schema under the dotted name, the head puts it in schema s; the approach sits at the right layer, as the earlier rounds found. Every item of the last review is answered and checks out: the saveAsTable test passes against PySpark, the temporary view test now fails for the joined string reading, the long s test fails under the merge base's pattern, and the description's double quote sentence matches what the builds do. One thing must change before merging: under PySpark the two saveAsTable tests leave their tables in the warehouse directory, so the second run in the same checkout fails both. The fix is a teardown in the test file.
Since the last review
- Round 3, the
saveAsTabletest failed against PySpark: addressed on a first run. WithUSE_ACTUAL_SPARK=true,test_spark_writer.pyandtest_spark_dataframe.pygive 1 failed, 108 passed, 1 skipped (last round: 2 failed, 106 passed, 1 skipped); the one failure left istest_df_from_pandason PySpark's pandas 3FutureWarning, untouched here. The twoTestRepeatedColumnNamesclasses alone: 31 passed. The description now says PySpark refuses a dot or a backtick inside a permanent table name and that the shim keeps taking them. A second run is finding 1 below. - Round 3, the joined string reading passed the temporary view test: addressed.
self.conn.table(".".join(_name_parts(tableName)))in a clone of the head turnstest_temp_view_name_is_read_with_spark_quotingred, and the newtest_save_as_table_takes_a_dot_or_a_backtick_in_a_quoted_nameas well. - Round 3, no test for the long s rule: addressed. The merge base's pattern (
re.IGNORECASEwithout the ASCII groups) in a clone turns both cases oftest_pragma_show_with_a_long_s_is_refused_like_sqlred, and both fail on the merge base build, whererel.querydescribes the relation while SQL raises "Pragma Function with name ſhow does not exist!". - Round 3,
pytest.raises(Exception, ...): addressed, now(AnalysisException, ParseException); PySpark raisesParseExceptionfor"t.", the shimAnalysisException. - Round 3, the double quote change was not spelled out: addressed, and the sentence holds. On 2.0.0 and the merge base
spark.table('"orders"')readsorders; on the head it raisesCatalogException("Table with name "orders" does not exist!").saveAsTable('"new2"')madenew2before and makes"new2", quotes included, now. PySpark 4.3.0 raisesParseExceptionfor both. - Rounds 1 and 2: every earlier probe rerun on 2.0.0, the merge base and the head prints what it printed last round (one differs only in object addresses), and the earlier reverts still turn their tests red:
spark.tablewith the raw string (3 failed),saveAsTablewith the raw string (2), the round 2re.ASCIIpattern (2), no empty name check (1), the tuple taking the literal fall back (1).
Findings
- Under PySpark the two
saveAsTabletests leave their tables on disk, so every later run in the same checkout fails them (tests/fast/spark/test_spark_writer.py:139).
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
Make both tests leave nothing in the session or the warehouse underUSE_ACTUAL_SPARK=true, and check it with two runs of the class in a row. Evidence in the inline comment.
Smaller things
- The comment at
tests/fast/spark/test_spark_writer.py:149namesINVALID_SCHEMA_OR_RELATION_NAMEfor both names, and the description givesSessionCatalog.validateNameas the reason PySpark refuses "a dot or a backtick". PySpark 4.3.0 refuses`ab`` withINVALID_ATTRIBUTE_NAME_SYNTAX("Syntax error in the attribute name: ab"), from its name parser, and the PySpark branch of the test asserts only`x.y`. Both areAnalysisException, so the refusal holds; the named cause fits the dot only.
Outside findings not taken
- Codex, "the leftover
t1breaksTestSparkCatalog.test_list_tables": confirmed by running, and folded into finding 1; the order dependence itself is older, since temporary views from tests the merge base already had break that test the same way.
Codex's finding on the leftover tables is finding 1, confirmed by two consecutive runs against PySpark; its finding on the error class in the comment is the smaller thing above.
What I verified
- Head f8f26ab, merge base 5b8e7e4, delta 1d10a6e..f8f26ab:
tests/fast/relational_api/test_rapi_query_binding.py,tests/fast/spark/test_spark_dataframe.py,tests/fast/spark/test_spark_writer.py. - Against the shim on the head at
-n 8: the three changed files, 175 passed;tests/fast/relational_api,tests/fast/api/test_table_name_resolution.py,tests/fast/api/test_seam.pyandtests/fast/spark, 4064 passed, 40 skipped, 13 xfailed (last round 4061, plus the three new cases). - Against PySpark 4.3.0 (Temurin 17, master
local[4], fresh warehouse): the two Spark files 1 failed, 108 passed, 1 skipped; the two changed classes 31 passed. Leftovers: the writer class thentest_list_tablesin one run, 1 failed, 8 passed; the writer class again without deleting the warehouse, 2 failed, 6 passed; the merge base's tests of the two files thentest_list_tables, 2 failed (test_df_from_pandasandtest_list_tableson temporary views), 105 passed, warehouse left empty. - The five new and changed tests on the merge base build: 5 failed.
- Reverts in a clone of the head, over the 1076 tests last round's set covers: the head 1076 passed, 3 skipped; each revert listed above turns its tests red.
- Probes on 2.0.0, the merge base and the head: every probe of rounds 1 to 3 rerun; a new one for the long s, which gives
CatalogException"Pragma Function with name ſhow does not exist!" from SQL and fromrel.queryon the head for both spellings. - Codex at extra high effort, read only, on the delta. The code-review skill was not run for a tests only delta of this size.
- CI on f8f26ab at 23:33 UTC: 8 passed, 4 pending (macOS arm64 seed, the two manylinux sanity jobs, the sdist), none failed.
What held
- The long s test cannot pass for an unrelated reason: table
texists, both paths raise the same class with the same first line, and under the merge base's patternrel.querysucceeds, so the test fails at itspytest.raises. - With schema
xpresent, the joined reading turns`x.y`into schemax, tableyand misses the view, so the temporary view test now tells the two readings apart. - Against the shim each test gets its own connection, so the two writer tests do not see each other's tables, and both pin the full table listing.
- PySpark accepts
s.t,`t1`and`s`.`t2`and reads each back throughspark.table, refuses"t."withParseExceptionand`x.y`withAnalysisException, as the tests now say.
| # Backticks quote a name part, as in Spark, and spark.table reads the same spelling back. | ||
| df = spark.createDataFrame([(1,)], ["a"]) | ||
| spark.sql("CREATE SCHEMA IF NOT EXISTS s") | ||
| for name in ["s.t", "`t1`", "`s`.`t2`"]: |
There was a problem hiding this comment.
Under PySpark the two saveAsTable tests leave their tables on disk, so every later run in the same checkout fails them.
Severity: should fix. Category: correctness. Evidence: ran. Fix: root-cause. Origin: duckdb-python. Base: introduced.
PySpark's default session catalog keeps a managed table's files under spark-warehouse in the working directory, and the spark fixture in tests/conftest.py returns one shared session for the whole run. This test saves s.t, `t1` and `s`.`t2` and never drops them:
for name in ["s.t", "`t1`", "`s`.`t2`"]:
df.write.saveAsTable(name)and the next test's refused saveAsTable("`x.y`") (line 154) still writes its files before the catalog refuses the name. After one run of TestRepeatedColumnNames in this file with USE_ACTUAL_SPARK=true (PySpark 4.3.0, Java 17), the warehouse holds s.db/t, s.db/t2, t1 and x.y, each with parquet files. A second run in the same checkout:
SparkRuntimeException: [LOCATION_ALREADY_EXISTS] Cannot name the managed table as `spark_catalog`.`s`.`t`, as its associated location '.../spark-warehouse/s.db/t' already exists. Please pick a different table name, or remove the existing location first. SQLSTATE: 42710
SparkRuntimeException: [LOCATION_ALREADY_EXISTS] Cannot name the managed table as `spark_catalog`.`default`.`x.y`, as its associated location '.../spark-warehouse/x.y' already exists. ...
2 failed, 6 passed
The second one is not an AnalysisException, so the pytest.raises(AnalysisException) at line 153 does not catch it. The Spark tests the merge base already had leave the warehouse empty (this file and test_spark_dataframe.py under PySpark with the three name tests deselected: nothing under spark-warehouse afterwards), and spark-warehouse is not in .gitignore, so the leftovers also show in git status. Within one run the leftover t1 is listed by spark.catalog.listTables(): run after this class, TestSparkCatalog.test_list_tables fails on Table(name='t1', ..., tableType='MANAGED', isTemporary=False). That test is already order dependent through temporary views other tests leave (repeated_filter_view), so that part is not new.
The repository's idiom is the table_name fixture in tests/fast/spark/test_spark_session.py, which drops its table in teardown. Drop s.t, t1 and s.t2 when the test ends, and for the refused `x.y` remove the location Spark wrote under spark.conf.get("spark.sql.warehouse.dir"), or assert the refusal in a way that writes nothing. Fixed when two consecutive runs of the class with USE_ACTUAL_SPARK=true pass without deleting spark-warehouse, and no table directory is left in it.
el-marvin
left a comment
There was a problem hiding this comment.
This is ready to merge. I reviewed 67cce9b, one commit on top of round 4's f8f26ab that changes one test file, tests/fast/spark/test_spark_writer.py (30 lines in, 4 out); nothing under duckdb/ or src/ changed, so the package is the one round 4 reviewed. The premise still holds on the new build: 2.0.0 and the merge base put create_view("s.v") at memory.main."s.v", the head puts it at memory.s.v. The approach sits at the right layer, the engine's own QualifiedName::TryParse behind one seam entry, as settled in round 2. Round 4's one should fix is addressed and I confirmed it under real PySpark 4.3.0, not only in the shim. Nothing here blocks merging. The items below are nits the author may take or leave. Outside the code the maintainer still has to let the five pending CI jobs finish (none has failed) and accept the behaviour changes the description lists, which earlier rounds raised and did not block.
Since the last review
- Addressed: round 4's should fix, the two
saveAsTabletests leavings.t,t1,s.t2and the refusedx.yunderspark-warehouseso that a second run in the same checkout failed withLOCATION_ALREADY_EXISTS. WithUSE_ACTUAL_SPARK=true, a fresh warehouse, and nothing deleted between the runs,TestRepeatedColumnNamespassed 8 of 8 twice in a row, and after each run only the emptyspark-warehousedirectory remained, as the merge base's tests leave it. The writer class followed bytest_spark_catalog.py::TestSparkCatalog::test_list_tablespassed 9 of 9, where round 4 sawt1leak into the listing. With anassert Falseforced into both tests after their saves and refusals, the teardown still ran and the warehouse was empty after each of two runs. Removing only theshutil.rmtreeline brought back the failure (LOCATION_ALREADY_EXISTS ... default.x.yon the second run) and leftx.yanda`bbehind. So the location removal is what makes the second run clean, as the reply says, and PySpark writes the files for both refused names before refusing them, as the fixture's comment says. In shim mode the class passed twice with nospark-warehousecreated. - Addressed in part, with a correction to round 4: the cause of the
`ab`` refusal. The comment and the description now name each refusal with its error class, and the test asserts both classes (INVALID_SCHEMA_OR_RELATION_NAMEfor ``x.y``,INVALID_ATTRIBUTE_NAME_SYNTAXfor ``ab`), which PySpark 4.3.0 raises. My round 4 review said`ab`` was refused for a different cause than the one named. That was wrong: the Java stack trace shows both names are refused by the same check,SessionCatalog.validateName(called fromSessionCatalog.createTable, after the data is written). The error class differs only because, while building theINVALID_SCHEMA_OR_RELATION_NAMEmessage,QueryCompilationErrors.invalidNameForTableOrDatabaseErrorcallstoSQLId, which parses the name again withAttributeNameParser` and fails on the lone backtick. The part that remains, "from its name parser", is the first item below. - Rounds 1 to 3: every earlier probe gives the same output on the new build as in round 4, apart from object addresses and script paths. The revert variants give the same results:
spark_table_joined,spark_table_raw,save_as_table_rawandpragma_no_asciieach turn the tests that pin them red, and the head passes 1076 of the touched tests.
Findings
None.
Smaller things
- The comment at
tests/fast/spark/test_spark_writer.py:171and the description's Spark paragraph attribute the backtick refusal to Spark's "name parser". The name parsersaveAsTableuses accepts`ab`` (PySpark writes `` ab `` to disk), and the refusal isSessionCatalog.validateName, the same check as for the dot. Something like "Spark's `SessionCatalog.validateName` refuses a dot or a backtick in a table name after writing the data; for the backtick the error class is INVALID_ATTRIBUTE_NAME_SYNTAX, because building the message parses the name again" would say what happens. The error classes asserted are right either way. tests/fast/spark/test_spark_dataframe.py:941(added in round 4) creates schemaxand does not drop it. Under actual Spark this leaves schemaxin the shared session and an emptyx.dbunderspark-warehouse. It does no harm: a second run passes, and the writer fixture dropsxwhen it runs later. ADROP SCHEMA IF EXISTS xat the end, or the same fixture shape, would leave the warehouse as the merge base's tests leave it.
Outside findings not taken
- Codex (xhigh, read only, on the delta) reported no findings above the bar. It noted that other Spark tests still leave temporary views in the shared session. That is true and pre-existing (round 4 measured it at the merge base), and it is outside what this change claims.
What I verified
- Head 67cce9b; merge base 5b8e7e4 (unchanged); delta
git diff f8f26ab..67cce9btouches one file,tests/fast/spark/test_spark_writer.py, 30 insertions, 4 deletions. The pull request diff on GitHub carries the same hunks. - Builds: the head as a Release build by the review script (log ends
BUILD OK, tree HEAD 67cce9b); the merge base Release build from round 2; the 2.0.0 release in its own environment. - Shim mode:
TestRepeatedColumnNamestwice in a row, 8 passed each time, nospark-warehousecreated. Touched directories (tests/fast/relational_api,tests/fast/api/test_table_name_resolution.py,tests/fast/api/test_seam.py,tests/fast/spark) at 8 workers: 4064 passed, 40 skipped, 13 xfailed. - Real PySpark 4.3.0 on a Temurin 17 JDK (
USE_ACTUAL_SPARK=true, local master with 4 threads, pytest without xdist), from a fresh warehouse that was not cleaned between runs: the writer class twice, 8 passed and 8 passed, warehouse empty after each; the writer class thenTestSparkCatalog::test_list_tables, 9 passed;test_spark_writer.pyandtest_spark_dataframe.py, 1 failed (test_df_from_pandas, PySpark's pandas 3 FutureWarning, untouched and the same in round 4), 108 passed, 1 skipped; the dataframe quoting test followed by the writer class twice over the warehouse that run left (withx.db), 9 passed each time. - Teardown on failure: a copy of the file with
assert Falseafter the saves in the first test and after the dotted refusal in the second, run twice under real PySpark: 2 failed with the forced assertions both times, warehouse empty after each. A copy without theshutil.rmtreecall: the first run passed and leftx.yanda`b; the second failed withLOCATION_ALREADY_EXISTSfordefault.x.y. - The backtick refusal: a script saving
`ab`` under PySpark printed the Java framesAttributeNameParser.parseAttributeName<DataTypeErrorsBase.toSQLId<QueryCompilationErrors.invalidNameForTableOrDatabaseError<SessionCatalog.validateName(SessionCatalog.scala:325) <SessionCatalog.createTable(SessionCatalog.scala:577) <CreateDataSourceTableAsSelectCommand.run, and `` ab `` was on disk afterwards. - Earlier rounds: the probes of rounds 1 to 4 rerun on the release, the base and the head; the revert variants rerun in a clone at the head (results above).
- Outside review: Codex at xhigh effort, read only, on the delta; no question filed.
- CI on 67cce9b: 7 passed (submodule sanity, code quality, the Windows amd64 and arm64, manylinux x86_64 and aarch64, and macOS arm64 seeds), 5 pending (macOS x86_64 seed, sdist, two manylinux sanity jobs, the full API build with assertions), none failed.
What held
- The fixture asks for
spark, so its teardown runs while the session is alive, and it runs after a failing test body too. Tables go before their schemas, soDROP SCHEMA IF EXISTSwithoutCASCADEsucceeded every time, including after the forced failures. DROP TABLE IF EXISTSwith the backtick quoted names is accepted by both PySpark and the shim, whether the table exists or not.- The warehouse location is read from
spark.sql.warehouse.dir(afile:URL here) and resolves to the directory PySpark writes to. Only the two named leftovers are removed. - Order: the dataframe test before the writer tests, the writer tests before the catalog listing, and repeated runs over a dirty warehouse all passed.
Fixes #753.
rel.create_view(name)andrel.to_view(name)took the whole string as one identifier, so a view meant for anotherschema or database landed in the default schema under a dotted name:
rel.create("s.t")andrel.insert_into("s.t")have split a dotted name since #682, so the two view methods werethe odd ones out, and the only public way to put a view of a relation in another schema was
CREATE VIEW s.v AS {rel.sql_query()}, which loses whatcreate_viewgives: a view over a DataFrame stops workingonce the relation is collected, and a relation with parameters cannot be written as text at all. A tuple of parts
already worked through
create_view, because the C++ side builds a qualified name from it, but nothing said so andthe signature said
str.The name of a table or view is now read by one rule for
create,to_table,insert_into,create_view,to_view,con.table,con.view,duckdb.tableandduckdb.view, and the signatures saystr | tuple[str, ...].A string goes to the engine's own reader of a qualified name string (
QualifiedName::TryParse, the reader theappender and SQL's
pragma "show"already use), exposed through one seam entry: it is split at the dots outsidedouble quotes into catalog, schema path and name; a part in double quotes may hold dots, and a doubled quote inside
it is one quote; a trailing dot is dropped, as the appender has always read it; an empty part, an unterminated quote
or a quote in the wrong place is refused with the engine's own message. Every part is then one identifier taken
verbatim: no case folding, no trimming of whitespace, a keyword or a name with a space accepted quoted or not, and
a single quote or a backtick an ordinary character. These are the engine's rules for such a string, not the SQL
lexer's. A tuple of strings is the parts themselves, outermost first, each read by its text, so a
strsubclasssuch as an Enum member counts by its value, in Python and in the C++ side's reading of a string or a tuple alike;
rel.query(name, sql)reads its name the same way. The view or table lands whereCREATE VIEW <name>,CREATE TABLE <name>orINSERT INTO <name>would put it: the default or an attached database, a schema, a nestedschema path of any depth, with
USEin effect read as SQL reads it, and an unqualified name following the searchpath; whether a view of a Python object may be stored is decided by the database the name resolves to, so a frame
view into an attached file database is refused and one into an attached in-memory database is created.
con.tableand
con.viewcheck the connection first like the writers, read a tuple as the parts of the name, and keep theirtwo fall backs to one literal name for a string: when the reader refuses it, and when its first part names neither a
schema nor a database, which is what makes a file path such as
people.csvwork.The limit of three parts that #682 put on
createandinsert_intogoes. It existed because the client then handedcatalog, schema and name to the engine's
Relation::CreateRelandInsertRel, which take only those three; sincethe relational API moved onto statement text (7d1bcc7) the client renders the name into SQL, and the engine's
grammar, transformer and binder keep a schema path of any depth. So
rel.create("memory.extra.s.t")now createsthe table instead of raising a
NotImplementedExceptionthat pointed at SQL.Found alongside:
rel.create(("s", "t")),rel.insert_into(("s", "t")),con.table(("s", "t"))andcon.view(("s", "v"))concatenated the tuple's elements and wrote to, or read, a table calledstin the defaultschema, a silent wrong target; a tuple is now the parts of the name on the nine functions above (the appender still
takes a string only). An empty tuple, a non string part, or a name that is neither a string nor a tuple (
None, anint) raise
InvalidInputException; an empty part in a tuple raises the reader'sParserExceptionbefore anythingruns, so an open transaction stays usable, as it does for every string the reader refuses.
Behaviour changes, beyond the one the issue asks for:
create_viewandto_viewread a dotted string as a qualified name. A name whose first part is neither a schemanor a database used to create a view named with its dots in the default schema and now raises the engine's
CatalogException("Schema with name a does not exist!"). With a schemaxand an existing viewmain."x.y",create_view("x.y")used to replace that view and now createsx.ybeside it; such a view is reached with thequoted name
'"x.y"'or the one part tuple("x.y",). A quoted name is read as SQL reads it:create_view('"a.b"')used to create a view whose name kept the quotes and now createsa.b.create,insert_into,con.tableandcon.view: a quoteinside a bare part (
a"b"cbecame the identifierabc) and anything but a dot after a closing quote ("a"bbecame
ab). The writers now refuse them with the engine's messages;con.tableandcon.viewread them as oneliteral name, their fall back for a file path, so a reader and a writer may read such a string differently.
rel.query(name, 'pragma "show"(...)')describedabcbefore and raises the same parser error as SQL'spragma "show"now.rel.create("s.t.")writess.twherecreateandinsert_intorefused it, andcon.table("a.")readsawhere it read a table literally nameda.. Themessages for an unterminated quote and an empty quoted part are the engine's ("Unterminated quote in qualified
name!", "Zero-length delimited identifier in qualified name!") instead of the client's earlier wording.
create,to_tableorinsert_intonow runs; a missing schema inside an opentransaction aborts it as the same SQL does, where the old refusal left it usable.
path with every part quoted,
"s"."v"rather than"s.v".createOrReplaceTempView,spark.tableandsaveAsTablealike: backticks quote a part, and double quotes are ordinary characters of the name. Sospark.table("`my table`")reads the tablemy tableas PySpark does, and a view made under`x.y`readsback with
spark.table("`x.y`"); PySpark itself refuses a permanent table name with a dot(INVALID_SCHEMA_OR_RELATION_NAME, from
SessionCatalog.validateName) or a backtick (INVALID_ATTRIBUTE_NAME_SYNTAX,from its name parser), which the shim keeps taking for
saveAsTableas a leniency. The double quotes area change for existing callers:
spark.table('"orders"')read the tableordersbefore and now raisesCatalogExceptionunless a table named with the quotes exists, andsaveAsTable('"new"')creatednewand nowcreates a table named with the quotes; PySpark raises
ParseExceptionfor both spellings.createOrReplaceTempViewrefuses a multi part name as PySpark does, with
AnalysisException, error classTEMP_VIEW_NAME_TOO_MANY_NAME_PARTSand sqlState 428EK; a name Spark's reader refuses (x.,a..b, the emptyname) raises the shim's
INVALID_ATTRIBUTE_NAME_SYNTAXincreateOrReplaceTempViewandsaveAsTable, wheresaveAsTable("t.")createdt. It used to make a temporary view named with the dots in the default schema thatspark.table(name)could not read back.The change is in
duckdb/_relation.py(one name reader replaces_write_target; the string reader is the seam's;con.tableandcon.viewshare the tuple reader;query()reads its name and itspragma "show"argument thesame way, the pragma's keywords matched in ASCII letters only with the whitespace between them left to the engine's
rules), one new entry in
src/pyconnection/seam.cpp(_name_componentsoverQualifiedName::TryParse; andNameFromPartsreads astr, whole or as a tuple's part, by its text), its stub, the_seamwrapper, and theSpark shim's
createOrReplaceTempView,SparkSession.tableandsaveAsTablewith PySpark's error class. Theengine is untouched.
Tests, in
tests/fast/relational_api/test_rapi_write_qualified_name.py, compare each relation call with the samename in SQL on a second connection, and compare the full listing of views by database and quoted schema path, so a
view that landed under a literal dotted name fails the test: dotted and quoted names on
create_viewandto_viewin the default and an attached database, an ambiguous qualifier that is both a schema and a database, nested paths
of three to five parts on all five methods, replacing an existing nested view and refusing to without
replace, thetuple form with its errors (empty tuple, non string part, empty part,
Noneand an int) and astrEnum part onevery method and on the seam itself, the refused spellings and the empty part on every method with the engine's
messages, the reader's verbatim rules by the stored spelling (trailing dot dropped, whitespace and case kept, a bare
keyword; string and tuple forms; the appender and
insert_intoagreeing ons.t.), a DataFrame relation and aparameterized relation viewed under a dotted name after the relation is gone, a frame view into an attached file
database refused and into an attached in-memory database created, the held rows refusal,
USEin effect, thesignatures, and the transaction staying usable after every client side refusal, string and tuple, on every method.
The tests that pinned the three part refusal are replaced by the tests that pin the landing.
tests/fast/api/test_table_name_resolution.pypins the tuple form ofcon.table,con.view,duckdb.tableandduckdb.view(never joined, with a tablestbesides.t; never one literal name, with a table"a.b"and noschema
a), the closed connection refused before the name, the literal reading of the stray quote spellings and thetrailing dot;
tests/fast/relational_api/test_rapi_query_binding.pypinspragma "show"against SQL on the strayquotes, the trailing dot, a no-break or ideographic space between its words and a long s in the keyword, and a
strEnum name on
query();tests/fast/spark/test_spark_dataframe.pyandtest_spark_writer.pypin the Sparkrefusals, the error class and sqlState, the read back of backtick quoted names through
spark.tablewith a schemaof the same first part present, and, for the shim only, the dot and the backtick inside a quoted table name.