@@ -1347,6 +1347,134 @@ jobs:
13471347 open(path, 'w').write(src)
13481348 print('patched UPDATE multi-table to rowid-IN subquery')
13491349
1350+ # 17. For single-row INSERT ... VALUES without DEFAULT tokens,
1351+ # inline each value expression directly into the wrapping
1352+ # SELECT instead of using a FROM (VALUES ...) subquery with
1353+ # columnN aliases. Turso can't resolve the outer columnN
1354+ # reference through an INSERT...SELECT...FROM subquery even
1355+ # when the aliases are explicit. The inline form bypasses
1356+ # the columnN indirection entirely and is equivalent SQL on
1357+ # real SQLite.
1358+ src = open(path).read()
1359+
1360+ # Step 1: intercept select_list initialization for single-row
1361+ # VALUES without DEFAULT, set select_list to pre-translated
1362+ # expressions (wrapped in parens so CAST composes correctly),
1363+ # and set $inline_values = true.
1364+ old_init = (
1365+ "\t\t} else {\n"
1366+ "\t\t\t// When inserting from a VALUES list, SQLite uses a \"columnN\" naming.\n"
1367+ "\t\t\t// This also applies to the SET syntax, which is converted to VALUES.\n"
1368+ "\t\t\tforeach ( array_keys( $insert_list ) as $position ) {\n"
1369+ "\t\t\t\t$select_list[] = 'column' . ( $position + 1 );\n"
1370+ "\t\t\t}\n"
1371+ "\t\t}"
1372+ )
1373+ new_init = (
1374+ "\t\t} else {\n"
1375+ "\t\t\t$inline_values = false;\n"
1376+ "\t\t\tif ( 'insertFromConstructor' === $node->rule_name ) {\n"
1377+ "\t\t\t\t$insert_values = $node->get_first_child_node( 'insertValues' );\n"
1378+ "\t\t\t\t$value_list = $insert_values->get_first_child_node( 'valueList' );\n"
1379+ "\t\t\t\t$values_nodes = $value_list->get_child_nodes( 'values' );\n"
1380+ "\t\t\t\t$has_default = count( $values_nodes ) === 1\n"
1381+ "\t\t\t\t\t&& count( $values_nodes[0]->get_child_nodes( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) > 0;\n"
1382+ "\t\t\t\tif ( count( $values_nodes ) === 1 && ! $has_default ) {\n"
1383+ "\t\t\t\t\t$inline_values = true;\n"
1384+ "\t\t\t\t\tforeach ( $values_nodes[0]->get_child_nodes( 'expr' ) as $expr_node ) {\n"
1385+ "\t\t\t\t\t\t$select_list[] = '(' . $this->translate( $expr_node ) . ')';\n"
1386+ "\t\t\t\t\t}\n"
1387+ "\t\t\t\t}\n"
1388+ "\t\t\t}\n"
1389+ "\t\t\tif ( ! $inline_values ) {\n"
1390+ "\t\t\t\t// When inserting from a VALUES list, SQLite uses a \"columnN\" naming.\n"
1391+ "\t\t\t\t// This also applies to the SET syntax, which is converted to VALUES.\n"
1392+ "\t\t\t\tforeach ( array_keys( $insert_list ) as $position ) {\n"
1393+ "\t\t\t\t\t$select_list[] = 'column' . ( $position + 1 );\n"
1394+ "\t\t\t\t}\n"
1395+ "\t\t\t}\n"
1396+ "\t\t}"
1397+ )
1398+ assert old_init in src, 'select_list else-branch not found'
1399+ src = src.replace(old_init, new_init, 1)
1400+
1401+ # Step 2: CAST loop — use raw expression when $inline_values.
1402+ old_cast = (
1403+ "\t\t\t\t$position = array_search( $column['COLUMN_NAME'], $insert_list, true );\n"
1404+ "\t\t\t\t$identifier = $this->quote_sqlite_identifier( $select_list[ $position ] );\n"
1405+ "\t\t\t\t$value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier );\n"
1406+ )
1407+ new_cast = (
1408+ "\t\t\t\t$position = array_search( $column['COLUMN_NAME'], $insert_list, true );\n"
1409+ "\t\t\t\t$identifier = ( isset( $inline_values ) && $inline_values )\n"
1410+ "\t\t\t\t\t? $select_list[ $position ]\n"
1411+ "\t\t\t\t\t: $this->quote_sqlite_identifier( $select_list[ $position ] );\n"
1412+ "\t\t\t\t$value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier );\n"
1413+ )
1414+ assert old_cast in src, 'CAST identifier lookup not found'
1415+ src = src.replace(old_cast, new_cast, 1)
1416+
1417+ # Step 3: FROM wrapping — skip for inline case.
1418+ old_from_block = (
1419+ "\t\t// Wrap the original insert VALUES, SELECT, or SET list in a FROM clause.\n"
1420+ "\t\tif ( 'insertFromConstructor' === $node->rule_name ) {\n"
1421+ "\t\t\t// VALUES (...)\n"
1422+ "\t\t\t$insert_values = $node->get_first_child_node( 'insertValues' );\n"
1423+ "\t\t\t$from = $this->translate( $insert_values );\n"
1424+ )
1425+ new_from_block = (
1426+ "\t\t// Wrap the original insert VALUES, SELECT, or SET list in a FROM clause.\n"
1427+ "\t\tif ( isset( $inline_values ) && $inline_values ) {\n"
1428+ "\t\t\t$from = null;\n"
1429+ "\t\t} elseif ( 'insertFromConstructor' === $node->rule_name ) {\n"
1430+ "\t\t\t// VALUES (...)\n"
1431+ "\t\t\t$insert_values = $node->get_first_child_node( 'insertValues' );\n"
1432+ "\t\t\t$from = $this->translate( $insert_values );\n"
1433+ )
1434+ assert old_from_block in src, 'FROM wrapping block not found'
1435+ src = src.replace(old_from_block, new_from_block, 1)
1436+
1437+ # Step 4: final emission — omit FROM when $from is null.
1438+ old_emit = "\t\t$fragment .= ' FROM (' . $from . ') WHERE true';"
1439+ new_emit = (
1440+ "\t\tif ( null === $from ) {\n"
1441+ "\t\t\t$fragment .= ' WHERE true';\n"
1442+ "\t\t} else {\n"
1443+ "\t\t\t$fragment .= ' FROM (' . $from . ') WHERE true';\n"
1444+ "\t\t}"
1445+ )
1446+ assert old_emit in src, 'final FROM emission not found'
1447+ src = src.replace(old_emit, new_emit, 1)
1448+ open(path, 'w').write(src)
1449+ print('patched single-row VALUES to inline into outer SELECT (no FROM subquery)')
1450+
1451+ # Update Translation_Tests INSERT/REPLACE expectations for the inline form.
1452+ import re
1453+ path_tt = 'tests/WP_SQLite_Driver_Translation_Tests.php'
1454+ src_tt = open(path_tt).read()
1455+ srow_pat = re.compile(
1456+ r"SELECT (?P<cols>`column\d+`(?:, `column\d+`)*) FROM \(VALUES \( (?P<vals>[^()]+) \)\) WHERE true"
1457+ )
1458+ def rewrite_single_row(m):
1459+ cols = re.findall(r"`column\d+`", m.group('cols'))
1460+ vals = [v.strip() for v in m.group('vals').split(',')]
1461+ if len(cols) != len(vals):
1462+ return m.group(0)
1463+ return "SELECT " + ", ".join(f"({v})" for v in vals) + " WHERE true"
1464+ src_tt2, n1 = srow_pat.subn(rewrite_single_row, src_tt)
1465+ scast_pat = re.compile(
1466+ r"SELECT (?P<casts>CAST\(`column\d+` AS \w+\)(?:, CAST\(`column\d+` AS \w+\))*) FROM \(VALUES \( (?P<vals>[^()]+) \)\) WHERE true"
1467+ )
1468+ def rewrite_cast_row(m):
1469+ types = re.findall(r"CAST\(`column\d+` AS (\w+)\)", m.group('casts'))
1470+ vals = [v.strip() for v in m.group('vals').split(',')]
1471+ if len(types) != len(vals):
1472+ return m.group(0)
1473+ return "SELECT " + ", ".join(f"CAST(({v}) AS {t})" for v, t in zip(vals, types)) + " WHERE true"
1474+ src_tt2, n2 = scast_pat.subn(rewrite_cast_row, src_tt2)
1475+ open(path_tt, 'w').write(src_tt2)
1476+ print(f'patched Translation_Tests VALUES expectations: {n1} plain + {n2} CAST')
1477+
13501478 # 14. Update Translation_Tests::testHexadecimalLiterals to match
13511479 # the hex-literal alias force patch (which needs to stay so
13521480 # Turso doesn't mangle x'417a' into 17a' at runtime).
0 commit comments