diff --git a/postgres/parser/types/types.go b/postgres/parser/types/types.go index 19898e05b0..421d35fab0 100644 --- a/postgres/parser/types/types.go +++ b/postgres/parser/types/types.go @@ -2506,6 +2506,24 @@ func init() { typNameLiterals[name] = t } } + for name, t := range sqlStandardTypeAliases { + if _, ok := typNameLiterals[name]; !ok { + typNameLiterals[name] = t + } + } +} + +// typeNameWhitespace matches any run of whitespace within a type name. +var typeNameWhitespace = regexp.MustCompile(`\s+`) + +// normalizeTypeName collapses each run of whitespace within the given type name to a single space, so +// that the multi-word SQL standard type names (`double precision`, `timestamp\n\twith time zone`, etc.) +// match their entries in typNameLiterals regardless of how they were spelled in the original statement. +func normalizeTypeName(name string) string { + if !strings.ContainsAny(name, " \t\n\r\f\v") { + return name + } + return typeNameWhitespace.ReplaceAllString(strings.TrimSpace(name), " ") } // TypeForNonKeywordTypeName returns the column type for the string name of a @@ -2515,6 +2533,7 @@ func init() { // -1 if the type is known in postgres. // >0 for a github issue number. func TypeForNonKeywordTypeName(name string) (*T, bool, int) { + name = normalizeTypeName(name) t, ok := typNameLiterals[name] if ok { return t, ok, 0 @@ -2581,6 +2600,46 @@ var unreservedTypeTokens = map[string]*T{ "uuid": Uuid, } +// sqlStandardTypeAliases contains the SQL standard type names that Postgres accepts as aliases for its own +// type names (https://www.postgresql.org/docs/15/datatype.html#DATATYPE-TABLE). The grammar has tokens for +// all of them, so this map serves only the callers that resolve a name arriving as a bare string rather than +// as parser input: a schema-qualified name such as `pg_catalog.boolean`, or a name from a PL/pgSQL datum +// list. Multi-word names are keyed with single spaces, which is what TypeForNonKeywordTypeName normalizes to. +// +// `char` is absent because `pg_catalog.char` names the internal one-byte "char" type that OidToType already +// registers, rather than the bpchar that a bare CHAR keyword names. +var sqlStandardTypeAliases = map[string]*T{ + "bigint": Int, + "boolean": Bool, + "dec": Decimal, + "decimal": Decimal, + "int": Int4, + "real": Float4, + "smallint": Int2, + + // FLOAT with no precision is FLOAT8; only FLOAT(1) through FLOAT(24) are FLOAT4, and the precision is applied + // by the caller rather than by this lookup. + "float": Float, + "double precision": Float, + + "character": typeBpChar, + "national char": typeBpChar, + "national character": typeBpChar, + "nchar": typeBpChar, + "char varying": VarChar, + "character varying": VarChar, + "national char varying": VarChar, + "national character varying": VarChar, + "nchar varying": VarChar, + + "bit varying": VarBit, + + "time without time zone": Time, + "time with time zone": TimeTZ, + "timestamp without time zone": Timestamp, + "timestamp with time zone": TimestampTZ, +} + // The following map must include all types predefined in PostgreSQL // that are also not yet defined in CockroachDB and link them to // github issues. It is also possible, but not necessary, to include diff --git a/server/plpgsql/interpreter_logic.go b/server/plpgsql/interpreter_logic.go index eefb1d4314..d11c1f542d 100644 --- a/server/plpgsql/interpreter_logic.go +++ b/server/plpgsql/interpreter_logic.go @@ -141,15 +141,19 @@ func call(ctx *sql.Context, iFunc InterpretedFunction, stack InterpreterStack) ( parts := strings.Split(typeName, ".") schemaName = parts[0] typeName = parts[1] - // Check the NonKeyword type names to see if we're looking at - // an alias of a type if we're in the pg_catalog schema. - // Skip array types (names starting with "_") since their internal - // lookup key uses the "_typename" form, not the "typename[]" form - // that TypeForNonKeywordTypeName returns. - if schemaName == "pg_catalog" && !strings.HasPrefix(typeName, "_") { - typ, ok, _ := types.TypeForNonKeywordTypeName(typeName) + // Within pg_catalog the name may be an alias, so map it to the name the type is + // registered under (`pg_catalog.boolean` to `pg_catalog.bool`). An array type arrives + // as "_typename", which is also the form its lookup key takes, so it is the element + // name that gets mapped. + if schemaName == "pg_catalog" { + arrayPrefix := "" + elementName := typeName + if strings.HasPrefix(typeName, "_") { + arrayPrefix, elementName = "_", typeName[1:] + } + typ, ok, _ := types.TypeForNonKeywordTypeName(elementName) if ok && typ != nil { - typeName = typ.Name() + typeName = arrayPrefix + typ.PGName() } } } diff --git a/testing/go/plpgsql_type_alias_test.go b/testing/go/plpgsql_type_alias_test.go new file mode 100644 index 0000000000..57ff16662a --- /dev/null +++ b/testing/go/plpgsql_type_alias_test.go @@ -0,0 +1,202 @@ +// Copyright 2026 Dolthub, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package _go + +import ( + "fmt" + "testing" + + "github.com/dolthub/go-mysql-server/sql" +) + +// typeAliasCase declares a single PL/pgSQL variable of the type named by TypeName and checks what the variable +// actually holds, so that a type name resolving to the wrong type is caught rather than just a type name that +// fails to resolve at all. +type typeAliasCase struct { + // FuncName names the generated function, and so also names the assertion. + FuncName string + // TypeName is written verbatim into the DECLARE, e.g. "pg_catalog.boolean". + TypeName string + // Literal is the DECLARE's default value, written verbatim. When empty the variable is declared without one. + Literal string + // ValueExpr is the expression the function returns, and defaults to "b::text". + ValueExpr string + // ExpectedType is the expected pg_typeof of the declared variable. When empty, pg_typeof is not checked, + // which is necessary for the types that pg_typeof does not report yet. + ExpectedType string + // ExpectedValue is the expected result of ValueExpr. + ExpectedValue string +} + +func (c typeAliasCase) statement() string { + declaration := fmt.Sprintf("b %s", c.TypeName) + if c.Literal != "" { + declaration = fmt.Sprintf("%s := %s", declaration, c.Literal) + } + value := c.ValueExpr + if value == "" { + value = "b::text" + } + if c.ExpectedType != "" { + value = fmt.Sprintf("pg_typeof(b)::text || '|' || %s", value) + } + return fmt.Sprintf(`CREATE FUNCTION %s() RETURNS text AS $$ DECLARE %s; BEGIN RETURN %s; END; $$ LANGUAGE plpgsql;`, + c.FuncName, declaration, value) +} + +func (c typeAliasCase) expected() string { + if c.ExpectedType != "" { + return c.ExpectedType + "|" + c.ExpectedValue + } + return c.ExpectedValue +} + +// typeAliasScript builds a ScriptTest that creates one function per case and asserts on what each one returns. +func typeAliasScript(name string, cases []typeAliasCase) ScriptTest { + script := ScriptTest{Name: name} + for _, c := range cases { + script.SetUpScript = append(script.SetUpScript, c.statement()) + script.Assertions = append(script.Assertions, ScriptTestAssertion{ + Query: fmt.Sprintf("SELECT %s();", c.FuncName), + Expected: []sql.Row{{c.expected()}}, + }) + } + return script +} + +// TestPlpgsqlDeclareTypeAliases tests that a PL/pgSQL variable may be declared with any of the SQL standard type +// names that Postgres accepts as aliases for its own type names, qualified with the pg_catalog schema. +// +// The grammar resolves these names when they are written bare, but a schema-qualified name is handed to the +// interpreter as text (pg_query_go reports a declared type as the text that was written), so it can only be +// resolved by looking the name up in the parser's type name table. +func TestPlpgsqlDeclareTypeAliases(t *testing.T) { + RunScripts(t, []ScriptTest{ + typeAliasScript("schema-qualified numeric type aliases", []typeAliasCase{ + {FuncName: "alias_boolean", TypeName: "pg_catalog.boolean", Literal: "true", + ExpectedType: "boolean", ExpectedValue: "true"}, + {FuncName: "alias_int", TypeName: "pg_catalog.int", Literal: "11", + ExpectedType: "integer", ExpectedValue: "11"}, + {FuncName: "alias_integer", TypeName: "pg_catalog.integer", Literal: "12", + ExpectedType: "integer", ExpectedValue: "12"}, + {FuncName: "alias_bigint", TypeName: "pg_catalog.bigint", Literal: "9223372036854775807", + ExpectedType: "bigint", ExpectedValue: "9223372036854775807"}, + {FuncName: "alias_smallint", TypeName: "pg_catalog.smallint", Literal: "32767", + ExpectedType: "smallint", ExpectedValue: "32767"}, + {FuncName: "alias_decimal", TypeName: "pg_catalog.decimal", Literal: "'1.25'", + ExpectedType: "numeric", ExpectedValue: "1.25"}, + {FuncName: "alias_dec", TypeName: "pg_catalog.dec", Literal: "'2.50'", + ExpectedType: "numeric", ExpectedValue: "2.50"}, + {FuncName: "alias_real", TypeName: "pg_catalog.real", Literal: "'1.5'", + ExpectedType: "real", ExpectedValue: "1.5"}, + {FuncName: "alias_double_precision", TypeName: "pg_catalog.double precision", Literal: "'2.5'", + ExpectedType: "double precision", ExpectedValue: "2.5"}, + // FLOAT with no precision is FLOAT8, the same as DOUBLE PRECISION. + {FuncName: "alias_float", TypeName: "pg_catalog.float", Literal: "'3.5'", + ExpectedType: "double precision", ExpectedValue: "3.5"}, + }), + typeAliasScript("schema-qualified character type aliases", []typeAliasCase{ + {FuncName: "alias_character_varying", TypeName: "pg_catalog.character varying", Literal: "'abc'", + ExpectedType: "character varying", ExpectedValue: "abc"}, + {FuncName: "alias_char_varying", TypeName: "pg_catalog.char varying", Literal: "'def'", + ExpectedType: "character varying", ExpectedValue: "def"}, + {FuncName: "alias_national_character_varying", TypeName: "pg_catalog.national character varying", Literal: "'ghi'", + ExpectedType: "character varying", ExpectedValue: "ghi"}, + {FuncName: "alias_national_char_varying", TypeName: "pg_catalog.national char varying", Literal: "'jkl'", + ExpectedType: "character varying", ExpectedValue: "jkl"}, + {FuncName: "alias_nchar_varying", TypeName: "pg_catalog.nchar varying", Literal: "'mno'", + ExpectedType: "character varying", ExpectedValue: "mno"}, + {FuncName: "alias_character", TypeName: "pg_catalog.character", Literal: "'pqr'", + ExpectedType: "character", ExpectedValue: "pqr"}, + {FuncName: "alias_national_character", TypeName: "pg_catalog.national character", Literal: "'stu'", + ExpectedType: "character", ExpectedValue: "stu"}, + {FuncName: "alias_nchar", TypeName: "pg_catalog.nchar", Literal: "'vwx'", + ExpectedType: "character", ExpectedValue: "vwx"}, + }), + typeAliasScript("schema-qualified date/time type aliases", []typeAliasCase{ + {FuncName: "alias_timestamp_without_tz", TypeName: "pg_catalog.timestamp without time zone", + Literal: "'2024-01-02 03:04:05'", ExpectedType: "timestamp without time zone", + ExpectedValue: "2024-01-02 03:04:05"}, + // The value is rendered in UTC rather than in the session's time zone so that the expected result does + // not depend on where the test runs. + {FuncName: "alias_timestamp_with_tz", TypeName: "pg_catalog.timestamp with time zone", + Literal: "'2024-01-02 03:04:05+00'", ValueExpr: "(b AT TIME ZONE 'UTC')::text", + ExpectedType: "timestamp with time zone", ExpectedValue: "2024-01-02 03:04:05"}, + // pg_typeof does not report the time types yet, so these only check the round-tripped value. + {FuncName: "alias_time_without_tz", TypeName: "pg_catalog.time without time zone", + Literal: "'03:04:05'", ExpectedValue: "03:04:05"}, + {FuncName: "alias_time_with_tz", TypeName: "pg_catalog.time with time zone", + Literal: "'03:04:05+00'", ExpectedValue: "03:04:05+00"}, + }), + typeAliasScript("schema-qualified bit string type alias", []typeAliasCase{ + // A PL/pgSQL variable of a bit string type cannot hold a value yet (assigning one fails the same way + // for the canonical `varbit` spelling), so this only checks that the name resolves to a usable type. + {FuncName: "alias_bit_varying", TypeName: "pg_catalog.bit varying", + ValueExpr: "(b IS NULL)::text", ExpectedValue: "true"}, + }), + // pg_query_go reports a declared type as the text that was written, without normalizing the whitespace + // within a multi-word name, so the lookup has to normalize it. + typeAliasScript("multi-word type aliases with irregular whitespace", []typeAliasCase{ + {FuncName: "alias_double_precision_spaces", TypeName: "pg_catalog.double precision", Literal: "'4.5'", + ExpectedType: "double precision", ExpectedValue: "4.5"}, + {FuncName: "alias_timestamp_tz_newline", TypeName: "pg_catalog.timestamp\nwith\ttime zone", + Literal: "'2024-01-02 03:04:05+00'", ValueExpr: "(b AT TIME ZONE 'UTC')::text", + ExpectedType: "timestamp with time zone", ExpectedValue: "2024-01-02 03:04:05"}, + }), + // Not aliases, but they resolve through the same lookup, and each of them is a name whose + // registered spelling differs from the one being declared. + typeAliasScript("names whose registered spelling differs", []typeAliasCase{ + {FuncName: "canonical_bytea", TypeName: "pg_catalog.bytea", Literal: `'\x0102'`, + ExpectedValue: `\x0102`}, + {FuncName: "unqualified_bytea", TypeName: "bytea", Literal: `'\x0304'`, + ExpectedValue: `\x0304`}, + {FuncName: "canonical_bpchar", TypeName: "pg_catalog.bpchar", Literal: "'yz'", + ExpectedType: "character", ExpectedValue: "yz"}, + {FuncName: "unqualified_character", TypeName: "character(3)", Literal: "'abc'", + ExpectedType: "character", ExpectedValue: "abc"}, + // Unlike the bare CHARACTER keyword, which names bpchar, `pg_catalog.char` names the internal + // one-byte "char" type. A PL/pgSQL variable of that type cannot hold a value yet (assigning one fails + // the same way for the canonical `"char"` spelling), so this only checks that the name resolves. + {FuncName: "canonical_qchar", TypeName: "pg_catalog.char", + ValueExpr: "(b IS NULL)::text", ExpectedValue: "true"}, + {FuncName: "canonical_integer_array", TypeName: "pg_catalog.integer[]", Literal: "'{1,2,3}'", + ExpectedType: "integer[]", ExpectedValue: "{1,2,3}"}, + }), + }) +} + +// TestPlpgsqlDeclareUnknownType tests that a schema-qualified type name that names no type is still rejected, +// rather than being resolved by a too-eager alias lookup. +func TestPlpgsqlDeclareUnknownType(t *testing.T) { + RunScripts(t, []ScriptTest{ + { + Name: "unknown schema-qualified type names are still rejected", + SetUpScript: []string{ + `CREATE FUNCTION unknown_type() RETURNS text AS $$ DECLARE b pg_catalog.not_a_type; BEGIN RETURN 'x'; END; $$ LANGUAGE plpgsql;`, + `CREATE FUNCTION unknown_multiword_type() RETURNS text AS $$ DECLARE b pg_catalog.double imprecision; BEGIN RETURN 'x'; END; $$ LANGUAGE plpgsql;`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: "SELECT unknown_type();", + ExpectedErr: `type "pg_catalog.not_a_type" does not exist`, + }, + { + Query: "SELECT unknown_multiword_type();", + ExpectedErr: `type "pg_catalog.double imprecision" does not exist`, + }, + }, + }, + }) +}