From 5b880b573bb5d5d1a4b9ea9b28cfd01bb2e4b25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebubekir=20=C3=87a=C4=9Fr=C4=B1=20=C5=9Een?= Date: Tue, 5 May 2026 12:34:38 +0300 Subject: [PATCH] Add opt-in domain-based CLR type mapping (boolean/guid) Closes #1266 (proposal). Restores IBProvider-style behaviour for codebases that store booleans as SMALLINT and guids as CHAR(16) OCTETS behind named domains. Connection string keys (both default to empty - feature is fully off when unset; no extra catalog query, no DbField allocation change): boolean domains = D_BOOL%,IS\_% guid domains = D_GUID% Patterns use SQL LIKE syntax. Matching is case-insensitive against RDB$FIELD_SOURCE; system domains (RDB$*) are never matched. Design * RawDbDataType vs DbDataType. DbField gains RawDbDataType (the actual SQL/wire type, computed from sqltype/subtype/length/charset as before) and OverrideDataType (CLR-level reporting only). DbDataType returns the override when set, otherwise RawDbDataType. All wire serialisation paths (GdsStatement.WriteRawParameter / ReadRawValue, XsqldaMarshaler, DbValue.GetBytes, DbField.FixNull) explicitly use RawDbDataType so the override cannot leak into the protocol layer. * DomainNameResolver. Per-FbConnectionInternal cache of (relation, field) -> domain name. Populated lazily on Prepare via a single SELECT on RDB\$RELATION_FIELDS (one round-trip per batch of unseen columns). Guarded by an _isResolving flag so its own internal query cannot recurse. Negative entries are cached so a missing or failed lookup does not retry every prepare. Fetch failures are swallowed (best-effort): an opt-in feature must never break user's normal queries. * Parameter binding. FbCommand.NormalizeDomainParameterValue converts bool inputs to the underlying numeric wire type (SmallInt/Integer/ BigInt/Numeric/Decimal) using RawDbDataType, so callers can pass true or false to a SMALLINT-backed boolean column directly. Guid handling reuses the provider's existing CHAR(16) OCTETS <-> Guid path. * DbValue.GetBoolean. Tightened from Convert.ToBoolean to explicit short/int/long/decimal -> bool with a fallback, so reading a SMALLINT with a Boolean override is safe and culture-independent. Opt-out path When neither connection-string key is set, ConnectionString.HasDomainTypeMappings is false and FbCommand.Prepare returns before touching the resolver. RDB\$RELATION_FIELDS is never queried, no per-field state changes, behaviour is identical to master. Tests * DomainPatternListTests - 12 unit tests for LIKE pattern parsing (escape handling, case-insensitivity, RDB\$ exclusion, edge cases). * DomainTypeMappingTests - 12 integration tests covering schema-level reporting, parameter round-trip (bool -> SMALLINT -> bool), guid round-trip, NULL handling, non-matching patterns staying as raw types, DataAdapter round-trip, GetSchemaTable, and resolver caching / re-entry. * ConnectionStringTests - parsing and builder coverage for the two new keys. Compatibility No breaking changes. Public API surface adds two properties on FbConnectionStringBuilder (BooleanDomains, GuidDomains) and is backward-compatible at the wire level. --- .../ConnectionStringTests.cs | 77 +++- .../DomainPatternListTests.cs | 136 ++++++ .../DomainTypeMappingTests.cs | 399 ++++++++++++++++++ .../FbSchemaTests.cs | 4 +- .../Client/Managed/Version10/GdsStatement.cs | 16 +- .../Native/Marshalers/XsqldaMarshaler.cs | 2 +- .../Common/ConnectionString.cs | 39 ++ .../Common/DbField.cs | 46 +- .../Common/DbValue.cs | 34 +- .../Common/DomainNameResolver.cs | 268 ++++++++++++ .../Common/DomainPatternList.cs | 101 +++++ .../FirebirdClient/FbCommand.cs | 110 ++++- .../FirebirdClient/FbConnectionInternal.cs | 8 + .../FbConnectionStringBuilder.cs | 22 +- .../FbTestsSetup.cs | 37 +- 15 files changed, 1253 insertions(+), 46 deletions(-) create mode 100644 src/FirebirdSql.Data.FirebirdClient.Tests/DomainPatternListTests.cs create mode 100644 src/FirebirdSql.Data.FirebirdClient.Tests/DomainTypeMappingTests.cs create mode 100644 src/FirebirdSql.Data.FirebirdClient/Common/DomainNameResolver.cs create mode 100644 src/FirebirdSql.Data.FirebirdClient/Common/DomainPatternList.cs diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/ConnectionStringTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/ConnectionStringTests.cs index fb696d29f..3c3a1fc8e 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/ConnectionStringTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/ConnectionStringTests.cs @@ -1,4 +1,4 @@ -/* +/* * The contents of this file are subject to the Initial * Developer's Public License Version 1.0 (the "License"); * you may not use this file except in compliance with the @@ -760,4 +760,79 @@ public void ParsingDatabaseHostnames(string hostname) Assert.AreEqual(hostname, cs.DataSource); Assert.AreEqual("test.fdb", cs.Database); } + + [Test] + public void BooleanDomains_Spaced_Key() + { + var cs = new ConnectionString("user=u;password=p;boolean domains=D_BOOL%"); + Assert.AreEqual("D_BOOL%", cs.BooleanDomains); + Assert.IsTrue(cs.HasDomainTypeMappings); + Assert.IsTrue(cs.DomainTypeMappings.ContainsKey(DbDataType.Boolean)); + } + + [Test] + public void BooleanDomains_NoSpace_Key() + { + var cs = new ConnectionString("user=u;password=p;booleandomains=D_BOOL%"); + Assert.AreEqual("D_BOOL%", cs.BooleanDomains); + } + + [Test] + public void GuidDomains_Spaced_Key() + { + var cs = new ConnectionString("user=u;password=p;guid domains=GUID%"); + Assert.AreEqual("GUID%", cs.GuidDomains); + Assert.IsTrue(cs.DomainTypeMappings.ContainsKey(DbDataType.Guid)); + } + + [Test] + public void GuidDomains_NoSpace_Key() + { + var cs = new ConnectionString("user=u;password=p;guiddomains=GUID%"); + Assert.AreEqual("GUID%", cs.GuidDomains); + } + + [Test] + public void DomainTypeMappings_BothBooleanAndGuid() + { + var cs = new ConnectionString("user=u;password=p;boolean domains=D_BOOL%;guid domains=D_GUID%"); + Assert.AreEqual("D_BOOL%", cs.BooleanDomains); + Assert.AreEqual("D_GUID%", cs.GuidDomains); + Assert.AreEqual(2, cs.DomainTypeMappings.Count); + Assert.IsTrue(cs.DomainTypeMappings[DbDataType.Boolean].Matches("D_BOOL")); + Assert.IsTrue(cs.DomainTypeMappings[DbDataType.Boolean].Matches("D_BOOL_NULLABLE")); + Assert.IsTrue(cs.DomainTypeMappings[DbDataType.Guid].Matches("D_GUID")); + Assert.IsTrue(cs.DomainTypeMappings[DbDataType.Guid].Matches("D_GUID_NULLABLE")); + } + + [Test] + public void DomainTypeMappings_DefaultEmpty_NoOverrides() + { + var cs = new ConnectionString("user=u;password=p"); + Assert.AreEqual("", cs.BooleanDomains); + Assert.AreEqual("", cs.GuidDomains); + Assert.IsFalse(cs.HasDomainTypeMappings); + Assert.AreEqual(0, cs.DomainTypeMappings.Count); + } + + [Test] + public void BooleanDomains_CommaSeparatedPatterns() + { + var cs = new ConnectionString("user=u;password=p;boolean domains=D_BOOL%,FLAG%"); + var patterns = cs.DomainTypeMappings[DbDataType.Boolean]; + Assert.IsTrue(patterns.Matches("D_BOOL")); + Assert.IsTrue(patterns.Matches("D_BOOL_NULLABLE")); + Assert.IsTrue(patterns.Matches("FLAG_X")); + Assert.IsFalse(patterns.Matches("OTHER")); + } + + [Test] + public void DomainTypeMappings_RdbSystemDomainsAreNotMatched() + { + var cs = new ConnectionString("user=u;password=p;boolean domains=%"); + var patterns = cs.DomainTypeMappings[DbDataType.Boolean]; + Assert.IsTrue(patterns.Matches("CUSTOM")); + Assert.IsFalse(patterns.Matches("RDB$1")); + Assert.IsFalse(patterns.Matches("rdb$something")); + } } diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/DomainPatternListTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/DomainPatternListTests.cs new file mode 100644 index 000000000..0b9dbbd89 --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/DomainPatternListTests.cs @@ -0,0 +1,136 @@ +/* + * The contents of this file are subject to the Initial + * Developer's Public License Version 1.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt. + * + * Software distributed under the License is distributed on + * an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either + * express or implied. See the License for the specific + * language governing rights and limitations under the License. + * + * All Rights Reserved. + */ + +//$Authors = Ebubekir Cagri Sen (ebubekircagrisen@gmail.com) + +using FirebirdSql.Data.Common; +using FirebirdSql.Data.TestsBase; +using NUnit.Framework; + +namespace FirebirdSql.Data.FirebirdClient.Tests; + +[NoServerCategory] +public class DomainPatternListTests +{ + [Test] + public void Empty_NullSpec() + { + Assert.IsFalse(DomainPatternList.Parse(null).HasAny); + } + + [Test] + public void Empty_BlankSpec() + { + Assert.IsFalse(DomainPatternList.Parse(" ").HasAny); + } + + [Test] + public void Percent_PrefixWildcard_Matches() + { + var list = DomainPatternList.Parse("D_BOOL%"); + Assert.IsTrue(list.HasAny); + Assert.IsTrue(list.Matches("D_BOOL")); + Assert.IsTrue(list.Matches("D_BOOL_NULLABLE")); + Assert.IsFalse(list.Matches("BOOL")); + } + + [Test] + public void Percent_OnlyMatchesAnything() + { + var list = DomainPatternList.Parse("%"); + Assert.IsTrue(list.Matches("ANYTHING")); + Assert.IsTrue(list.Matches("X")); + Assert.IsTrue(list.Matches("A")); + } + + [Test] + public void Underscore_MatchesSingleChar() + { + var list = DomainPatternList.Parse("FLAG_"); + Assert.IsTrue(list.Matches("FLAGX")); + Assert.IsTrue(list.Matches("FLAG1")); + Assert.IsFalse(list.Matches("FLAG")); + Assert.IsFalse(list.Matches("FLAGXX")); + } + + [Test] + public void CaseInsensitive() + { + var list = DomainPatternList.Parse("d_bool%"); + Assert.IsTrue(list.Matches("D_BOOL")); + Assert.IsTrue(list.Matches("D_Bool_Nullable")); + } + + [Test] + public void MultiplePatterns_CommaSeparated() + { + var list = DomainPatternList.Parse("D_BOOL%,BOOL\\_%,FLAG"); + Assert.IsTrue(list.Matches("D_BOOL_X")); + Assert.IsTrue(list.Matches("FLAG")); + Assert.IsFalse(list.Matches("FLAGX")); + Assert.IsFalse(list.Matches("OTHER")); + } + + [Test] + public void RdbSystemDomains_Skipped() + { + var list = DomainPatternList.Parse("%"); + Assert.IsFalse(list.Matches("RDB$1")); + Assert.IsFalse(list.Matches("rdb$abc")); + } + + [Test] + public void EmptyOrWhitespaceTokens_Ignored() + { + var list = DomainPatternList.Parse(",,FOO,, ,BAR"); + Assert.IsTrue(list.Matches("FOO")); + Assert.IsTrue(list.Matches("BAR")); + } + + [Test] + public void NullDomain_NoMatch() + { + var list = DomainPatternList.Parse("%"); + Assert.IsFalse(list.Matches(null)); + Assert.IsFalse(list.Matches("")); + Assert.IsFalse(list.Matches(" ")); + } + + [Test] + public void TrimsDomainName() + { + var list = DomainPatternList.Parse("D_BOOL"); + Assert.IsTrue(list.Matches(" D_BOOL ")); + } + + [Test] + public void Percent_InMiddle_Works() + { + var list = DomainPatternList.Parse("FOO%BAR"); + Assert.IsTrue(list.Matches("FOOBAR")); + Assert.IsTrue(list.Matches("FOO_X_BAR")); + Assert.IsFalse(list.Matches("FOOBAZ")); + } + + [Test] + public void LiteralStar_HasNoSpecialMeaning() + { + // '*' is no longer a wildcard; it's matched literally. + var list = DomainPatternList.Parse("FOO*"); + Assert.IsFalse(list.Matches("FOO")); + Assert.IsFalse(list.Matches("FOOBAR")); + Assert.IsTrue(list.Matches("FOO*")); + } +} diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/DomainTypeMappingTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/DomainTypeMappingTests.cs new file mode 100644 index 000000000..0fa1c2aa8 --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/DomainTypeMappingTests.cs @@ -0,0 +1,399 @@ +/* + * The contents of this file are subject to the Initial + * Developer's Public License Version 1.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt. + * + * Software distributed under the License is distributed on + * an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either + * express or implied. See the License for the specific + * language governing rights and limitations under the License. + * + * All Rights Reserved. + */ + +//$Authors = Ebubekir Cagri Sen (ebubekircagrisen@gmail.com) + +using System; +using System.Threading.Tasks; +using FirebirdSql.Data.TestsBase; +using NUnit.Framework; + +namespace FirebirdSql.Data.FirebirdClient.Tests; + +[TestFixtureSource(typeof(FbServerTypeTestFixtureSource), nameof(FbServerTypeTestFixtureSource.Default))] +[TestFixtureSource(typeof(FbServerTypeTestFixtureSource), nameof(FbServerTypeTestFixtureSource.Embedded))] +public class DomainTypeMappingTests : FbTestsBase +{ + public DomainTypeMappingTests(FbServerType serverType, bool compression, FbWireCrypt wireCrypt) + : base(serverType, compression, wireCrypt, insertTestData: false) + { } + + private FbConnectionStringBuilder BuildBuilderWithDomainMappings(string booleanDomains, string guidDomains) + { + var builder = BuildConnectionStringBuilder(ServerType, Compression, WireCrypt); + if (booleanDomains != null) + builder.BooleanDomains = booleanDomains; + if (guidDomains != null) + builder.GuidDomains = guidDomains; + return builder; + } + + private async Task ResetTableAsync() + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "DELETE FROM USER_TYPE_TEST"; + await cmd.ExecuteNonQueryAsync(); + } + } + + [OneTimeTearDown] + public async Task TearDownAsync() + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "DROP TABLE USER_TYPE_TEST"; + await cmd.ExecuteNonQueryAsync(); + } + } + + + private async Task SeedRowAsync(int id, short isActive, short? optFlag, Guid rowGuid, Guid? optGuid) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "INSERT INTO USER_TYPE_TEST (ID, IS_ACTIVE, OPT_FLAG, ROW_GUID, OPT_GUID) VALUES (@id, @act, @opt, @g, @og)"; + cmd.Parameters.Add("@id", id); + cmd.Parameters.Add("@act", isActive); + cmd.Parameters.Add("@opt", (object)optFlag ?? DBNull.Value); + cmd.Parameters.Add("@g", rowGuid); + cmd.Parameters.Add("@og", optGuid.HasValue ? (object)optGuid.Value : DBNull.Value); + await cmd.ExecuteNonQueryAsync(); + } + } + + [Test] + public async Task Read_BooleanMapping_ReportsBoolType() + { + await ResetTableAsync(); + await SeedRowAsync(1, 1, 0, Guid.NewGuid(), null); + + var cs = BuildBuilderWithDomainMappings("D_BOOL%", null).ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT IS_ACTIVE, OPT_FLAG FROM USER_TYPE_TEST WHERE ID = 1"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + Assert.AreEqual(typeof(bool), reader.GetFieldType(0)); + Assert.AreEqual(typeof(bool), reader.GetFieldType(1)); + Assert.AreEqual(true, reader.GetBoolean(0)); + Assert.AreEqual(false, reader.GetBoolean(1)); + } + } + } + } + + [Test] + public async Task Read_NoMapping_ReportsShortType() + { + await ResetTableAsync(); + await SeedRowAsync(2, 1, null, Guid.NewGuid(), null); + + var cs = BuildConnectionStringBuilder(ServerType, Compression, WireCrypt).ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT IS_ACTIVE FROM USER_TYPE_TEST WHERE ID = 2"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + Assert.AreEqual(typeof(short), reader.GetFieldType(0)); + } + } + } + } + + [Test] + public async Task Write_BoolToSmallintRoundtrip() + { + await ResetTableAsync(); + + var cs = BuildBuilderWithDomainMappings("D_BOOL%", null).ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + await using (var insert = conn.CreateCommand()) + { + insert.CommandText = "INSERT INTO USER_TYPE_TEST (ID, IS_ACTIVE, OPT_FLAG, ROW_GUID) VALUES (@id, @act, @opt, @g)"; + insert.Parameters.Add("@id", 10); + insert.Parameters.Add("@act", true); + insert.Parameters.Add("@opt", false); + insert.Parameters.Add("@g", Guid.NewGuid()); + var affected = await insert.ExecuteNonQueryAsync(); + Assert.AreEqual(1, affected); + } + await using (var read = conn.CreateCommand()) + { + read.CommandText = "SELECT IS_ACTIVE, OPT_FLAG FROM USER_TYPE_TEST WHERE ID = 10"; + await using (var reader = await read.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + Assert.AreEqual(true, reader.GetBoolean(0)); + Assert.AreEqual(false, reader.GetBoolean(1)); + } + } + } + } + + [Test] + public async Task Write_RawNumeric_AlsoStillWorks() + { + await ResetTableAsync(); + + var cs = BuildBuilderWithDomainMappings("D_BOOL%", null).ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + await using (var insert = conn.CreateCommand()) + { + insert.CommandText = "INSERT INTO USER_TYPE_TEST (ID, IS_ACTIVE, ROW_GUID) VALUES (@id, @act, @g)"; + insert.Parameters.Add("@id", 11); + insert.Parameters.Add("@act", (short)1); + insert.Parameters.Add("@g", Guid.NewGuid()); + await insert.ExecuteNonQueryAsync(); + } + await using (var read = conn.CreateCommand()) + { + read.CommandText = "SELECT IS_ACTIVE FROM USER_TYPE_TEST WHERE ID = 11"; + await using (var reader = await read.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + Assert.AreEqual(true, reader.GetBoolean(0)); + } + } + } + } + + [Test] + public async Task Read_GuidMapping_ReportsGuidType() + { + await ResetTableAsync(); + var g = Guid.NewGuid(); + await SeedRowAsync(20, 1, null, g, null); + + var cs = BuildBuilderWithDomainMappings(null, "D_GUID%").ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT ROW_GUID FROM USER_TYPE_TEST WHERE ID = 20"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + Assert.AreEqual(typeof(Guid), reader.GetFieldType(0)); + Assert.AreEqual(g, reader.GetGuid(0)); + } + } + } + } + + [Test] + public async Task PatternList_BothFamiliesMatched() + { + await ResetTableAsync(); + await SeedRowAsync(40, 1, 0, Guid.NewGuid(), null); + + var cs = BuildBuilderWithDomainMappings("D_BOOL%,BOOL\\_%", null).ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT IS_ACTIVE, OPT_FLAG FROM USER_TYPE_TEST WHERE ID = 40"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + Assert.AreEqual(typeof(bool), reader.GetFieldType(0)); + Assert.AreEqual(typeof(bool), reader.GetFieldType(1)); + } + } + } + } + + [Test] + public async Task NonMatchingPattern_NoOverride() + { + await ResetTableAsync(); + await SeedRowAsync(50, 1, null, Guid.NewGuid(), null); + + var cs = BuildBuilderWithDomainMappings("ZZZ\\_NEVER\\_%", null).ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT IS_ACTIVE FROM USER_TYPE_TEST WHERE ID = 50"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + Assert.AreEqual(typeof(short), reader.GetFieldType(0)); + } + } + } + } + + [Test] + public async Task NullValue_BoolMapping_ReturnsDbNull() + { + await ResetTableAsync(); + await SeedRowAsync(60, 1, null, Guid.NewGuid(), null); + + var cs = BuildBuilderWithDomainMappings("D_BOOL%", null).ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT OPT_FLAG FROM USER_TYPE_TEST WHERE ID = 60"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + Assert.IsTrue(reader.IsDBNull(0)); + Assert.AreEqual(typeof(bool), reader.GetFieldType(0)); + } + } + } + } + + [Test] + public async Task DataAdapter_BoolColumnRoundtrip() + { + await ResetTableAsync(); + // Seed a row so FbCommandBuilder can infer the PK from the filled DataTable. + await SeedRowAsync(70, 1, null, Guid.NewGuid(), null); + + var cs = BuildBuilderWithDomainMappings("D_BOOL%", "D_GUID%").ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + using (var adapter = new FbDataAdapter(new FbCommand("SELECT ID, IS_ACTIVE, ROW_GUID FROM USER_TYPE_TEST WHERE ID = 70", conn))) + using (var builder = new FbCommandBuilder(adapter)) + { + var table = new System.Data.DataTable(); + adapter.Fill(table); + + // Verify the seeded row came back with domain-mapped bool type. + Assert.AreEqual(1, table.Rows.Count); + Assert.AreEqual(typeof(bool), table.Columns["IS_ACTIVE"].DataType); + Assert.AreEqual(typeof(Guid), table.Columns["ROW_GUID"].DataType); + + // Update the row. + table.Rows[0]["IS_ACTIVE"] = false; + adapter.Update(table); + + table.Clear(); + adapter.Fill(table); + Assert.AreEqual(1, table.Rows.Count); + Assert.AreEqual(false, table.Rows[0]["IS_ACTIVE"]); + } + } + } + + [Test] + public async Task Resolver_CacheHit_OnlyFetchesOncePerSchema() + { + await ResetTableAsync(); + await SeedRowAsync(80, 1, null, Guid.NewGuid(), null); + + var cs = BuildBuilderWithDomainMappings("D_BOOL%", "D_GUID%").ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + var resolver = conn.InnerConnection.DomainResolver; + Assert.AreEqual(0, resolver.FetchCount); + + for (var i = 0; i < 5; i++) + { + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT IS_ACTIVE, ROW_GUID FROM USER_TYPE_TEST WHERE ID = 80"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) { } + } + } + } + + // Two columns from one table -> at most one fetch round-trip. + Assert.AreEqual(1, resolver.FetchCount); + } + } + + [Test] + public async Task Resolver_RepeatedPrepare_NoStackOverflow() + { + await ResetTableAsync(); + await SeedRowAsync(90, 1, null, Guid.NewGuid(), null); + + var cs = BuildBuilderWithDomainMappings("D_BOOL%", "D_GUID%").ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + for (var i = 0; i < 100; i++) + { + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $"SELECT IS_ACTIVE FROM USER_TYPE_TEST WHERE ID = {90 + (i % 5)}"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) { } + } + } + } + Assert.Pass(); + } + } + + [Test] + public async Task GetSchemaTable_BoolMapping_ReportsCorrectDataType() + { + await ResetTableAsync(); + await SeedRowAsync(100, 1, 0, Guid.NewGuid(), null); + + var cs = BuildBuilderWithDomainMappings("D_BOOL%", "D_GUID%").ToString(); + await using (var conn = new FbConnection(cs)) + { + await conn.OpenAsync(); + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT IS_ACTIVE, OPT_FLAG, ROW_GUID FROM USER_TYPE_TEST WHERE ID = 100"; + await using (var reader = await cmd.ExecuteReaderAsync(System.Data.CommandBehavior.KeyInfo)) + { + var schema = reader.GetSchemaTable(); + Assert.IsNotNull(schema); + + // IS_ACTIVE: D_BOOL domain -> should report Boolean + var isActiveType = (Type)schema.Rows[0]["DataType"]; + Assert.AreEqual(typeof(bool), isActiveType, "IS_ACTIVE should be reported as Boolean via domain mapping"); + + // OPT_FLAG: D_BOOL_NULLABLE domain -> should also report Boolean + var optFlagType = (Type)schema.Rows[1]["DataType"]; + Assert.AreEqual(typeof(bool), optFlagType, "OPT_FLAG should be reported as Boolean via domain mapping"); + + // ROW_GUID: D_GUID domain -> should report Guid + var rowGuidType = (Type)schema.Rows[2]["DataType"]; + Assert.AreEqual(typeof(Guid), rowGuidType, "ROW_GUID should be reported as Guid via domain mapping"); + } + } + } + } +} diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbSchemaTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbSchemaTests.cs index db6a2e7c6..c332fe229 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbSchemaTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbSchemaTests.cs @@ -1,4 +1,4 @@ -/* +/* * The contents of this file are subject to the Initial * Developer's Public License Version 1.0 (the "License"); * you may not use this file except in compliance with the @@ -206,7 +206,7 @@ public async Task Tables() Assert.AreEqual(1, tables1.Rows.Count); var tables2 = await Connection.GetSchemaAsync("Tables", new string[] { null, null, null, "TABLE" }); - Assert.AreEqual(3, tables2.Rows.Count); + Assert.AreEqual(4, tables2.Rows.Count); } [Test] diff --git a/src/FirebirdSql.Data.FirebirdClient/Client/Managed/Version10/GdsStatement.cs b/src/FirebirdSql.Data.FirebirdClient/Client/Managed/Version10/GdsStatement.cs index 54b5698cc..0f90e0580 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Client/Managed/Version10/GdsStatement.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Client/Managed/Version10/GdsStatement.cs @@ -1224,11 +1224,11 @@ protected virtual async ValueTask WriteParametersAsync(CancellationToken protected void WriteRawParameter(IXdrWriter xdr, DbField field) { - if (field.DbDataType != DbDataType.Null) + if (field.RawDbDataType != DbDataType.Null) { field.FixNull(); - switch (field.DbDataType) + switch (field.RawDbDataType) { case DbDataType.Char: if (field.Charset.IsOctetsCharset) @@ -1372,11 +1372,11 @@ protected void WriteRawParameter(IXdrWriter xdr, DbField field) } protected async ValueTask WriteRawParameterAsync(IXdrWriter xdr, DbField field, CancellationToken cancellationToken = default) { - if (field.DbDataType != DbDataType.Null) + if (field.RawDbDataType != DbDataType.Null) { field.FixNull(); - switch (field.DbDataType) + switch (field.RawDbDataType) { case DbDataType.Char: if (field.Charset.IsOctetsCharset) @@ -1523,7 +1523,7 @@ protected object ReadRawValue(IXdrReader xdr, DbField field) { var innerCharset = !_database.Charset.IsNoneCharset ? _database.Charset : field.Charset; - switch (field.DbDataType) + switch (field.RawDbDataType) { case DbDataType.Char: if (field.Charset.IsOctetsCharset) @@ -1614,14 +1614,14 @@ protected object ReadRawValue(IXdrReader xdr, DbField field) return xdr.ReadInt128(); default: - throw TypeHelper.InvalidDataType((int)field.DbDataType); + throw TypeHelper.InvalidDataType((int)field.RawDbDataType); } } protected async ValueTask ReadRawValueAsync(IXdrReader xdr, DbField field, CancellationToken cancellationToken = default) { var innerCharset = !_database.Charset.IsNoneCharset ? _database.Charset : field.Charset; - switch (field.DbDataType) + switch (field.RawDbDataType) { case DbDataType.Char: if (field.Charset.IsOctetsCharset) @@ -1712,7 +1712,7 @@ protected async ValueTask ReadRawValueAsync(IXdrReader xdr, DbField fiel return await xdr.ReadInt128Async(cancellationToken).ConfigureAwait(false); default: - throw TypeHelper.InvalidDataType((int)field.DbDataType); + throw TypeHelper.InvalidDataType((int)field.RawDbDataType); } } diff --git a/src/FirebirdSql.Data.FirebirdClient/Client/Native/Marshalers/XsqldaMarshaler.cs b/src/FirebirdSql.Data.FirebirdClient/Client/Native/Marshalers/XsqldaMarshaler.cs index 65b770264..038a5af27 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Client/Native/Marshalers/XsqldaMarshaler.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Client/Native/Marshalers/XsqldaMarshaler.cs @@ -86,7 +86,7 @@ public static IntPtr MarshalManagedToNative(Charset charset, Descriptor descript }; - if (descriptor[i].HasDataType() && descriptor[i].DbDataType != DbDataType.Null) + if (descriptor[i].HasDataType() && descriptor[i].RawDbDataType != DbDataType.Null) { var buffer = descriptor[i].DbValue.GetBytes(); xsqlvar[i].sqldata = Marshal.AllocHGlobal(buffer.Length); diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/ConnectionString.cs b/src/FirebirdSql.Data.FirebirdClient/Common/ConnectionString.cs index 5192e7857..831511cd5 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/ConnectionString.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/ConnectionString.cs @@ -58,6 +58,7 @@ internal sealed class ConnectionString internal const string DefaultValueApplicationName = ""; internal const int DefaultValueCommandTimeout = 0; internal const int DefaultValueParallelWorkers = 0; + internal const string DefaultValueDomainPatterns = ""; internal const string DefaultKeyUserId = "user id"; internal const string DefaultKeyPortNumber = "port number"; @@ -88,6 +89,8 @@ internal sealed class ConnectionString internal const string DefaultKeyApplicationName = "application name"; internal const string DefaultKeyCommandTimeout = "command timeout"; internal const string DefaultKeyParallelWorkers = "parallel workers"; + internal const string DefaultKeyBooleanDomains = "boolean domains"; + internal const string DefaultKeyGuidDomains = "guid domains"; #endregion #region Static Fields @@ -163,6 +166,17 @@ internal sealed class ConnectionString { DefaultKeyParallelWorkers, DefaultKeyParallelWorkers }, { "parallelworkers", DefaultKeyParallelWorkers }, { "parallel", DefaultKeyParallelWorkers }, + { DefaultKeyBooleanDomains, DefaultKeyBooleanDomains }, + { "booleandomains", DefaultKeyBooleanDomains }, + { DefaultKeyGuidDomains, DefaultKeyGuidDomains }, + { "guiddomains", DefaultKeyGuidDomains }, + }; + + internal static readonly IReadOnlyDictionary DomainTypeMappingKeys = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { DefaultKeyBooleanDomains, DbDataType.Boolean }, + { DefaultKeyGuidDomains, DbDataType.Guid }, }; internal static readonly IDictionary DefaultValues = new Dictionary(StringComparer.Ordinal) @@ -196,6 +210,8 @@ internal sealed class ConnectionString { DefaultKeyApplicationName, DefaultValueApplicationName }, { DefaultKeyCommandTimeout, DefaultValueCommandTimeout }, { DefaultKeyParallelWorkers, DefaultValueParallelWorkers }, + { DefaultKeyBooleanDomains, DefaultValueDomainPatterns }, + { DefaultKeyGuidDomains, DefaultValueDomainPatterns }, }; #endregion @@ -203,6 +219,7 @@ internal sealed class ConnectionString #region Fields private Dictionary _options; + private IReadOnlyDictionary _domainTypeMappings; #endregion @@ -237,6 +254,28 @@ internal sealed class ConnectionString public string ApplicationName => GetString(DefaultKeyApplicationName, _options.TryGetValue); public int CommandTimeout => GetInt32(DefaultKeyCommandTimeout, _options.TryGetValue); public int ParallelWorkers => GetInt32(DefaultKeyParallelWorkers, _options.TryGetValue); + public string BooleanDomains => GetString(DefaultKeyBooleanDomains, _options.TryGetValue); + public string GuidDomains => GetString(DefaultKeyGuidDomains, _options.TryGetValue); + + public IReadOnlyDictionary DomainTypeMappings + { + get + { + if (_domainTypeMappings != null) + return _domainTypeMappings; + var map = new Dictionary(); + foreach (var entry in DomainTypeMappingKeys) + { + var spec = GetString(entry.Key, _options.TryGetValue); + var patterns = DomainPatternList.Parse(spec); + if (patterns.HasAny) + map[entry.Value] = patterns; + } + return _domainTypeMappings = map; + } + } + + public bool HasDomainTypeMappings => DomainTypeMappings.Count > 0; #endregion diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs index 3be89be74..3d07c6cd7 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs @@ -1,4 +1,4 @@ -/* +/* * The contents of this file are subject to the Initial * Developer's Public License Version 1.0 (the "License"); * you may not use this file except in compliance with the @@ -39,16 +39,43 @@ internal sealed class DbField private DbValue _dbValue; private Charset _charset; private ArrayBase _arrayHandle; + private string _domainName; + private DbDataType? _overrideDataType; #endregion #region Properties public DbDataType DbDataType + { + get + { + return _overrideDataType ?? RawDbDataType; + } + } + + // Underlying SQL-level type, ignoring any domain-based override. Wire serialization + // (read/write paths in GdsStatement and DbValue) must use this — the override is a + // CLR-level reporting concern only; the on-the-wire shape is dictated by the actual + // SQL type the server prepared (e.g. SMALLINT remains 2 bytes even when reported as + // Boolean to ADO.NET). + public DbDataType RawDbDataType { get { return TypeHelper.GetDbDataTypeFromSqlType(SqlType, SubType, NumericScale, Length, Charset); } } + public string DomainName + { + get { return _domainName; } + set { _domainName = value?.Trim(); } + } + + public DbDataType? OverrideDataType + { + get { return _overrideDataType; } + set { _overrideDataType = value; } + } + public int SqlType { get { return _dataType & ~1; } @@ -183,7 +210,7 @@ public bool IsNumeric() return false; } - switch (DbDataType) + switch (RawDbDataType) { case DbDataType.SmallInt: case DbDataType.Integer: @@ -206,7 +233,7 @@ public bool IsDecimal() return false; } - switch (DbDataType) + switch (RawDbDataType) { case DbDataType.Numeric: case DbDataType.Decimal: @@ -224,7 +251,7 @@ public bool IsLong() return false; } - switch (DbDataType) + switch (RawDbDataType) { case DbDataType.Binary: case DbDataType.Text: @@ -242,7 +269,7 @@ public bool IsCharacter() return false; } - switch (DbDataType) + switch (RawDbDataType) { case DbDataType.Char: case DbDataType.VarChar: @@ -261,7 +288,7 @@ public bool IsArray() return false; } - switch (DbDataType) + switch (RawDbDataType) { case DbDataType.Array: return true; @@ -308,11 +335,12 @@ public void SetValue(byte[] buffer) } else { + // SetValue uses SqlType (wire-level) and RawDbDataType (never the CLR override) switch (SqlType) { case IscCodes.SQL_TEXT: case IscCodes.SQL_VARYING: - if (DbDataType == DbDataType.Guid) + if (RawDbDataType == DbDataType.Guid) { DbValue.SetValue(TypeDecoder.DecodeGuid(buffer)); } @@ -468,9 +496,11 @@ public void SetValue(byte[] buffer) public void FixNull() { + // Wire serialization uses RawDbDataType, so FixNull must set a value compatible + // with the wire-level SQL type — not the CLR-level override (e.g. Boolean). if (NullFlag == -1 && _dbValue.IsDBNull()) { - switch (DbDataType) + switch (RawDbDataType) { case DbDataType.Char: case DbDataType.VarChar: diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs index 44b79a0f1..72bb05557 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs @@ -94,6 +94,12 @@ public object GetValue() return GetArray(); } + case DbDataType.Boolean: + return GetBoolean(); + + case DbDataType.Guid: + return GetGuid(); + default: return _value; } @@ -137,6 +143,12 @@ public async ValueTask GetValueAsync(CancellationToken cancellationToken return await GetArrayAsync(cancellationToken).ConfigureAwait(false); } + case DbDataType.Boolean: + return GetBoolean(); + + case DbDataType.Guid: + return GetGuid(); + default: return _value; } @@ -149,7 +161,7 @@ public void SetValue(object value) public string GetString() { - if (Field.DbDataType == DbDataType.Text && _value is long l) + if (Field.RawDbDataType == DbDataType.Text && _value is long l) { _value = GetClobData(l); } @@ -162,7 +174,7 @@ public string GetString() } public async ValueTask GetStringAsync(CancellationToken cancellationToken = default) { - if (Field.DbDataType == DbDataType.Text && _value is long l) + if (Field.RawDbDataType == DbDataType.Text && _value is long l) { _value = await GetClobDataAsync(l, cancellationToken).ConfigureAwait(false); } @@ -181,7 +193,15 @@ public char GetChar() public bool GetBoolean() { - return Convert.ToBoolean(_value, CultureInfo.InvariantCulture); + return _value switch + { + bool b => b, + short i16 => i16 != 0, + int i32 => i32 != 0, + long i64 => i64 != 0L, + decimal dec => dec != 0m, + _ => Convert.ToBoolean(_value, CultureInfo.InvariantCulture), + }; } public byte GetByte() @@ -401,7 +421,7 @@ public byte[] GetBytes() } - switch (Field.DbDataType) + switch (Field.RawDbDataType) { case DbDataType.Char: { @@ -597,7 +617,7 @@ public byte[] GetBytes() return Int128Helper.GetBytes(GetInt128()); default: - throw TypeHelper.InvalidDataType((int)Field.DbDataType); + throw TypeHelper.InvalidDataType((int)Field.RawDbDataType); } } public async ValueTask GetBytesAsync(CancellationToken cancellationToken = default) @@ -616,7 +636,7 @@ public async ValueTask GetBytesAsync(CancellationToken cancellationToken } - switch (Field.DbDataType) + switch (Field.RawDbDataType) { case DbDataType.Char: { @@ -812,7 +832,7 @@ public async ValueTask GetBytesAsync(CancellationToken cancellationToken return Int128Helper.GetBytes(GetInt128()); default: - throw TypeHelper.InvalidDataType((int)Field.DbDataType); + throw TypeHelper.InvalidDataType((int)Field.RawDbDataType); } } diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DomainNameResolver.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DomainNameResolver.cs new file mode 100644 index 000000000..d7ee0c67b --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DomainNameResolver.cs @@ -0,0 +1,268 @@ +/* + * The contents of this file are subject to the Initial + * Developer's Public License Version 1.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt. + * + * Software distributed under the License is distributed on + * an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either + * express or implied. See the License for the specific + * language governing rights and limitations under the License. + * + * All Rights Reserved. + */ + +//$Authors = Ebubekir Cagri Sen (ebubekircagrisen@gmail.com) + +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FirebirdSql.Data.FirebirdClient; + +namespace FirebirdSql.Data.Common; + +// Resolves domain names (RDB$FIELD_SOURCE) for the (relation, field) pairs +// of a prepared statement and applies any configured DbDataType override +// to the matched DbField. Cache is per FbConnectionInternal; an +// _isResolving sentinel suppresses re-entry while the resolver itself +// runs an internal SELECT against RDB$RELATION_FIELDS. Fetch failures +// are swallowed (best-effort): a feature opted in via the connection +// string must never break the user's normal queries. +internal sealed class DomainNameResolver +{ + private readonly Dictionary<(string Relation, string Field), string> _cache = new(); + // volatile: the flag is read/written across async continuations that may resume + // on different threads. Without volatile the compiler or JIT may cache the value + // in a register and miss the reset in the finally block. + private volatile bool _isResolving; + private int _fetchCount; + + public bool IsResolving => _isResolving; + public int FetchCount => _fetchCount; + + public void Resolve(FbConnection connection, IEnumerable fields, + IReadOnlyDictionary mappings) + { + if (_isResolving) + return; + if (mappings == null || mappings.Count == 0) + return; + + var collected = Collect(fields); + if (collected.Count == 0) + return; + + var needed = NotInCache(collected.Keys); + if (needed.Count > 0) + { + _isResolving = true; + try + { + FetchDomainNames(connection, needed); + } + finally + { + _isResolving = false; + } + } + + ApplyOverrides(collected, mappings); + } + + public async ValueTask ResolveAsync(FbConnection connection, IEnumerable fields, + IReadOnlyDictionary mappings, + CancellationToken cancellationToken = default) + { + if (_isResolving) + return; + if (mappings == null || mappings.Count == 0) + return; + + var collected = Collect(fields); + if (collected.Count == 0) + return; + + var needed = NotInCache(collected.Keys); + if (needed.Count > 0) + { + _isResolving = true; + try + { + await FetchDomainNamesAsync(connection, needed, cancellationToken).ConfigureAwait(false); + } + finally + { + _isResolving = false; + } + } + + ApplyOverrides(collected, mappings); + } + + private static Dictionary<(string Relation, string Field), List> Collect(IEnumerable fields) + { + var result = new Dictionary<(string, string), List>(); + if (fields == null) + return result; + foreach (var f in fields) + { + if (f == null) + continue; + var rel = f.Relation; + var name = f.Name; + if (string.IsNullOrEmpty(rel) || string.IsNullOrEmpty(name)) + continue; + var key = (rel.Trim(), name.Trim()); + if (key.Item1.Length == 0 || key.Item2.Length == 0) + continue; + if (!result.TryGetValue(key, out var list)) + { + list = new List(); + result[key] = list; + } + list.Add(f); + } + return result; + } + + private List<(string Relation, string Field)> NotInCache(IEnumerable<(string Relation, string Field)> keys) + { + var result = new List<(string, string)>(); + foreach (var k in keys) + { + if (!_cache.ContainsKey(k)) + result.Add(k); + } + return result; + } + + private void ApplyOverrides(Dictionary<(string Relation, string Field), List> collected, + IReadOnlyDictionary mappings) + { + foreach (var entry in collected) + { + if (!_cache.TryGetValue(entry.Key, out var domain) || string.IsNullOrEmpty(domain)) + continue; + DbDataType? matched = null; + foreach (var m in mappings) + { + if (m.Value.Matches(domain)) + { + matched = m.Key; + break; + } + } + foreach (var field in entry.Value) + { + field.DomainName = domain; + if (matched.HasValue) + field.OverrideDataType = matched.Value; + } + } + } + + private void FetchDomainNames(FbConnection connection, List<(string Relation, string Field)> needed) + { + // Negative cache: if the fetch fails or rows are missing, the entry stays null. + foreach (var k in needed) + _cache[k] = null; + + _fetchCount++; + try + { + var sql = BuildQuery(needed.Count); + var activeTransaction = connection.InnerConnection?.ActiveTransaction; + using (var cmd = new FbCommand(sql, connection, activeTransaction)) + { + AddParameters(cmd, needed); + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var rel = reader.IsDBNull(0) ? null : reader.GetString(0); + var fld = reader.IsDBNull(1) ? null : reader.GetString(1); + var dom = reader.IsDBNull(2) ? null : reader.GetString(2); + if (rel != null && fld != null) + _cache[(rel.Trim(), fld.Trim())] = dom?.Trim(); + } + } + } + } + catch + { + // Best-effort: an opt-in feature must never break user's normal queries. + // Cache is already populated with negative entries above, so subsequent + // prepares for the same columns won't keep re-trying within this session. + } + } + + private async ValueTask FetchDomainNamesAsync(FbConnection connection, List<(string Relation, string Field)> needed, + CancellationToken cancellationToken) + { + foreach (var k in needed) + _cache[k] = null; + + _fetchCount++; + try + { + var sql = BuildQuery(needed.Count); + var activeTransaction = connection.InnerConnection?.ActiveTransaction; + var cmd = new FbCommand(sql, connection, activeTransaction); + await using (cmd.ConfigureAwait(false)) + { + AddParameters(cmd, needed); + var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + await using (reader.ConfigureAwait(false)) + { + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var rel = await reader.IsDBNullAsync(0, cancellationToken).ConfigureAwait(false) ? null : reader.GetString(0); + var fld = await reader.IsDBNullAsync(1, cancellationToken).ConfigureAwait(false) ? null : reader.GetString(1); + var dom = await reader.IsDBNullAsync(2, cancellationToken).ConfigureAwait(false) ? null : reader.GetString(2); + if (rel != null && fld != null) + _cache[(rel.Trim(), fld.Trim())] = dom?.Trim(); + } + } + } + } + catch + { + // Best-effort. See sync counterpart. + } + } + + private static string BuildQuery(int count) + { + var sb = new StringBuilder(); + sb.Append("SELECT TRIM(rfr.RDB$RELATION_NAME), TRIM(rfr.RDB$FIELD_NAME), TRIM(rfr.RDB$FIELD_SOURCE) "); + sb.Append("FROM RDB$RELATION_FIELDS rfr WHERE "); + for (var i = 0; i < count; i++) + { + if (i > 0) + sb.Append(" OR "); + sb.Append("(rfr.RDB$RELATION_NAME = @r"); + sb.Append(i); + sb.Append(" AND rfr.RDB$FIELD_NAME = @f"); + sb.Append(i); + sb.Append(')'); + } + return sb.ToString(); + } + + private static void AddParameters(FbCommand cmd, List<(string Relation, string Field)> needed) + { + for (var i = 0; i < needed.Count; i++) + { + cmd.Parameters.Add("@r" + i, needed[i].Relation); + cmd.Parameters.Add("@f" + i, needed[i].Field); + } + } + + public void Clear() + { + _cache.Clear(); + _fetchCount = 0; + } +} diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DomainPatternList.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DomainPatternList.cs new file mode 100644 index 000000000..22db66c34 --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DomainPatternList.cs @@ -0,0 +1,101 @@ +/* + * The contents of this file are subject to the Initial + * Developer's Public License Version 1.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt. + * + * Software distributed under the License is distributed on + * an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either + * express or implied. See the License for the specific + * language governing rights and limitations under the License. + * + * All Rights Reserved. + */ + +//$Authors = Ebubekir Cagri Sen (ebubekircagrisen@gmail.com) + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace FirebirdSql.Data.Common; + +// Comma-separated list of SQL LIKE patterns used to match Firebird domain +// names (RDB$FIELD_SOURCE). '%' matches any sequence of characters; '_' +// matches a single character. Matching is case-insensitive. System +// domains (RDB$ prefix) are never matched. +internal sealed class DomainPatternList +{ + public static DomainPatternList Empty { get; } = new(Array.Empty()); + + private readonly Regex[] _patterns; + + private DomainPatternList(Regex[] patterns) + { + _patterns = patterns; + } + + public bool HasAny => _patterns.Length > 0; + + public static DomainPatternList Parse(string spec) + { + if (string.IsNullOrWhiteSpace(spec)) + return Empty; + + var parts = spec.Split(','); + var compiled = new List(parts.Length); + foreach (var raw in parts) + { + var token = raw.Trim(); + if (token.Length == 0) + continue; + compiled.Add(CompilePattern(token)); + } + return compiled.Count == 0 ? Empty : new DomainPatternList(compiled.ToArray()); + } + + public bool Matches(string domainName) + { + if (_patterns.Length == 0) + return false; + if (string.IsNullOrEmpty(domainName)) + return false; + var trimmed = domainName.Trim(); + if (trimmed.Length == 0) + return false; + if (trimmed.StartsWith("RDB$", StringComparison.OrdinalIgnoreCase)) + return false; + foreach (var regex in _patterns) + { + if (regex.IsMatch(trimmed)) + return true; + } + return false; + } + + private static Regex CompilePattern(string pattern) + { + var sb = new StringBuilder(pattern.Length + 8); + sb.Append('^'); + for (var i = 0; i < pattern.Length; i++) + { + var c = pattern[i]; + switch (c) + { + case '%': + sb.Append(".*"); + break; + case '_': + sb.Append('.'); + break; + default: + sb.Append(Regex.Escape(c.ToString())); + break; + } + } + sb.Append('$'); + return new Regex(sb.ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + } +} diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs index 551c6ea64..a0628dd4b 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs @@ -1,4 +1,4 @@ -/* +/* * The contents of this file are subject to the Initial * Developer's Public License Version 1.0 (the "License"); * you may not use this file except in compliance with the @@ -1144,12 +1144,14 @@ private void UpdateParameterValues(Descriptor descriptor) { parameter.NullFlag = 0; + var inputValue = NormalizeDomainParameterValue(commandParameter.InternalValue, parameter.RawDbDataType); + switch (parameter.DbDataType) { case DbDataType.Binary: { var blob = _statement.CreateBlob(); - blob.Write((byte[])commandParameter.InternalValue); + blob.Write((byte[])inputValue); parameter.DbValue.SetValue(blob.Id); } break; @@ -1157,13 +1159,13 @@ private void UpdateParameterValues(Descriptor descriptor) case DbDataType.Text: { var blob = _statement.CreateBlob(); - if (commandParameter.InternalValue is byte[]) + if (inputValue is byte[]) { - blob.Write((byte[])commandParameter.InternalValue); + blob.Write((byte[])inputValue); } else { - blob.Write((string)commandParameter.InternalValue); + blob.Write((string)inputValue); } parameter.DbValue.SetValue(blob.Id); } @@ -1182,21 +1184,21 @@ private void UpdateParameterValues(Descriptor descriptor) } parameter.ArrayHandle.Handle = 0; - parameter.ArrayHandle.Write((Array)commandParameter.InternalValue); + parameter.ArrayHandle.Write((Array)inputValue); parameter.DbValue.SetValue(parameter.ArrayHandle.Handle); } break; case DbDataType.Guid: - if (!(commandParameter.InternalValue is Guid) && !(commandParameter.InternalValue is byte[])) + if (!(inputValue is Guid) && !(inputValue is byte[])) { throw new InvalidOperationException("Incorrect Guid value."); } - parameter.DbValue.SetValue(commandParameter.InternalValue); + parameter.DbValue.SetValue(inputValue); break; default: - parameter.DbValue.SetValue(commandParameter.InternalValue); + parameter.DbValue.SetValue(inputValue); break; } } @@ -1240,12 +1242,14 @@ private async ValueTask UpdateParameterValuesAsync(Descriptor descriptor, Cancel { statementParameter.NullFlag = 0; + var inputValue = NormalizeDomainParameterValue(commandParameter.InternalValue, statementParameter.RawDbDataType); + switch (statementParameter.DbDataType) { case DbDataType.Binary: { var blob = _statement.CreateBlob(); - await blob.WriteAsync((byte[])commandParameter.InternalValue, cancellationToken).ConfigureAwait(false); + await blob.WriteAsync((byte[])inputValue, cancellationToken).ConfigureAwait(false); statementParameter.DbValue.SetValue(blob.Id); } break; @@ -1253,13 +1257,13 @@ private async ValueTask UpdateParameterValuesAsync(Descriptor descriptor, Cancel case DbDataType.Text: { var blob = _statement.CreateBlob(); - if (commandParameter.InternalValue is byte[]) + if (inputValue is byte[]) { - await blob.WriteAsync((byte[])commandParameter.InternalValue, cancellationToken).ConfigureAwait(false); + await blob.WriteAsync((byte[])inputValue, cancellationToken).ConfigureAwait(false); } else { - await blob.WriteAsync((string)commandParameter.InternalValue, cancellationToken).ConfigureAwait(false); + await blob.WriteAsync((string)inputValue, cancellationToken).ConfigureAwait(false); } statementParameter.DbValue.SetValue(blob.Id); } @@ -1278,21 +1282,21 @@ private async ValueTask UpdateParameterValuesAsync(Descriptor descriptor, Cancel } statementParameter.ArrayHandle.Handle = 0; - await statementParameter.ArrayHandle.WriteAsync((Array)commandParameter.InternalValue, cancellationToken).ConfigureAwait(false); + await statementParameter.ArrayHandle.WriteAsync((Array)inputValue, cancellationToken).ConfigureAwait(false); statementParameter.DbValue.SetValue(statementParameter.ArrayHandle.Handle); } break; case DbDataType.Guid: - if (!(commandParameter.InternalValue is Guid) && !(commandParameter.InternalValue is byte[])) + if (!(inputValue is Guid) && !(inputValue is byte[])) { throw new InvalidOperationException("Incorrect Guid value."); } - statementParameter.DbValue.SetValue(commandParameter.InternalValue); + statementParameter.DbValue.SetValue(inputValue); break; default: - statementParameter.DbValue.SetValue(commandParameter.InternalValue); + statementParameter.DbValue.SetValue(inputValue); break; } } @@ -1364,6 +1368,8 @@ private void Prepare(bool returnsSet) throw; } + ApplyDomainTypeMappings(innerConn); + // Add this command to the active command list innerConn.AddPreparedCommand(this); } @@ -1433,6 +1439,8 @@ private async Task PrepareAsync(bool returnsSet, CancellationToken cancellationT throw; } + await ApplyDomainTypeMappingsAsync(innerConn, cancellationToken).ConfigureAwait(false); + // Add this command to the active command list innerConn.AddPreparedCommand(this); } @@ -1579,5 +1587,73 @@ private void CheckCommand() } } + private static object NormalizeDomainParameterValue(object value, DbDataType rawTargetType) + { + // rawTargetType is always RawDbDataType (the wire-level SQL type). + // Input parameters have no relation/field name, so DomainNameResolver cannot + // set OverrideDataType on them via RDB$RELATION_FIELDS. We therefore work + // against the actual SQL type to convert bool → the correct numeric wire value. + if (value is bool b) + { + switch (rawTargetType) + { + case DbDataType.SmallInt: + return (short)(b ? 1 : 0); + case DbDataType.Integer: + return b ? 1 : 0; + case DbDataType.BigInt: + return b ? 1L : 0L; + case DbDataType.Numeric: + case DbDataType.Decimal: + return b ? 1m : 0m; + } + } + return value; + } + + private void ApplyDomainTypeMappings(FbConnectionInternal innerConn) + { + var opts = innerConn.ConnectionStringOptions; + if (opts == null || !opts.HasDomainTypeMappings) + return; + var resolver = innerConn.DomainResolver; + if (resolver.IsResolving) + return; + resolver.Resolve(_connection, EnumerateStatementFields(), opts.DomainTypeMappings); + } + + private async ValueTask ApplyDomainTypeMappingsAsync(FbConnectionInternal innerConn, CancellationToken cancellationToken) + { + var opts = innerConn.ConnectionStringOptions; + if (opts == null || !opts.HasDomainTypeMappings) + return; + var resolver = innerConn.DomainResolver; + if (resolver.IsResolving) + return; + await resolver.ResolveAsync(_connection, EnumerateStatementFields(), opts.DomainTypeMappings, cancellationToken).ConfigureAwait(false); + } + + private IEnumerable EnumerateStatementFields() + { + if (_statement == null) + yield break; + // Yield output fields (SELECT columns, SP output) — domain override applies here. + var fields = _statement.Fields; + if (fields != null) + { + for (var i = 0; i < fields.Count; i++) + yield return fields[i]; + } + // Also yield input parameters. Their Relation/Name are typically empty so + // DomainNameResolver.Collect() will skip them; NormalizeDomainParameterValue + // handles bool→numeric conversion via RawDbDataType independently. + var parameters = _statement.Parameters; + if (parameters != null) + { + for (var i = 0; i < parameters.Count; i++) + yield return parameters[i]; + } + } + #endregion } diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionInternal.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionInternal.cs index 370129b3d..d4a64d845 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionInternal.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionInternal.cs @@ -40,6 +40,7 @@ internal class FbConnectionInternal private ConnectionString _connectionStringOptions; private FbConnection _owningConnection; private FbEnlistmentNotification _enlistmentNotification; + private DomainNameResolver _domainResolver; #endregion @@ -81,6 +82,11 @@ public ConnectionString ConnectionStringOptions get { return _connectionStringOptions; } } + public DomainNameResolver DomainResolver + { + get { return _domainResolver ??= new DomainNameResolver(); } + } + public bool CancelDisabled { get; private set; } #endregion @@ -300,6 +306,7 @@ public void Disconnect() _db = null; _owningConnection = null; _connectionStringOptions = null; + _domainResolver = null; } } } @@ -318,6 +325,7 @@ public async Task DisconnectAsync(CancellationToken cancellationToken = default) _db = null; _owningConnection = null; _connectionStringOptions = null; + _domainResolver = null; } } } diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionStringBuilder.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionStringBuilder.cs index fa6433f38..9bf4edd6a 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionStringBuilder.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionStringBuilder.cs @@ -1,4 +1,4 @@ -/* +/* * The contents of this file are subject to the Initial * Developer's Public License Version 1.0 (the "License"); * you may not use this file except in compliance with the @@ -319,6 +319,26 @@ public int ParallelWorkers set { SetValue(Common.ConnectionString.DefaultKeyParallelWorkers, value); } } + [Category("Advanced")] + [DisplayName("Boolean Domains")] + [Description("Comma-separated SQL LIKE patterns. Columns whose Firebird domain name matches are reported as Boolean to ADO.NET, regardless of the underlying SQL type. '%' matches any sequence; '_' matches a single character. Example: 'D_BOOL%,IS\\_%'.")] + [DefaultValue(Common.ConnectionString.DefaultValueDomainPatterns)] + public string BooleanDomains + { + get { return Common.ConnectionString.GetString(GetKey(Common.ConnectionString.DefaultKeyBooleanDomains), base.TryGetValue, Common.ConnectionString.DefaultValueDomainPatterns); } + set { SetValue(Common.ConnectionString.DefaultKeyBooleanDomains, value); } + } + + [Category("Advanced")] + [DisplayName("Guid Domains")] + [Description("Comma-separated SQL LIKE patterns. Columns whose Firebird domain name matches are reported as Guid to ADO.NET, regardless of the underlying SQL type. '%' matches any sequence; '_' matches a single character. Example: 'GUID%'.")] + [DefaultValue(Common.ConnectionString.DefaultValueDomainPatterns)] + public string GuidDomains + { + get { return Common.ConnectionString.GetString(GetKey(Common.ConnectionString.DefaultKeyGuidDomains), base.TryGetValue, Common.ConnectionString.DefaultValueDomainPatterns); } + set { SetValue(Common.ConnectionString.DefaultKeyGuidDomains, value); } + } + #endregion #region Constructors diff --git a/src/FirebirdSql.Data.TestsBase/FbTestsSetup.cs b/src/FirebirdSql.Data.TestsBase/FbTestsSetup.cs index b9b844a49..d5b815b20 100644 --- a/src/FirebirdSql.Data.TestsBase/FbTestsSetup.cs +++ b/src/FirebirdSql.Data.TestsBase/FbTestsSetup.cs @@ -1,4 +1,4 @@ -/* +/* * The contents of this file are subject to the Initial * Developer's Public License Version 1.0 (the "License"); * you may not use this file except in compliance with the @@ -50,6 +50,7 @@ public static async Task SetUp(FbServerType serverType, bool compression, FbWire { await connection.OpenAsync(); var serverVersion = FbServerProperties.ParseServerVersion(connection.ServerVersion); + await CreateUserTypeDomains(connection); await CreateTables(connection, serverVersion); await CreateProcedures(connection, serverVersion); await CreateFunctions(connection, serverVersion); @@ -128,6 +129,40 @@ private static async Task CreateTables(FbConnection connection, Version serverVe { await command.ExecuteNonQueryAsync(); } + + await using (var command = new FbCommand(@" +RECREATE TABLE USER_TYPE_TEST ( + ID INTEGER NOT NULL PRIMARY KEY, + IS_ACTIVE D_BOOL, + OPT_FLAG D_BOOL_NULLABLE, + ROW_GUID D_GUID, + OPT_GUID D_GUID_NULLABLE +)", connection)) + { + await command.ExecuteNonQueryAsync(); + } + } + + private static async Task CreateUserTypeDomains(FbConnection connection) + { + var domains = new[] + { + ("D_BOOL", "SMALLINT NOT NULL CHECK (VALUE IN (0,1))"), + ("D_BOOL_DEFAULT1", "SMALLINT DEFAULT 1 NOT NULL CHECK (VALUE IN (0,1))"), + ("D_BOOL_DEFAULT0", "SMALLINT DEFAULT 0 NOT NULL CHECK (VALUE IN (0,1))"), + ("D_BOOL_NULLABLE", "SMALLINT CHECK (VALUE IN (0,1))"), + ("D_GUID", "CHAR(16) CHARACTER SET OCTETS NOT NULL"), + ("D_GUID_NULLABLE", "CHAR(16) CHARACTER SET OCTETS"), + }; + // CREATE OR ALTER DOMAIN does not exist in Firebird (any version). + // The test database is always created fresh so CREATE DOMAIN is correct. + foreach (var (name, definition) in domains) + { + await using (var command = new FbCommand($"CREATE DOMAIN {name} AS {definition}", connection)) + { + await command.ExecuteNonQueryAsync(); + } + } } private static async Task CreateProcedures(FbConnection connection, Version serverVersion)