diff --git a/internal/mycli/dump_cyclic_mutations.go b/internal/mycli/dump_cyclic_mutations.go index 24695bc9..b0d6fffd 100644 --- a/internal/mycli/dump_cyclic_mutations.go +++ b/internal/mycli/dump_cyclic_mutations.go @@ -78,7 +78,7 @@ func dumpMutationFormatConfig() *spanvalue.FormatConfig { // decoding them. Validate scalars (also inside arrays) before falling // through to the shared literal policy, so malformed values fail before // any dump output. Do not format the decoded wrapper: retain wire fidelity. - return sqlLiteralFormatConfig().WithComplexPlugin(spanvalue.PluginFromNullable( + return spanvalue.LiteralFormatConfig().WithComplexPlugin(spanvalue.PluginFromNullable( func(spanvalue.NullableValue) (string, error) { return "", spanvalue.ErrFallthrough }, )).WithComplexPlugin(func(_ spanvalue.Formatter, value spanner.GenericColumnValue, _ bool) (string, error) { if value.Value == nil || value.Value.Kind == nil { diff --git a/internal/mycli/execute_sql.go b/internal/mycli/execute_sql.go index 52814205..8b7fc068 100644 --- a/internal/mycli/execute_sql.go +++ b/internal/mycli/execute_sql.go @@ -109,7 +109,7 @@ func prepareFormatConfig(sql string, sysVars *systemVariables, render queryRende switch vfm { case format.SQLLiteralValues: - render.Spanvalue = sqlLiteralFormatConfig() + render.Spanvalue = spanvalue.LiteralFormatConfig() if render.Export.SQLTableName == "" { detectedTableName, detectionErr := extractTableNameFromQuery(sql) if detectedTableName != "" { @@ -143,27 +143,6 @@ func prepareFormatConfig(sql string, sysVars *systemVariables, render queryRende } } -// sqlLiteralFormatConfig keeps SQL export and typed replay on the same policy. -func sqlLiteralFormatConfig() *spanvalue.FormatConfig { - // spanvalue v0.8.4 emits CAST(-0 AS FLOAT32), whose integer operand loses - // the sign on replay. Remove this bridge after adopting an upstream version - // that preserves FLOAT32 negative zero. A typed plugin also covers nested - // values without rewriting matching text inside STRING or JSON literals. - return spanvalue.LiteralFormatConfig().WithComplexPlugin(spanvalue.PluginForTypeCode( - sppb.TypeCode_FLOAT32, - func(_ spanvalue.Formatter, value spanner.GenericColumnValue, _ bool) (string, error) { - var f spanner.NullFloat32 - if err := value.Decode(&f); err != nil { - return "", err - } - if f.Valid && f.Float32 == 0 && math.Signbit(float64(f.Float32)) { - return "CAST(-0.0 AS FLOAT32)", nil - } - return "", spanvalue.ErrFallthrough - }, - )) -} - // newMetrics creates and initializes execution metrics from system variables. func newMetrics(sysVars *systemVariables) *metrics.ExecutionMetrics { m := &metrics.ExecutionMetrics{ diff --git a/internal/mycli/execute_sql_test.go b/internal/mycli/execute_sql_test.go index 1a9d319e..26f85c56 100644 --- a/internal/mycli/execute_sql_test.go +++ b/internal/mycli/execute_sql_test.go @@ -35,36 +35,23 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) +// TestSQLLiteralFormatConfigFloat32 checks mycli SQL-literal export and +// typed-replay formatter selection. The scalar/array/struct CAST(-0.0 AS +// FLOAT32) matrix is owned by spanvalue v0.9.0: +// - literal_test.go TestLiteralFloat32NegativeZero +// - writer/writer_test.go TestSQLInsertWriterFloat32NegativeZero func TestSQLLiteralFormatConfigFloat32(t *testing.T) { t.Parallel() - negativeZero := float32(math.Copysign(0, -1)) - structValue := spanner.GenericColumnValue{ - Type: &sppb.Type{Code: sppb.TypeCode_STRUCT, StructType: &sppb.StructType{ - Fields: []*sppb.StructType_Field{{Name: "F", Type: &sppb.Type{Code: sppb.TypeCode_FLOAT32}}}, - }}, - Value: structpb.NewListValue(&structpb.ListValue{Values: []*structpb.Value{structpb.NewNumberValue(math.Copysign(0, -1))}}), - } for _, tc := range []struct { name string value any - want string // Empty means preserve the upstream preset's existing bytes. + want string }{ - {"negative zero", negativeZero, "CAST(-0.0 AS FLOAT32)"}, - {"nullable negative zero", spanner.NullFloat32{Float32: negativeZero, Valid: true}, "CAST(-0.0 AS FLOAT32)"}, - {"positive zero", float32(0), ""}, - {"integer", float32(3), ""}, - {"finite", float32(1.5), ""}, - {"NaN", float32(math.NaN()), ""}, - {"positive infinity", float32(math.Inf(1)), ""}, - {"negative infinity", float32(math.Inf(-1)), ""}, - {"NULL", spanner.NullFloat32{}, ""}, - {"FLOAT64 negative zero", math.Copysign(0, -1), ""}, - {"STRING with cast text", "CAST(-0 AS FLOAT32)", ""}, - {"JSON with cast text", spanner.NullJSON{Value: map[string]any{"text": "CAST(-0 AS FLOAT32)"}, Valid: true}, ""}, - {"array", []spanner.NullFloat32{{Float32: negativeZero, Valid: true}, {Valid: true}, {}}, "[CAST(-0.0 AS FLOAT32), CAST(0 AS FLOAT32), NULL]"}, - {"empty array", []spanner.NullFloat32{}, ""}, - {"NULL array", []spanner.NullFloat32(nil), ""}, - {"struct field", structValue, "STRUCT(CAST(-0.0 AS FLOAT32))"}, + {"negative zero", float32(math.Copysign(0, -1)), "CAST(-0.0 AS FLOAT32)"}, + {"STRING with cast text", "CAST(-0 AS FLOAT32)", `"CAST(-0 AS FLOAT32)"`}, + {"JSON with cast text", spanner.NullJSON{Value: map[string]any{"text": "CAST(-0 AS FLOAT32)"}, Valid: true}, `JSON '{"text":"CAST(-0 AS FLOAT32)"}'`}, + {"empty array", []spanner.NullFloat32{}, "[]"}, + {"NULL array", []spanner.NullFloat32(nil), "NULL"}, } { t.Run(tc.name, func(t *testing.T) { row, err := spanner.NewRow([]string{"V"}, []any{tc.value}) @@ -75,13 +62,6 @@ func TestSQLLiteralFormatConfigFloat32(t *testing.T) { if err := row.Column(0, &value); err != nil { t.Fatal(err) } - want := tc.want - if want == "" { - want, err = spanvalue.LiteralFormatConfig().FormatToplevelColumn(value) - if err != nil { - t.Fatal(err) - } - } for _, mode := range []enums.DisplayMode{enums.DisplayModeSQLInsert, enums.DisplayModeSQLInsertOrIgnore, enums.DisplayModeSQLInsertOrUpdate} { sv := newSystemVariablesWithDefaults() sv.Display.CLIFormat = mode @@ -100,8 +80,8 @@ func TestSQLLiteralFormatConfigFloat32(t *testing.T) { if err != nil { t.Fatal(err) } - if got != want { - t.Errorf("%s/%s got %s, want %s", mode, name, got, want) + if got != tc.want { + t.Errorf("%s/%s got %s, want %s", mode, name, got, tc.want) } } } @@ -120,8 +100,9 @@ func TestSQLLiteralFormatConfigInvalidFloat32(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := config.FormatToplevelColumn(value); err == nil { - t.Fatal("expected invalid FLOAT32 error") + _, err = config.FormatToplevelColumn(value) + if !errors.Is(err, spanvalue.ErrMalformedWire) || !strings.Contains(err.Error(), `"not a float"`) { + t.Fatalf("invalid FLOAT32 error = %v, want ErrMalformedWire mentioning the payload", err) } } @@ -199,8 +180,9 @@ func TestSQLLiteralFormatConfigInvalidFloat32Streaming(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := render.Spanvalue.FormatToplevelColumn(value); err == nil { - t.Fatal("expected invalid FLOAT32 error from sqlLiteralFormatConfig") + _, err = render.Spanvalue.FormatToplevelColumn(value) + if !errors.Is(err, spanvalue.ErrMalformedWire) || !strings.Contains(err.Error(), `"not a float"`) { + t.Fatalf("invalid FLOAT32 streaming error = %v, want ErrMalformedWire mentioning the payload", err) } } diff --git a/internal/mycli/spanvalue_streaming_test.go b/internal/mycli/spanvalue_streaming_test.go index d9bb0817..18d1cc7d 100644 --- a/internal/mycli/spanvalue_streaming_test.go +++ b/internal/mycli/spanvalue_streaming_test.go @@ -68,7 +68,7 @@ func TestNewSpanvalueRowIteratorWriterForContract(t *testing.T) { t.Run("SQL export missing table name is handled with exact error text", func(t *testing.T) { t.Parallel() var buf bytes.Buffer - w, handled, err := newSpanvalueRowIteratorWriterFor(&buf, exportWriterOptions{CLIFormat: enums.DisplayModeSQLInsert}, sqlLiteralFormatConfig()) + w, handled, err := newSpanvalueRowIteratorWriterFor(&buf, exportWriterOptions{CLIFormat: enums.DisplayModeSQLInsert}, spanvalue.LiteralFormatConfig()) if w != nil || !handled { t.Fatalf("got writer=%v handled=%v, want nil, true", w, handled) } @@ -87,7 +87,7 @@ func TestNewSpanvalueRowIteratorWriterForContract(t *testing.T) { CLIFormat: enums.DisplayModeSQLInsert, SQLTableName: "Items", SQLBatchSize: -1, - }, sqlLiteralFormatConfig()) + }, spanvalue.LiteralFormatConfig()) if !handled { t.Fatal("negative batch size should be handled") } @@ -104,7 +104,7 @@ func TestNewSpanvalueRowIteratorWriterForContract(t *testing.T) { CLIFormat: enums.DisplayModeSQLInsert, SQLTableName: "Items", SQLBatchSize: 10001, - }, sqlLiteralFormatConfig()) + }, spanvalue.LiteralFormatConfig()) if !handled { t.Fatal("oversized batch size should be handled") } @@ -148,25 +148,25 @@ func TestNewSpanvalueRowIteratorWriterForOutput(t *testing.T) { { name: "SQL INSERT uses table name and GoogleSQL string quotes", opts: exportWriterOptions{CLIFormat: enums.DisplayModeSQLInsert, SQLTableName: "Items"}, - fc: sqlLiteralFormatConfig(), + fc: spanvalue.LiteralFormatConfig(), want: "INSERT INTO `Items` (`id`, `name`) VALUES (1, \"Alice\");\nINSERT INTO `Items` (`id`, `name`) VALUES (2, \"Bob\");\nINSERT INTO `Items` (`id`, `name`) VALUES (3, \"Carol\");\n", }, { name: "SQL INSERT OR IGNORE", opts: exportWriterOptions{CLIFormat: enums.DisplayModeSQLInsertOrIgnore, SQLTableName: "Items"}, - fc: sqlLiteralFormatConfig(), + fc: spanvalue.LiteralFormatConfig(), want: "INSERT OR IGNORE INTO `Items` (`id`, `name`) VALUES (1, \"Alice\");\nINSERT OR IGNORE INTO `Items` (`id`, `name`) VALUES (2, \"Bob\");\nINSERT OR IGNORE INTO `Items` (`id`, `name`) VALUES (3, \"Carol\");\n", }, { name: "SQL INSERT OR UPDATE", opts: exportWriterOptions{CLIFormat: enums.DisplayModeSQLInsertOrUpdate, SQLTableName: "Items"}, - fc: sqlLiteralFormatConfig(), + fc: spanvalue.LiteralFormatConfig(), want: "INSERT OR UPDATE INTO `Items` (`id`, `name`) VALUES (1, \"Alice\");\nINSERT OR UPDATE INTO `Items` (`id`, `name`) VALUES (2, \"Bob\");\nINSERT OR UPDATE INTO `Items` (`id`, `name`) VALUES (3, \"Carol\");\n", }, { name: "SQL batch size groups VALUES lists", opts: exportWriterOptions{CLIFormat: enums.DisplayModeSQLInsert, SQLTableName: "Items", SQLBatchSize: 2}, - fc: sqlLiteralFormatConfig(), + fc: spanvalue.LiteralFormatConfig(), want: "INSERT INTO `Items` (`id`, `name`) VALUES\n (1, \"Alice\"),\n (2, \"Bob\");\nINSERT INTO `Items` (`id`, `name`) VALUES\n (3, \"Carol\");\n", }, { @@ -176,7 +176,7 @@ func TestNewSpanvalueRowIteratorWriterForOutput(t *testing.T) { SQLTableName: "Items", DatabaseDialect: databasepb.DatabaseDialect_POSTGRESQL, }, - fc: sqlLiteralFormatConfig(), + fc: spanvalue.LiteralFormatConfig(), want: "INSERT INTO \"Items\" (\"id\", \"name\") VALUES (1, \"Alice\");\nINSERT INTO \"Items\" (\"id\", \"name\") VALUES (2, \"Bob\");\nINSERT INTO \"Items\" (\"id\", \"name\") VALUES (3, \"Carol\");\n", }, } diff --git a/internal/mycli/typed_rows.go b/internal/mycli/typed_rows.go index 27a98b4b..ddb571e6 100644 --- a/internal/mycli/typed_rows.go +++ b/internal/mycli/typed_rows.go @@ -36,7 +36,7 @@ func typedReplayFormatConfig(sysVars *systemVariables) (*spanvalue.FormatConfig, vfm := format.ValueFormatModeFor(format.Mode(sysVars.Display.CLIFormat.String())) switch vfm { case format.SQLLiteralValues: - return sqlLiteralFormatConfig(), vfm, nil + return spanvalue.LiteralFormatConfig(), vfm, nil case format.JSONValues: return decoder.JSONFormatConfig(), vfm, nil default: