using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace Npgsql.Internal.Postgres;
///
/// Represents the normalized name of a PostgreSQL data type.
///
[Experimental(NpgsqlDiagnostics.ConvertersExperimental)]
[DebuggerDisplay("{DisplayName,nq}")]
public readonly struct DataTypeName : IEquatable
{
const char InvalidIdentifier = '-';
///
/// The maximum length of names in an unmodified PostgreSQL installation.
///
///
/// We need to respect this to get to valid names when deriving them (for multirange/arrays etc).
/// This does not include the namespace.
///
internal const int NAMEDATALEN = 64 - 1; // Minus null terminator.
readonly string _value;
DataTypeName(string fullyQualifiedDataTypeName, bool validated)
{
if (!validated)
{
var schemaEndIndex = fullyQualifiedDataTypeName.IndexOf('.');
if (schemaEndIndex is -1 or 0)
throw new ArgumentException("Given value does not contain a schema.", nameof(fullyQualifiedDataTypeName));
// Friendly array syntax is the only fully qualified name quirk that's allowed by postgres (see FromDisplayName).
if (fullyQualifiedDataTypeName.AsSpan(schemaEndIndex).EndsWith("[]".AsSpan()))
fullyQualifiedDataTypeName = NormalizeName(fullyQualifiedDataTypeName);
var typeNameLength = fullyQualifiedDataTypeName.Length - (schemaEndIndex + 1);
if (typeNameLength > NAMEDATALEN)
throw new ArgumentException(
$"Name is too long and would be truncated to: {fullyQualifiedDataTypeName.Substring(0,
fullyQualifiedDataTypeName.Length - typeNameLength + NAMEDATALEN)}");
}
_value = fullyQualifiedDataTypeName;
}
public DataTypeName(string fullyQualifiedDataTypeName)
: this(fullyQualifiedDataTypeName, validated: false) { }
internal static DataTypeName ValidatedName(string fullyQualifiedDataTypeName)
=> new(fullyQualifiedDataTypeName, validated: true);
bool IsUnqualifiedDisplayName => SchemaSpan is "pg_catalog" || IsUnqualified;
// Includes schema unless it's pg_catalog or the schema is an invalid character used to represent an unspecified schema.
public string DisplayName =>
IsUnqualifiedDisplayName
? UnqualifiedDisplayName
: Schema + "." + UnqualifiedDisplayName;
public string UnqualifiedDisplayName => ToDisplayName(UnqualifiedNameSpan, mapAliases: IsUnqualifiedDisplayName);
internal ReadOnlySpan SchemaSpan => Value.AsSpan(0, _value.IndexOf('.'));
public string Schema => Value.Substring(0, _value.IndexOf('.'));
internal ReadOnlySpan UnqualifiedNameSpan => Value.AsSpan(_value.IndexOf('.') + 1);
public string UnqualifiedName => Value.Substring(_value.IndexOf('.') + 1);
public string Value => _value is null ? ThrowDefaultException() : _value;
static string ThrowDefaultException() =>
throw new InvalidOperationException($"This operation cannot be performed on a default value of {nameof(DataTypeName)}.");
public static implicit operator string(DataTypeName value) => value.Value;
// This contains two invalid sql identifiers (schema and name are both separate identifiers, and would both have to be quoted to be valid).
// Given this is an invalid name it's fine for us to represent a fully qualified 'unspecified' name with it.
static string UnspecifiedName => $"{InvalidIdentifier}.{InvalidIdentifier}";
public static DataTypeName Unspecified => ValidatedName(UnspecifiedName);
public static string GetUnqualifiedName(string dataTypeName)
=> dataTypeName.IndexOf('.') is not -1 and var index
? dataTypeName.Substring(index + 1) : dataTypeName;
public bool IsUnqualified => Value.StartsWith(InvalidIdentifier) && Value != UnspecifiedName;
public bool IsArray => UnqualifiedNameSpan.StartsWith("_".AsSpan(), StringComparison.Ordinal);
internal static DataTypeName CreateFullyQualifiedName(string dataTypeName)
=> dataTypeName.IndexOf('.') != -1 ? new(dataTypeName) : new("-." + dataTypeName);
// Static transform as defined by https://www.postgresql.org/docs/current/sql-createtype.html#SQL-CREATETYPE-ARRAY
// We don't have to deal with [] as we're always starting from a normalized fully qualified name.
public DataTypeName ToArrayName()
{
var unqualifiedNameSpan = UnqualifiedNameSpan;
if (unqualifiedNameSpan.StartsWith("_".AsSpan(), StringComparison.Ordinal))
return this;
if (unqualifiedNameSpan.Length + "_".Length > NAMEDATALEN)
unqualifiedNameSpan = unqualifiedNameSpan.Slice(0, NAMEDATALEN - "_".Length);
return new(string.Concat(Schema, "._", unqualifiedNameSpan));
}
// Static transform as defined by https://www.postgresql.org/docs/current/sql-createtype.html#SQL-CREATETYPE-RANGE
// Manual testing on PG confirmed it's only the first occurence of 'range' that gets replaced.
public DataTypeName ToDefaultMultirangeName()
{
var nameSpan = UnqualifiedNameSpan;
if (nameSpan.IndexOf("multirange".AsSpan(), StringComparison.Ordinal) is not -1)
return this;
if (nameSpan.IndexOf("range", StringComparison.Ordinal) is var rangeIndex and not -1)
{
nameSpan = string.Concat(nameSpan.Slice(0, rangeIndex), "multirange", nameSpan.Slice(rangeIndex + "range".Length));
return new(string.Concat(SchemaSpan, ".",
nameSpan.Length > NAMEDATALEN ? nameSpan.Slice(0, NAMEDATALEN) : nameSpan));
}
if (nameSpan.Length + "_multirange".Length > NAMEDATALEN)
nameSpan = nameSpan.Slice(0, NAMEDATALEN - "_multirange".Length);
return new(string.Concat(SchemaSpan, ".", nameSpan, "_multirange"));
}
// Create a DataTypeName from a broader range of valid names.
// including SQL aliases like 'timestamp without time zone', trailing facet info etc.
public static DataTypeName FromDisplayName(string displayName)
{
var displayNameSpan = displayName.AsSpan().Trim();
var schemaEndIndex = displayNameSpan.IndexOf('.');
ReadOnlySpan schemaSpan;
if (schemaEndIndex is not -1)
{
schemaSpan = displayNameSpan.Slice(0, schemaEndIndex);
displayNameSpan = displayNameSpan.Slice(schemaEndIndex + 1);
}
else
{
schemaSpan = $"{InvalidIdentifier}";
}
// Then we strip either of the two valid array representations to get the base type name (with or without facets).
var isArray = false;
if (displayNameSpan.StartsWith("_", StringComparison.Ordinal))
{
isArray = true;
displayNameSpan = displayNameSpan.Slice(1);
}
else if (displayNameSpan.EndsWith("[]", StringComparison.Ordinal))
{
isArray = true;
displayNameSpan = displayNameSpan.Slice(0, displayNameSpan.Length - 2);
}
if (schemaEndIndex is not -1)
{
// If we have a schema we're done, Postgres doesn't do display name conversions on fully qualified names.
// There is one exception and that's array syntax, which is always resolvable in both ways, while we want the canonical name.
return !isArray
? new(displayName.Length == schemaEndIndex + displayNameSpan.Length
? displayName
: string.Concat(schemaSpan, ".", displayNameSpan))
: new(string.Concat(schemaSpan, ".", "_", displayNameSpan));
}
// Finally we strip the facet info.
var parenIndex = displayNameSpan.IndexOf('(');
if (parenIndex > -1)
displayNameSpan = displayNameSpan.Slice(0, parenIndex);
// Map any aliases to the internal type name.
var mapped = displayNameSpan switch
{
"boolean" => "bool",
"character" => "bpchar",
"decimal" => "numeric",
"real" => "float4",
"double precision" => "float8",
"smallint" => "int2",
"integer" => "int4",
"bigint" => "int8",
"time without time zone" => "time",
"timestamp without time zone" => "timestamp",
"time with time zone" => "timetz",
"timestamp with time zone" => "timestamptz",
"bit varying" => "varbit",
"character varying" => "varchar",
var value => value
};
if (DataTypeNames.IsWellKnownUnqualifiedName(mapped))
schemaSpan = "pg_catalog".AsSpan();
return new(string.Concat(schemaSpan, ".", isArray ? "_" : "", mapped));
}
// The type names stored in a DataTypeName are usually the actual typname from the pg_type column.
// There are some canonical aliases defined in the SQL standard which we take into account.
// Additionally array types have a '_' prefix while for readability their element type should be postfixed with '[]'.
// See the table for all the aliases https://www.postgresql.org/docs/current/static/datatype.html#DATATYPE-TABLE
// Alternatively some of the source lives at https://github.com/postgres/postgres/blob/c8e1ba736b2b9e8c98d37a5b77c4ed31baf94147/src/backend/utils/adt/format_type.c#L186
static string ToDisplayName(ReadOnlySpan unqualifiedName, bool mapAliases)
{
var isArray = unqualifiedName.IndexOf('_') is 0;
var baseTypeName = isArray ? unqualifiedName.Slice(1) : unqualifiedName;
string? mappedBaseType = null;
if (mapAliases)
{
mappedBaseType = baseTypeName switch
{
"bool" => "boolean",
"bpchar" => "character",
"decimal" => "numeric",
"float4" => "real",
"float8" => "double precision",
"int2" => "smallint",
"int4" => "integer",
"int8" => "bigint",
"time" => "time without time zone",
"timestamp" => "timestamp without time zone",
"timetz" => "time with time zone",
"timestamptz" => "timestamp with time zone",
"varbit" => "bit varying",
"varchar" => "character varying",
_ => null
};
}
return isArray
? string.Concat(mappedBaseType ?? baseTypeName, "[]")
: mappedBaseType ?? baseTypeName.ToString();
}
internal static bool IsFullyQualified(ReadOnlySpan dataTypeName) => dataTypeName.Contains(".".AsSpan(), StringComparison.Ordinal);
internal static string NormalizeName(string dataTypeName)
{
var fqName = FromDisplayName(dataTypeName);
return IsFullyQualified(dataTypeName.AsSpan()) ? fqName.Value : fqName.UnqualifiedName;
}
public override string ToString() => Value;
public bool Equals(DataTypeName other) => string.Equals(_value, other._value);
public override bool Equals(object? obj) => obj is DataTypeName other && Equals(other);
public override int GetHashCode() => _value.GetHashCode();
public static bool operator ==(DataTypeName left, DataTypeName right) => left.Equals(right);
public static bool operator !=(DataTypeName left, DataTypeName right) => !left.Equals(right);
}