From 83cf822f88a9585ade7c31c864188cbcdcb0746d Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 10:09:00 -0500 Subject: [PATCH 01/10] =?UTF-8?q?Bump=20JasperFx=202.0.0-alpha.1=20?= =?UTF-8?q?=E2=86=92=202.0.0-alpha.8,=20JasperFx.Events=20=E2=86=92=202.0.?= =?UTF-8?q?0-alpha.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls in the latest pre-release alphas of JasperFx 2.0 ahead of #270 implementation work. Verified clean build across the solution and all non-Docker test suites pass: - Weasel.Core.Tests: 6/6 - Weasel.Sqlite.Tests: 361/361 - Weasel.CommandLine.Tests: 23/23 (with Postgres container) Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9d385782..df97733d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,8 +8,8 @@ - - + + From 5ea60f382b646a227076eb4f0923116adbf13efb Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 10:21:30 -0500 Subject: [PATCH 02/10] #270 step 1: SchemaObjectBase + SequenceBase, refactor all 4 Sequence providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new base classes in Weasel.Core that consolidate the boilerplate every schema object reimplements today: - SchemaObjectBase: implements ISchemaObject with default AllNames() (yield Identifier), default "single COUNT(*) row" CreateDeltaAsync, and the standard FindDeltaAsync(DbConnection) pattern that builds a query command, executes it, resolves the delta, and closes the reader. ReadExistsCountAsync is a virtual hook that converts the count to long via Convert.ToInt64 — handles the int / long / decimal variation across SQL Server / MySQL / Oracle so no provider needs to override the delta logic anymore. - SequenceBase: extends SchemaObjectBase with the cross-provider sequence properties: nullable StartWith, plus Owner / OwnerColumn for providers that support OWNED BY. Refactors the 4 provider Sequence subclasses to inherit from SequenceBase: - Postgresql.Sequence: 84 → 55 LOC - SqlServer.Sequence: 86 → 55 LOC - Oracle.Sequence: 104 → 76 LOC (Oracle keeps its provider-specific OracleConnection FindDeltaAsync overload and its PL/SQL DROP wrapper) - MySql.Sequence: 65 → 58 LOC (gains the standard IInheritance, keeps its table-based emulation override) Each provider's concrete subclass now only contains the 3 things that genuinely vary per database — WriteCreateStatement, WriteDropStatement, ConfigureQueryCommand — plus a strongly-typed FindDeltaAsync overload (e.g. NpgsqlConnection) where the original surface had one. No behavioural change. All 15 Postgresql Sequence tests pass; all 6 SqlServer Sequence tests pass. Sets the foundation for FunctionBase, ViewBase, and other ISchemaObject implementations in subsequent commits. Refs #270. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Weasel.Core/SchemaObjectBase.cs | 105 ++++++++++++++++++++++++++++ src/Weasel.Core/SequenceBase.cs | 38 ++++++++++ src/Weasel.MySql/Sequence.cs | 55 +++++++-------- src/Weasel.Oracle/Sequence.cs | 60 +++++----------- src/Weasel.Postgresql/Sequence.cs | 59 ++++------------ src/Weasel.SqlServer/Sequence.cs | 61 ++++------------ 6 files changed, 213 insertions(+), 165 deletions(-) create mode 100644 src/Weasel.Core/SchemaObjectBase.cs create mode 100644 src/Weasel.Core/SequenceBase.cs diff --git a/src/Weasel.Core/SchemaObjectBase.cs b/src/Weasel.Core/SchemaObjectBase.cs new file mode 100644 index 00000000..7a6700a6 --- /dev/null +++ b/src/Weasel.Core/SchemaObjectBase.cs @@ -0,0 +1,105 @@ +using System.Data.Common; + +namespace Weasel.Core; + +/// +/// Abstract base class for implementations that share the +/// same boilerplate: a single qualified name as their identifier, a single result-set +/// "does this exist?" delta check, and the standard FindDeltaAsync pattern of +/// "build command → execute reader → produce delta → close reader". +/// +/// Provider-specific subclasses (Sequence, Function, View, Extension, …) inherit the +/// boilerplate and only override the parts that truly differ per database: +/// , , +/// , and optionally +/// for objects that need richer delta semantics +/// than "exists / does not exist". +/// +/// +public abstract class SchemaObjectBase: ISchemaObject +{ + protected SchemaObjectBase(DbObjectName identifier) + { + Identifier = identifier ?? throw new ArgumentNullException(nameof(identifier)); + } + + /// + public DbObjectName Identifier { get; protected set; } + + /// + public abstract void WriteCreateStatement(Migrator migrator, TextWriter writer); + + /// + public abstract void WriteDropStatement(Migrator rules, TextWriter writer); + + /// + public abstract void ConfigureQueryCommand(DbCommandBuilder builder); + + /// + /// + /// The default implementation handles the common "single COUNT(*) row" delta pattern: + /// if the reader yields zero or a count of 0, the object needs to be created; otherwise + /// it already exists and no migration is needed. Subclasses that need richer comparison + /// (e.g. function body diffing, view SQL diffing) should override; subclasses whose + /// COUNT(*) returns a different CLR type than (e.g. Oracle + /// returns ) should override only . + /// + public virtual async Task CreateDeltaAsync( + DbDataReader reader, CancellationToken ct = default) + { + if (!await reader.ReadAsync(ct).ConfigureAwait(false) || + await ReadExistsCountAsync(reader, ct).ConfigureAwait(false) == 0) + { + return new SchemaObjectDelta(this, SchemaPatchDifference.Create); + } + + return new SchemaObjectDelta(this, SchemaPatchDifference.None); + } + + /// + /// Reads the first column of the current row as a count of matching rows in the catalog. + /// The CLR type returned by COUNT(*) varies by provider: + /// SQL Server returns , MySQL/Npgsql return , and + /// Oracle returns . The default reads the raw value and converts + /// via , which handles all three. Subclasses + /// normally have no reason to override. + /// + protected virtual async Task ReadExistsCountAsync( + DbDataReader reader, CancellationToken ct) + { + var raw = await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false); + return Convert.ToInt64(raw); + } + + /// + /// + /// The vast majority of implementations expose exactly + /// one named database object — their own . Subclasses that + /// create additional named artifacts (a table that creates indexes, for example) + /// should override. + /// + public virtual IEnumerable AllNames() + { + yield return Identifier; + } + + /// + /// Standard "find delta against an open " pattern: + /// build a query command, execute it, ask to produce + /// a delta from the reader, close the reader, return the delta. Provider-specific + /// subclasses can expose a strongly-typed overload (e.g. + /// FindDeltaAsync(NpgsqlConnection, CancellationToken)) that simply forwards + /// to this method. + /// + public async Task FindDeltaAsync( + DbConnection conn, CancellationToken ct = default) + { + var builder = new DbCommandBuilder(conn); + ConfigureQueryCommand(builder); + + await using var reader = await conn.ExecuteReaderAsync(builder, ct).ConfigureAwait(false); + var result = await CreateDeltaAsync(reader, ct).ConfigureAwait(false); + await reader.CloseAsync().ConfigureAwait(false); + return result; + } +} diff --git a/src/Weasel.Core/SequenceBase.cs b/src/Weasel.Core/SequenceBase.cs new file mode 100644 index 00000000..f547a4e0 --- /dev/null +++ b/src/Weasel.Core/SequenceBase.cs @@ -0,0 +1,38 @@ +namespace Weasel.Core; + +/// +/// Shared base for provider-specific Sequence classes (PostgreSQL, SQL Server, +/// Oracle, MySQL). Owns the cross-provider properties — identifier, optional starting +/// value, optional owner column — and the standard +/// boilerplate. Concrete subclasses only implement the provider-specific DDL. +/// +public abstract class SequenceBase: SchemaObjectBase +{ + protected SequenceBase(DbObjectName identifier) : base(identifier) + { + } + + protected SequenceBase(DbObjectName identifier, long startWith) : base(identifier) + { + StartWith = startWith; + } + + /// + /// Optional starting value for the sequence. When null, the provider's default is used + /// (typically 1). + /// + public long? StartWith { get; set; } + + /// + /// Optional table that "owns" this sequence (PostgreSQL's + /// ALTER SEQUENCE … OWNED BY tbl.col). On providers that do not support sequence + /// ownership semantics this is typically ignored. + /// + public DbObjectName? Owner { get; set; } + + /// + /// Name of the column on that the sequence is bound to. Only + /// meaningful when is set. + /// + public string OwnerColumn { get; set; } = null!; +} diff --git a/src/Weasel.MySql/Sequence.cs b/src/Weasel.MySql/Sequence.cs index 031c7407..2ae6523f 100644 --- a/src/Weasel.MySql/Sequence.cs +++ b/src/Weasel.MySql/Sequence.cs @@ -1,41 +1,49 @@ -using System.Data.Common; using Weasel.Core; namespace Weasel.MySql; -public class Sequence: ISchemaObject +/// +/// MySQL sequence. Because MySQL 5.x doesn't have native sequences, this is emulated as +/// a single-row auto-increment table — the underlying database object is therefore a +/// TABLE, not a SEQUENCE. The fluent surface still mirrors the other providers' +/// for portability of caller code. +/// +public class Sequence: SequenceBase { - public Sequence(DbObjectName identifier) + public Sequence(DbObjectName identifier) : base(identifier, startWith: 1) { - Identifier = identifier; } - public Sequence(string sequenceName): this(DbObjectName.Parse(MySqlProvider.Instance, sequenceName)) + public Sequence(string sequenceName) + : this(DbObjectName.Parse(MySqlProvider.Instance, sequenceName)) { } - public DbObjectName Identifier { get; } - - public long StartWith { get; set; } = 1; + /// + /// Reserved for future use — MySQL's table-based emulation doesn't currently honor + /// a non-1 increment, but the property is here for fluent-API parity and may be + /// wired into the emulation later. + /// public long IncrementBy { get; set; } = 1; - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { - // MySQL 8.0+ supports sequences, but older versions don't - // Using a table-based sequence for broader compatibility + // MySQL 8.0+ supports sequences, but older versions don't. + // Using a table-based sequence for broader compatibility. + var seed = StartWith ?? 1; writer.WriteLine($"CREATE TABLE IF NOT EXISTS {Identifier.QualifiedName} ("); writer.WriteLine(" id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,"); - writer.WriteLine($" current_value BIGINT NOT NULL DEFAULT {StartWith}"); + writer.WriteLine($" current_value BIGINT NOT NULL DEFAULT {seed}"); writer.WriteLine(");"); - writer.WriteLine($"INSERT IGNORE INTO {Identifier.QualifiedName} (current_value) VALUES ({StartWith});"); + writer.WriteLine($"INSERT IGNORE INTO {Identifier.QualifiedName} (current_value) VALUES ({seed});"); } - public void WriteDropStatement(Migrator migrator, TextWriter writer) + public override void WriteDropStatement(Migrator migrator, TextWriter writer) { writer.WriteLine($"DROP TABLE IF EXISTS {Identifier.QualifiedName};"); } - public void ConfigureQueryCommand(Core.DbCommandBuilder builder) + public override void ConfigureQueryCommand(Core.DbCommandBuilder builder) { var schemaParam = builder.AddParameter(Identifier.Schema).ParameterName; var nameParam = builder.AddParameter(Identifier.Name).ParameterName; @@ -46,20 +54,5 @@ SELECT COUNT(*) FROM information_schema.tables "); } - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) - { - var exists = false; - - if (await reader.ReadAsync(ct).ConfigureAwait(false)) - { - exists = await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false) > 0; - } - - return new SchemaObjectDelta(this, exists ? SchemaPatchDifference.None : SchemaPatchDifference.Create); - } - - public IEnumerable AllNames() - { - yield return Identifier; - } + // No CreateDeltaAsync override needed — base class handles COUNT(*) -> long natively. } diff --git a/src/Weasel.Oracle/Sequence.cs b/src/Weasel.Oracle/Sequence.cs index 7dd64f44..7cf2b404 100644 --- a/src/Weasel.Oracle/Sequence.cs +++ b/src/Weasel.Oracle/Sequence.cs @@ -5,38 +5,24 @@ namespace Weasel.Oracle; -public class Sequence: ISchemaObject +public class Sequence: SequenceBase { - private readonly long? _startWith; - public Sequence(string identifier) + : base(DbObjectName.Parse(OracleProvider.Instance, identifier)) { - Identifier = DbObjectName.Parse(OracleProvider.Instance, identifier); } - public Sequence(DbObjectName identifier) + public Sequence(DbObjectName identifier) : base(identifier) { - Identifier = identifier; } - public Sequence(DbObjectName identifier, long startWith) + public Sequence(DbObjectName identifier, long startWith) : base(identifier, startWith) { - Identifier = identifier; - _startWith = startWith; } - public DbObjectName? Owner { get; set; } - public string OwnerColumn { get; set; } = null!; - public DbObjectName Identifier { get; } - - public IEnumerable AllNames() + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { - yield return Identifier; - } - - public void WriteCreateStatement(Migrator migrator, TextWriter writer) - { - var startsWith = _startWith ?? 1; + var startsWith = StartWith ?? 1; // Use PL/SQL exception handling to safely create sequence if it doesn't exist // This avoids race conditions where another process creates the sequence @@ -55,7 +41,7 @@ WHEN OTHERS THEN writer.WriteLine("/"); } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteDropStatement(Migrator rules, TextWriter writer) { writer.WriteLine($@" DECLARE @@ -70,7 +56,7 @@ IF v_count > 0 THEN "); } - public void ConfigureQueryCommand(DbCommandBuilder builder) + public override void ConfigureQueryCommand(DbCommandBuilder builder) { var schemaParam = builder.AddParameter(Identifier.Schema.ToUpperInvariant()).ParameterName; var nameParam = builder.AddParameter(Identifier.Name.ToUpperInvariant()).ParameterName; @@ -78,27 +64,13 @@ public void ConfigureQueryCommand(DbCommandBuilder builder) $"SELECT COUNT(*) FROM all_sequences WHERE sequence_owner = :{schemaParam} AND sequence_name = :{nameParam}"); } - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) - { - if (!await reader.ReadAsync(ct).ConfigureAwait(false) || - await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false) == 0) - { - return new SchemaObjectDelta(this, SchemaPatchDifference.Create); - } + // No CreateDeltaAsync / ReadExistsCountAsync override needed — base class's + // Convert.ToInt64-based reader handles Oracle's decimal COUNT(*) natively. - return new SchemaObjectDelta(this, SchemaPatchDifference.None); - } - - public async Task FindDeltaAsync(OracleConnection conn, CancellationToken ct = default) - { - var builder = new DbCommandBuilder(conn); - - ConfigureQueryCommand(builder); - - await using var reader = await conn.ExecuteReaderAsync(builder, ct).ConfigureAwait(false); - - var result = await CreateDeltaAsync(reader, ct).ConfigureAwait(false); - await reader.CloseAsync().ConfigureAwait(false); - return result; - } + /// + /// Oracle-specific overload accepting an . Forwards + /// to the base . + /// + public Task FindDeltaAsync(OracleConnection conn, CancellationToken ct = default) + => FindDeltaAsync((DbConnection)conn, ct); } diff --git a/src/Weasel.Postgresql/Sequence.cs b/src/Weasel.Postgresql/Sequence.cs index 7ce594e1..125d0ff2 100644 --- a/src/Weasel.Postgresql/Sequence.cs +++ b/src/Weasel.Postgresql/Sequence.cs @@ -1,43 +1,30 @@ -using System.Data.Common; using Npgsql; using Weasel.Core; using DbCommandBuilder = Weasel.Core.DbCommandBuilder; namespace Weasel.Postgresql; -public class Sequence: ISchemaObject +public class Sequence: SequenceBase { - private readonly long? _startWith; - public Sequence(string identifier) + : base(DbObjectName.Parse(PostgresqlProvider.Instance, identifier)) { - Identifier = DbObjectName.Parse(PostgresqlProvider.Instance, identifier); } public Sequence(DbObjectName identifier) + : base(PostgresqlObjectName.From(identifier)) { - Identifier = PostgresqlObjectName.From(identifier); } public Sequence(DbObjectName identifier, long startWith) + : base(PostgresqlObjectName.From(identifier), startWith) { - Identifier = PostgresqlObjectName.From(identifier); - _startWith = startWith; - } - - public DbObjectName? Owner { get; set; } - public string OwnerColumn { get; set; } = null!; - public DbObjectName Identifier { get; } - - public IEnumerable AllNames() - { - yield return Identifier; } - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { writer.WriteLine( - $"CREATE SEQUENCE {Identifier}{(_startWith.HasValue ? $" START {_startWith.Value}" : string.Empty)};"); + $"CREATE SEQUENCE {Identifier}{(StartWith.HasValue ? $" START {StartWith.Value}" : string.Empty)};"); if (Owner != null) { @@ -45,12 +32,12 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) } } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteDropStatement(Migrator rules, TextWriter writer) { writer.WriteLine($"DROP SEQUENCE IF EXISTS {Identifier};"); } - public void ConfigureQueryCommand(DbCommandBuilder builder) + public override void ConfigureQueryCommand(DbCommandBuilder builder) { var schemaParam = builder.AddParameter(Identifier.Schema).ParameterName; var nameParam = builder.AddParameter(Identifier.Name).ParameterName; @@ -58,27 +45,11 @@ public void ConfigureQueryCommand(DbCommandBuilder builder) $"select count(*) from information_schema.sequences where sequence_schema = :{schemaParam} and sequence_name = :{nameParam};"); } - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) - { - if (!await reader.ReadAsync(ct).ConfigureAwait(false) || - await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false) == 0) - { - return new SchemaObjectDelta(this, SchemaPatchDifference.Create); - } - - return new SchemaObjectDelta(this, SchemaPatchDifference.None); - } - - public async Task FindDeltaAsync(NpgsqlConnection conn, CancellationToken ct = default) - { - var builder = new DbCommandBuilder(conn); - - ConfigureQueryCommand(builder); - - await using var reader = await conn.ExecuteReaderAsync(builder, ct).ConfigureAwait(false); - - var result = await CreateDeltaAsync(reader, ct).ConfigureAwait(false); - await reader.CloseAsync().ConfigureAwait(false); - return result; - } + /// + /// Provider-specific overload that accepts a + /// for caller convenience. Forwards to the base + /// . + /// + public Task FindDeltaAsync(NpgsqlConnection conn, CancellationToken ct = default) + => FindDeltaAsync((System.Data.Common.DbConnection)conn, ct); } diff --git a/src/Weasel.SqlServer/Sequence.cs b/src/Weasel.SqlServer/Sequence.cs index 4f589450..12a73def 100644 --- a/src/Weasel.SqlServer/Sequence.cs +++ b/src/Weasel.SqlServer/Sequence.cs @@ -1,42 +1,27 @@ -using System.Data.Common; using Microsoft.Data.SqlClient; using Weasel.Core; using DbCommandBuilder = Weasel.Core.DbCommandBuilder; namespace Weasel.SqlServer; -public class Sequence: ISchemaObject +public class Sequence: SequenceBase { - private readonly long? _startWith; - public Sequence(string identifier) + : base(DbObjectName.Parse(SqlServerProvider.Instance, identifier)) { - Identifier = DbObjectName.Parse(SqlServerProvider.Instance, identifier); - } - - public Sequence(DbObjectName identifier) - { - Identifier = identifier; } - public Sequence(DbObjectName identifier, long startWith) + public Sequence(DbObjectName identifier) : base(identifier) { - Identifier = identifier; - _startWith = startWith; } - public DbObjectName? Owner { get; set; } - public string OwnerColumn { get; set; } = null!; - public DbObjectName Identifier { get; } - - public IEnumerable AllNames() + public Sequence(DbObjectName identifier, long startWith) : base(identifier, startWith) { - yield return Identifier; } - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { - var startsWith = _startWith ?? 1; + var startsWith = StartWith ?? 1; writer.WriteLine( $"CREATE SEQUENCE {Identifier} START WITH {startsWith};"); @@ -47,12 +32,12 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) } } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteDropStatement(Migrator rules, TextWriter writer) { writer.WriteLine($"DROP SEQUENCE IF EXISTS {Identifier};"); } - public void ConfigureQueryCommand(DbCommandBuilder builder) + public override void ConfigureQueryCommand(DbCommandBuilder builder) { var schemaParam = builder.AddParameter(Identifier.Schema).ParameterName; var nameParam = builder.AddParameter(Identifier.Name).ParameterName; @@ -60,27 +45,11 @@ public void ConfigureQueryCommand(DbCommandBuilder builder) $"select count(*) from sys.sequences inner join sys.schemas on sys.sequences.schema_id = sys.schemas.schema_id where sys.schemas.name = @{schemaParam} and sys.sequences.name = @{nameParam};"); } - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) - { - if (!await reader.ReadAsync(ct).ConfigureAwait(false) || - await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false) == 0) - { - return new SchemaObjectDelta(this, SchemaPatchDifference.Create); - } - - return new SchemaObjectDelta(this, SchemaPatchDifference.None); - } - - public async Task FindDeltaAsync(SqlConnection conn, CancellationToken ct = default) - { - var builder = new DbCommandBuilder(conn); - - ConfigureQueryCommand(builder); - - await using var reader = await conn.ExecuteReaderAsync(builder, ct).ConfigureAwait(false); - - var result = await CreateDeltaAsync(reader, ct).ConfigureAwait(false); - await reader.CloseAsync().ConfigureAwait(false); - return result; - } + /// + /// Provider-specific overload that accepts a for caller + /// convenience. Forwards to the base + /// . + /// + public Task FindDeltaAsync(SqlConnection conn, CancellationToken ct = default) + => FindDeltaAsync((System.Data.Common.DbConnection)conn, ct); } From 639628fed5c5e3fbd79e90f07e6eba569675df00 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 10:28:50 -0500 Subject: [PATCH 03/10] #270 step 2: FunctionBase, refactor PostgreSQL + SQL Server Function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the ISchemaObject boilerplate that both Function implementations were duplicating: - Constructor pattern (DbObjectName, body, optional drop statements) - IsRemoved flag with protected setter - Body(Migrator?) — routes through the provider's WriteCreateStatement - DropStatements() — returns pre-supplied drops, empty for IsRemoved, or ComputeDefaultDropStatements() (abstract per provider) - WriteDropStatement — iterates DropStatements() and writes each line - CreateDeltaAsync — reads existing function via ReadExistingFromReaderAsync (abstract per provider) and routes through CreateFunctionDelta (abstract) - AllNames, Identifier — inherited from SchemaObjectBase Each provider's concrete Function now only contains: - WriteCreateStatement (PG writes RawBody directly; SS wraps in EXEC sp_executesql) - ConfigureQueryCommand (pg_proc + pg_namespace vs. sys.sql_modules) - ReadExistingFromReaderAsync (PG reads 2 result sets; SS reads 1) - ComputeDefaultDropStatements (PG parses function signature; SS uses identifier) - GetDefaultMigrator (provider's migrator type) - ParseIdentifier / ParseSignature (PG has the signature parser; SS uses identifier-only) - FetchExistingAsync / FindDeltaAsync (strongly-typed connection overloads) - ForSql / ForRemoval static factories - PG-only: BuildTemplate / WriteTemplate (template-based function generation) Result: - Postgresql.Functions.Function: 230 → 185 LOC (-45) - SqlServer.Functions.Function: 155 → 109 LOC (-46) - Weasel.Core.FunctionBase: 0 → 133 LOC (new shared base) Net: 91 LOC removed from per-provider Function files, 133 LOC of new reusable infrastructure. All 32 Postgresql Function tests pass; all 20 SqlServer Function tests pass. Refs #270. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Weasel.Core/FunctionBase.cs | 133 ++++++++++++++++++++ src/Weasel.Postgresql/Functions/Function.cs | 129 +++++++------------ src/Weasel.SqlServer/Functions/Function.cs | 108 +++++----------- 3 files changed, 206 insertions(+), 164 deletions(-) create mode 100644 src/Weasel.Core/FunctionBase.cs diff --git a/src/Weasel.Core/FunctionBase.cs b/src/Weasel.Core/FunctionBase.cs new file mode 100644 index 00000000..69e2bdce --- /dev/null +++ b/src/Weasel.Core/FunctionBase.cs @@ -0,0 +1,133 @@ +using System.Data.Common; + +namespace Weasel.Core; + +/// +/// Shared base for provider-specific Function classes (PostgreSQL, SQL Server). +/// Owns the cross-provider state (body, optional pre-built drop statements, removal flag) +/// and the standard boilerplate that both providers were +/// reimplementing nearly verbatim: , +/// (delegating to ), +/// , and a base rule. +/// +public abstract class FunctionBase: SchemaObjectBase +{ + private readonly string[]? _dropStatements; + + protected FunctionBase(DbObjectName identifier, string? body) : base(identifier) + { + RawBody = body; + } + + protected FunctionBase(DbObjectName identifier, string body, string[] dropStatements) : base(identifier) + { + RawBody = body; + _dropStatements = dropStatements; + } + + /// + /// The raw body string the function was constructed with. Subclasses route this + /// through their provider-specific + /// (PostgreSQL writes it directly; SQL Server wraps it in EXEC sp_executesql). + /// + protected string? RawBody { get; } + + /// + /// True when the function has been marked for removal via the provider's + /// ForRemoval(...) factory. A removed function emits no + /// WriteCreateStatement output and its is empty + /// (the delta is expected to call WriteDropStatement on the actual function). + /// + public bool IsRemoved { get; protected set; } + + /// + /// Returns the body of the function as the create-statement text would emit it. + /// Both PostgreSQL and SQL Server implementations build this by routing through + /// . + /// + public string Body(Migrator? rules = null) + { + rules ??= GetDefaultMigrator(); + var writer = new StringWriter(); + WriteCreateStatement(rules, writer); + return writer.ToString(); + } + + /// + /// Subclasses supply a provider-appropriate default for the + /// parameterless call. Mainly used by tests and by the + /// FunctionDeltaBase diff path. + /// + protected abstract Migrator GetDefaultMigrator(); + + /// + /// Returns the DROP statements that will be emitted by + /// . Honors a pre-built array when the function + /// was constructed with one (e.g. fetched from the database with the drop SQL + /// supplied by the catalog query); returns an empty array when the function is + /// marked removed; otherwise delegates to + /// for the provider-specific shape (e.g. PostgreSQL needs the function signature, + /// SQL Server can use the identifier directly). + /// + public virtual string[] DropStatements() + { + if (_dropStatements?.Length > 0) + { + return _dropStatements; + } + + if (IsRemoved) + { + return Array.Empty(); + } + + return ComputeDefaultDropStatements(); + } + + /// + /// Build the DROP statement(s) for a function that wasn't given an explicit + /// drop list at construction time. Subclasses override with provider-specific + /// SQL (PostgreSQL parses out the function signature for the OID-disambiguated + /// drop; SQL Server uses the qualified identifier directly). + /// + protected abstract string[] ComputeDefaultDropStatements(); + + public override void WriteDropStatement(Migrator rules, TextWriter writer) + { + foreach (var dropStatement in DropStatements()) + { + writer.WriteLine(dropStatement); + } + } + + /// + /// Function-specific delta production: emits a -typed + /// delta (the provider's concrete FunctionDelta) by first reading the actual + /// function from the result set. Default behaviour treats absence as Create and + /// compares bodies for Update; subclasses can override + /// for the provider-specific catalog query + /// shape. + /// + public override async Task CreateDeltaAsync( + DbDataReader reader, CancellationToken ct = default) + { + var existing = await ReadExistingFromReaderAsync(reader, ct).ConfigureAwait(false); + return CreateFunctionDelta(existing); + } + + /// + /// Read the actual function from the catalog query result and return it (or null + /// if it does not exist). Subclass implementation depends entirely on the shape of + /// the output (PostgreSQL emits + /// two result sets; SQL Server emits one). + /// + protected abstract Task ReadExistingFromReaderAsync( + DbDataReader reader, CancellationToken ct); + + /// + /// Subclasses wrap the actual function in their provider-specific + /// FunctionDelta type (which inherits from + /// of the concrete function type). + /// + protected abstract ISchemaObjectDelta CreateFunctionDelta(FunctionBase? actual); +} diff --git a/src/Weasel.Postgresql/Functions/Function.cs b/src/Weasel.Postgresql/Functions/Function.cs index 0fa97010..08419c82 100644 --- a/src/Weasel.Postgresql/Functions/Function.cs +++ b/src/Weasel.Postgresql/Functions/Function.cs @@ -3,51 +3,34 @@ using JasperFx.Core; using Npgsql; using Weasel.Core; -using Weasel.Postgresql; using DbCommandBuilder = Weasel.Core.DbCommandBuilder; namespace Weasel.Postgresql.Functions; -public class Function: ISchemaObject +public class Function: FunctionBase { - private readonly string? _body; - private readonly string[]? _dropStatements; - public Function(DbObjectName identifier, string body, string[] dropStatements) + : base(PostgresqlObjectName.From(identifier, SchemaUtils.IdentifierUsage.Function), + body, dropStatements) { - _body = body; - _dropStatements = dropStatements; - Identifier = PostgresqlObjectName.From(identifier, SchemaUtils.IdentifierUsage.Function); } public Function(DbObjectName identifier, string? body) + : base(PostgresqlObjectName.From(identifier, SchemaUtils.IdentifierUsage.Function), body) { - _body = body; - Identifier = PostgresqlObjectName.From(identifier, SchemaUtils.IdentifierUsage.Function); } protected Function(DbObjectName identifier) + : base(PostgresqlObjectName.From(identifier, SchemaUtils.IdentifierUsage.Function), body: null) { - Identifier = PostgresqlObjectName.From(identifier, SchemaUtils.IdentifierUsage.Function); - } - - - public bool IsRemoved { get; protected set; } - - - public virtual void WriteCreateStatement(Migrator migrator, TextWriter writer) - { - writer.WriteLine(_body); } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { - foreach (var dropStatement in DropStatements()) writer.WriteLine(dropStatement); + writer.WriteLine(RawBody); } - public DbObjectName Identifier { get; } - - public void ConfigureQueryCommand(DbCommandBuilder builder) + public override void ConfigureQueryCommand(DbCommandBuilder builder) { var schemaParam = builder.AddParameter(Identifier.Schema).ParameterName; var nameParam = builder.AddParameter(Identifier.Name).ParameterName; @@ -67,17 +50,44 @@ FROM pg_proc p "); } - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) + protected override Migrator GetDefaultMigrator() => new PostgresqlMigrator(); + + protected override string[] ComputeDefaultDropStatements() { - var existing = await readExistingAsync(reader, ct).ConfigureAwait(false); - return new FunctionDelta(this, existing); + var signature = ParseSignature(Body()); + var drop = $"drop function if exists {signature};"; + return new[] { drop }; } - public IEnumerable AllNames() + protected override async Task ReadExistingFromReaderAsync( + DbDataReader reader, CancellationToken ct) { - yield return Identifier; + if (!await reader.ReadAsync(ct).ConfigureAwait(false)) + { + await reader.NextResultAsync(ct).ConfigureAwait(false); + return null; + } + + var existingFunction = await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false); + + if (string.IsNullOrEmpty(existingFunction)) + { + return null; + } + + await reader.NextResultAsync(ct).ConfigureAwait(false); + var drops = new List(); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + drops.Add(await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false)); + } + + return new Function(Identifier, existingFunction.TrimEnd() + ";", drops.ToArray()); } + protected override ISchemaObjectDelta CreateFunctionDelta(FunctionBase? actual) + => new FunctionDelta(this, (Function?)actual); + public static string ParseSignature(string body) { var functionIndex = body.IndexOf("FUNCTION", StringComparison.OrdinalIgnoreCase); @@ -123,69 +133,14 @@ public static DbObjectName ParseIdentifier(string functionSql) public async Task FetchExistingAsync(NpgsqlConnection conn, CancellationToken ct = default) { var builder = new DbCommandBuilder(conn); - ConfigureQueryCommand(builder); await using var reader = await conn.ExecuteReaderAsync(builder, ct).ConfigureAwait(false); - var result = await readExistingAsync(reader, ct).ConfigureAwait(false); + var result = await ReadExistingFromReaderAsync(reader, ct).ConfigureAwait(false); await reader.CloseAsync().ConfigureAwait(false); - return result; + return (Function?)result; } - private async Task readExistingAsync(DbDataReader reader, CancellationToken ct = default) - { - if (!await reader.ReadAsync(ct).ConfigureAwait(false)) - { - await reader.NextResultAsync(ct).ConfigureAwait(false); - return null; - } - - var existingFunction = await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false); - - if (string.IsNullOrEmpty(existingFunction)) - { - return null; - } - - await reader.NextResultAsync(ct).ConfigureAwait(false); - var drops = new List(); - while (await reader.ReadAsync(ct).ConfigureAwait(false)) - { - drops.Add(await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false)); - } - - return new Function(Identifier, existingFunction.TrimEnd() + ";", drops.ToArray()); - } - - public string Body(Migrator? rules = null) - { - rules ??= new PostgresqlMigrator(); - var writer = new StringWriter(); - WriteCreateStatement(rules, writer); - - return writer.ToString(); - } - - public string[] DropStatements() - { - if (_dropStatements?.Length > 0) - { - return _dropStatements; - } - - if (IsRemoved) - { - return Array.Empty(); - } - - var signature = ParseSignature(Body()); - - var drop = $"drop function if exists {signature};"; - - return new[] { drop }; - } - - public static Function ForSql(string sql) { var identifier = ParseIdentifier(sql); diff --git a/src/Weasel.SqlServer/Functions/Function.cs b/src/Weasel.SqlServer/Functions/Function.cs index bfa94adc..57663b6a 100644 --- a/src/Weasel.SqlServer/Functions/Function.cs +++ b/src/Weasel.SqlServer/Functions/Function.cs @@ -5,63 +5,60 @@ namespace Weasel.SqlServer.Functions; -public class Function: ISchemaObject +public class Function: FunctionBase { - private readonly string? _body; - private readonly string[]? _dropStatements; - - public Function(DbObjectName identifier, string body, string[] dropStatements) + : base(identifier, body, dropStatements) { - _body = body; - _dropStatements = dropStatements; - Identifier = identifier; } - public Function(DbObjectName identifier, string? body) + public Function(DbObjectName identifier, string? body) : base(identifier, body) { - _body = body; - Identifier = identifier; } - protected Function(DbObjectName identifier) + protected Function(DbObjectName identifier) : base(identifier, body: null) { - Identifier = identifier; } - - public bool IsRemoved { get; protected set; } - - - public virtual void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { - writer.WriteLine($"EXEC sp_executesql N'{_body}';"); + writer.WriteLine($"EXEC sp_executesql N'{RawBody}';"); } - public void WriteDropStatement(Migrator rules, TextWriter writer) - { - foreach (var dropStatement in DropStatements()) writer.WriteLine(dropStatement); - } - - public DbObjectName Identifier { get; } - - public void ConfigureQueryCommand(DbCommandBuilder builder) + public override void ConfigureQueryCommand(DbCommandBuilder builder) { var nameParam = builder.AddParameter(Identifier.ToString()).ParameterName; builder.Append($"SELECT sm.definition FROM sys.sql_modules AS sm WHERE sm.object_id = OBJECT_ID(@{nameParam})"); } - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) + protected override Migrator GetDefaultMigrator() => new SqlServerMigrator(); + + protected override string[] ComputeDefaultDropStatements() { - var existing = await readExistingAsync(reader, ct).ConfigureAwait(false); - return new FunctionDelta(this, existing); + var drop = $"drop function if exists {Identifier};"; + return new[] { drop }; } - public IEnumerable AllNames() + protected override async Task ReadExistingFromReaderAsync( + DbDataReader reader, CancellationToken ct) { - yield return Identifier; + if (!await reader.ReadAsync(ct).ConfigureAwait(false)) + { + return null; + } + + var existingFunction = await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false); + if (string.IsNullOrEmpty(existingFunction)) + { + return null; + } + + return new Function(Identifier, existingFunction.TrimEnd()); } + protected override ISchemaObjectDelta CreateFunctionDelta(FunctionBase? actual) + => new FunctionDelta(this, (Function?)actual); + public static DbObjectName ParseIdentifier(string functionSql) { var functionIndex = functionSql.IndexOf("FUNCTION", StringComparison.OrdinalIgnoreCase); @@ -74,13 +71,12 @@ public static DbObjectName ParseIdentifier(string functionSql) public async Task FetchExistingAsync(SqlConnection conn, CancellationToken ct = default) { var builder = new DbCommandBuilder(conn); - ConfigureQueryCommand(builder); await using var reader = await conn.ExecuteReaderAsync(builder, ct).ConfigureAwait(false); - var result = await readExistingAsync(reader, ct).ConfigureAwait(false); + var result = await ReadExistingFromReaderAsync(reader, ct).ConfigureAwait(false); await reader.CloseAsync().ConfigureAwait(false); - return result; + return (Function?)result; } public static Task FetchExistingAsync(SqlConnection conn, DbObjectName identifier, CancellationToken ct = default) @@ -89,48 +85,6 @@ public static DbObjectName ParseIdentifier(string functionSql) return function.FetchExistingAsync(conn, ct); } - private async Task readExistingAsync(DbDataReader reader, CancellationToken ct = default) - { - if (!await reader.ReadAsync(ct).ConfigureAwait(false)) - { - return null; - } - - var existingFunction = await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false); - if (string.IsNullOrEmpty(existingFunction)) - { - return null; - } - - return new Function(Identifier, existingFunction.TrimEnd()); - } - - public string Body(Migrator? rules = null) - { - rules ??= new SqlServerMigrator(); - var writer = new StringWriter(); - WriteCreateStatement(rules, writer); - - return writer.ToString(); - } - - public string[] DropStatements() - { - if (_dropStatements?.Length > 0) - { - return _dropStatements; - } - - if (IsRemoved) - { - return Array.Empty(); - } - - var drop = $"drop function if exists {Identifier};"; - return new[] { drop }; - } - - public static Function ForSql(string sql) { var identifier = ParseIdentifier(sql); From 2967556a3609c1d696e68542978016e82b5f6e0e Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 10:49:41 -0500 Subject: [PATCH 04/10] #270 step 3: ViewBase, refactor PostgreSQL + SQLite View, SQLite ViewDelta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Weasel.Core/ViewBase (extends SchemaObjectBase) owning the SELECT body (ViewSql), the MoveToSchema/WithSchema template-method pair, and the ToBasicCreateViewSql helper. Both PostgreSQL.View and SQLite.View now inherit from it and only carry the provider-specific DDL. PostgreSQL View keeps its protected virtual ViewType/ViewKind/GetCreationOptions hooks so MaterializedView continues to override "VIEW"→"MATERIALIZED VIEW", 'v'→'m', and USING . SQLite ViewDelta now inherits from SchemaObjectDelta instead of hand-rolling ISchemaObjectDelta. To preserve its "no previous state to restore" no-op semantics when Actual is null, SchemaObjectDelta. WriteRestorationOfPreviousState is now virtual (it was concrete and would NRE on a null Actual). No existing override conflicts — grep across src shows the new SQLite ViewDelta override is the only one. Tests pass with no behaviour change: - SQLite view tests: 38/38 (net10.0) - PostgreSQL view tests: 11/11 (net10.0) - PostgreSQL sequence + function + materialized-view tests: 47/47 - SQL Server sequence + function tests: 26/26 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Weasel.Core/ISchemaObject.cs | 2 +- src/Weasel.Core/ViewBase.cs | 58 +++++++++++++++++ src/Weasel.Postgresql/Views/View.cs | 65 ++++++------------- src/Weasel.Sqlite/Views/View.cs | 60 +++++------------- src/Weasel.Sqlite/Views/ViewDelta.cs | 93 +++++++++++++--------------- 5 files changed, 136 insertions(+), 142 deletions(-) create mode 100644 src/Weasel.Core/ViewBase.cs diff --git a/src/Weasel.Core/ISchemaObject.cs b/src/Weasel.Core/ISchemaObject.cs index 083dbc4a..82c46b31 100644 --- a/src/Weasel.Core/ISchemaObject.cs +++ b/src/Weasel.Core/ISchemaObject.cs @@ -108,7 +108,7 @@ public virtual void WriteRollback(Migrator rules, TextWriter writer) Actual!.WriteCreateStatement(rules, writer); } - public void WriteRestorationOfPreviousState(Migrator rules, TextWriter writer) + public virtual void WriteRestorationOfPreviousState(Migrator rules, TextWriter writer) { Actual!.WriteCreateStatement(rules, writer); } diff --git a/src/Weasel.Core/ViewBase.cs b/src/Weasel.Core/ViewBase.cs new file mode 100644 index 00000000..d8aba821 --- /dev/null +++ b/src/Weasel.Core/ViewBase.cs @@ -0,0 +1,58 @@ +namespace Weasel.Core; + +/// +/// Shared base for provider-specific View classes (PostgreSQL, SQLite). +/// Owns the cross-provider state (the view's SELECT body) and the standard +/// boilerplate both providers reimplement: (with a virtual +/// hook for the provider-specific DbObjectName +/// wrapping), , and the "DROP first, then CREATE" +/// template for . +/// +public abstract class ViewBase: SchemaObjectBase +{ + protected ViewBase(DbObjectName identifier, string viewSql) : base(identifier) + { + ViewSql = viewSql ?? throw new ArgumentNullException(nameof(viewSql)); + } + + /// + /// The SELECT statement (without CREATE VIEW … AS prefix) that defines this + /// view's contents. Exposed publicly so delta classes can normalize/compare it. + /// + public string ViewSql { get; } + + /// + /// Move this view to a different schema. Subclasses provide + /// to wrap the new identifier in their provider-specific + /// subclass (PostgreSQL's PostgresqlObjectName, + /// SQLite's SqliteObjectName). + /// + public void MoveToSchema(string schemaName) + { + Identifier = WithSchema(schemaName); + } + + /// + /// Construct a new identifier for this view in the named schema, wrapped in the + /// provider-specific subclass. + /// + protected abstract DbObjectName WithSchema(string schemaName); + + /// + /// Generate the CREATE VIEW SQL with the provider's default formatting rules + /// ("concise"). Useful for diagnostics. + /// + public string ToBasicCreateViewSql() + { + var writer = new StringWriter(); + var migrator = GetDefaultMigratorForBasicSql(); + WriteCreateStatement(migrator, writer); + return writer.ToString(); + } + + /// + /// Subclasses supply a provider-appropriate for the + /// no-argument helper. + /// + protected abstract Migrator GetDefaultMigratorForBasicSql(); +} diff --git a/src/Weasel.Postgresql/Views/View.cs b/src/Weasel.Postgresql/Views/View.cs index 4aaa9417..16cb1c98 100644 --- a/src/Weasel.Postgresql/Views/View.cs +++ b/src/Weasel.Postgresql/Views/View.cs @@ -4,62 +4,33 @@ namespace Weasel.Postgresql.Views; -public class View: ISchemaObject +public class View: ViewBase { - private readonly string viewSql; - - public View(string viewName, string viewSql): this(DbObjectName.Parse(PostgresqlProvider.Instance, viewName), viewSql) + public View(string viewName, string viewSql) + : this(DbObjectName.Parse(PostgresqlProvider.Instance, viewName), viewSql) { } public View(DbObjectName name, string viewSql) + : base(PostgresqlObjectName.From(name ?? throw new ArgumentNullException(nameof(name))), viewSql) { - if (name == null) - { - throw new ArgumentNullException(nameof(name)); - } - - Identifier = PostgresqlObjectName.From(name); - this.viewSql = viewSql ?? throw new ArgumentNullException(nameof(viewSql)); } - public DbObjectName Identifier { get; private set; } - protected virtual string ViewType => "VIEW"; protected virtual char ViewKind => 'v'; protected virtual string GetCreationOptions() => string.Empty; - /// - /// Mutate this view to change the identifier to being in a different schema - /// - /// - public void MoveToSchema(string schemaName) - { - Identifier = PostgresqlObjectName.From(new DbObjectName(schemaName, Identifier.Name)); - } + /// + protected override DbObjectName WithSchema(string schemaName) + => PostgresqlObjectName.From(new DbObjectName(schemaName, Identifier.Name)); - /// - /// Generate the CREATE VIEW SQL expression with default - /// DDL rules. This is useful for quick diagnostics - /// - /// - public string ToBasicCreateViewSql() - { - var writer = new StringWriter(); - var rules = new PostgresqlMigrator { Formatting = SqlFormatting.Concise }; - WriteCreateStatement(rules, writer); - - return writer.ToString(); - } - - public IEnumerable AllNames() - { - yield return Identifier; - } + /// + protected override Migrator GetDefaultMigratorForBasicSql() + => new PostgresqlMigrator { Formatting = SqlFormatting.Concise }; - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { WriteDropStatement(migrator, writer); @@ -68,16 +39,16 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) var viewIdentifierWithCreationOptions = string.IsNullOrWhiteSpace(creationOptions) ? viewIdentifier - : $"{viewIdentifier} {GetCreationOptions()}"; - writer.WriteLine($"CREATE {ViewType} {viewIdentifierWithCreationOptions} AS {viewSql};"); + : $"{viewIdentifier} {creationOptions}"; + writer.WriteLine($"CREATE {ViewType} {viewIdentifierWithCreationOptions} AS {ViewSql};"); } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteDropStatement(Migrator rules, TextWriter writer) { writer.WriteLine($"DROP {ViewType} IF EXISTS {Identifier.QualifiedName};"); } - public void ConfigureQueryCommand(Core.DbCommandBuilder builder) + public override void ConfigureQueryCommand(Core.DbCommandBuilder builder) { builder.Append("SELECT (CASE WHEN pg_has_role(c.relowner, 'USAGE'::text) THEN LTRIM(pg_get_viewdef(c.oid),' ') ELSE NULL::text END)::information_schema.character_data AS view_definition "); builder.Append("FROM pg_catalog.pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "); @@ -90,13 +61,13 @@ public void ConfigureQueryCommand(Core.DbCommandBuilder builder) builder.Append(";"); } - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken cancellationToken) + public override async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) { - if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + if (await reader.ReadAsync(ct).ConfigureAwait(false)) { var previousView = reader.GetString(0); //This is to support when users specify view SQL with/without colon. Postgres allways returns with semicolon. - var sanitizedViewSqlBody = viewSql.EndsWith(';') ? viewSql : viewSql + ";"; + var sanitizedViewSqlBody = ViewSql.EndsWith(';') ? ViewSql : ViewSql + ";"; if (string.Equals(previousView, sanitizedViewSqlBody, StringComparison.OrdinalIgnoreCase)) { return new SchemaObjectDelta(this, SchemaPatchDifference.None); diff --git a/src/Weasel.Sqlite/Views/View.cs b/src/Weasel.Sqlite/Views/View.cs index fb3a425a..fa7efb55 100644 --- a/src/Weasel.Sqlite/Views/View.cs +++ b/src/Weasel.Sqlite/Views/View.cs @@ -8,10 +8,8 @@ namespace Weasel.Sqlite.Views; /// Represents a SQLite view with support for creation, deletion, and delta detection. /// SQLite views are read-only and do not support materialized views. /// -public class View : ISchemaObject +public class View : ViewBase { - private readonly string _viewSql; - /// /// Create a view with the specified name and SQL definition /// @@ -28,34 +26,26 @@ public View(string viewName, string viewSql) /// Fully qualified view name /// The SELECT statement defining the view public View(DbObjectName identifier, string viewSql) + : base(identifier, viewSql) { - Identifier = identifier ?? throw new ArgumentNullException(nameof(identifier)); - _viewSql = viewSql ?? throw new ArgumentNullException(nameof(viewSql)); } - public DbObjectName Identifier { get; private set; } + /// + protected override DbObjectName WithSchema(string schemaName) + => new SqliteObjectName(schemaName, Identifier.Name); - public IEnumerable AllNames() - { - yield return Identifier; - } + /// + protected override Migrator GetDefaultMigratorForBasicSql() + => new SqliteMigrator { Formatting = SqlFormatting.Concise }; - /// - /// Change the view's schema (supports "main" and "temp" schemas in SQLite) - /// - public void MoveToSchema(string schemaName) - { - Identifier = new SqliteObjectName(schemaName, Identifier.Name); - } - - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { WriteDropStatement(migrator, writer); var viewIdentifier = Identifier.QualifiedName; // Ensure SQL ends with semicolon - var sql = _viewSql.TrimEnd(); + var sql = ViewSql.TrimEnd(); if (!sql.EndsWith(';')) { sql += ";"; @@ -64,12 +54,12 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) writer.WriteLine($"CREATE VIEW {viewIdentifier} AS {sql}"); } - public void WriteDropStatement(Migrator migrator, TextWriter writer) + public override void WriteDropStatement(Migrator migrator, TextWriter writer) { writer.WriteLine($"DROP VIEW IF EXISTS {Identifier.QualifiedName};"); } - public void ConfigureQueryCommand(Core.DbCommandBuilder builder) + public override void ConfigureQueryCommand(Core.DbCommandBuilder builder) { // SQLite stores view definitions in sqlite_master table var schema = Identifier.Schema; @@ -80,11 +70,11 @@ public void ConfigureQueryCommand(Core.DbCommandBuilder builder) builder.Append(";"); } - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken cancellationToken) + public override async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) { - if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + if (await reader.ReadAsync(ct).ConfigureAwait(false)) { - var existingSql = await reader.GetFieldValueAsync(0, cancellationToken).ConfigureAwait(false); + var existingSql = await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false); if (!string.IsNullOrEmpty(existingSql)) { @@ -93,7 +83,7 @@ public async Task CreateDeltaAsync(DbDataReader reader, Canc // Normalize both SQL statements for comparison (just compare the SELECT portion) var normalizedExisting = NormalizeSql(existingBody); - var normalizedExpected = NormalizeSql(_viewSql); + var normalizedExpected = NormalizeSql(ViewSql); if (string.Equals(normalizedExisting, normalizedExpected, StringComparison.OrdinalIgnoreCase)) { @@ -148,23 +138,7 @@ public async Task ExistsInDatabaseAsync(SqliteConnection connection, Cance return null; } - /// - /// Generate a basic CREATE VIEW SQL statement for diagnostics - /// - public string ToBasicCreateViewSql() - { - var writer = new StringWriter(); - var migrator = new SqliteMigrator { Formatting = SqlFormatting.Concise }; - WriteCreateStatement(migrator, writer); - return writer.ToString(); - } - - /// - /// Get the view SQL body - /// - public string ViewSql => _viewSql; - - private static string NormalizeSql(string sql) + internal static string NormalizeSql(string sql) { // Remove all whitespace for comparison purposes var normalized = sql diff --git a/src/Weasel.Sqlite/Views/ViewDelta.cs b/src/Weasel.Sqlite/Views/ViewDelta.cs index 59c19902..549759bb 100644 --- a/src/Weasel.Sqlite/Views/ViewDelta.cs +++ b/src/Weasel.Sqlite/Views/ViewDelta.cs @@ -3,46 +3,50 @@ namespace Weasel.Sqlite.Views; /// -/// Represents the difference between expected and actual view definitions +/// Represents the difference between expected and actual view definitions. +/// Inherits the standard boilerplate +/// (Expected/Actual properties, Difference computation via the protected +/// hook, and the SchemaObject = Expected mapping) and only +/// overrides the parts that are SQLite-view-specific: +/// +/// uses whitespace-insensitive SQL comparison via +/// . +/// delegates to , +/// which already emits a DROP+CREATE pair (SQLite has no ALTER VIEW). +/// handles the "view didn't exist before" case by +/// dropping the expected view rather than NRE'ing on a null Actual. +/// is a no-op when there was no +/// previous state (Actual is null) instead of throwing. +/// /// -public class ViewDelta : ISchemaObjectDelta +public class ViewDelta : SchemaObjectDelta { - private readonly View _expected; - private readonly View? _actual; - - public ViewDelta(View expected, View? actual) + public ViewDelta(View expected, View? actual) : base(expected, actual) { - _expected = expected ?? throw new ArgumentNullException(nameof(expected)); - _actual = actual; } - public ISchemaObject SchemaObject => _expected; - - public SchemaPatchDifference Difference + protected override SchemaPatchDifference compare(View expected, View? actual) { - get + // View doesn't exist in database + if (actual == null) { - // View doesn't exist in database - if (_actual == null) - { - return SchemaPatchDifference.Create; - } - - // Compare normalized SQL - var expectedSql = NormalizeSql(_expected.ViewSql); - var actualSql = NormalizeSql(_actual.ViewSql); + return SchemaPatchDifference.Create; + } - if (string.Equals(expectedSql, actualSql, StringComparison.OrdinalIgnoreCase)) - { - return SchemaPatchDifference.None; - } + // Compare normalized SQL + var expectedSql = View.NormalizeSql(expected.ViewSql); + var actualSql = View.NormalizeSql(actual.ViewSql); - // View exists but definition is different - return SchemaPatchDifference.Update; + if (string.Equals(expectedSql, actualSql, StringComparison.OrdinalIgnoreCase)) + { + return SchemaPatchDifference.None; } + + // View exists but definition is different + return SchemaPatchDifference.Update; } - public void WriteUpdate(Migrator migrator, TextWriter writer) + public override void WriteUpdate(Migrator migrator, TextWriter writer) { if (Difference == SchemaPatchDifference.None) { @@ -51,44 +55,31 @@ public void WriteUpdate(Migrator migrator, TextWriter writer) // For both Create and Update, we drop and recreate the view // SQLite doesn't support ALTER VIEW, so we always DROP and CREATE - _expected.WriteCreateStatement(migrator, writer); + // (View.WriteCreateStatement emits DROP IF EXISTS then CREATE.) + Expected.WriteCreateStatement(migrator, writer); } - public void WriteRollback(Migrator migrator, TextWriter writer) + public override void WriteRollback(Migrator migrator, TextWriter writer) { - if (_actual != null) + if (Actual != null) { // Restore the previous view definition - _actual.WriteCreateStatement(migrator, writer); + Actual.WriteCreateStatement(migrator, writer); } else { // View didn't exist before, so drop it - _expected.WriteDropStatement(migrator, writer); + Expected.WriteDropStatement(migrator, writer); } } - public void WriteRestorationOfPreviousState(Migrator migrator, TextWriter writer) + public override void WriteRestorationOfPreviousState(Migrator migrator, TextWriter writer) { - if (_actual != null) + if (Actual != null) { // Restore the previous view definition as it existed - _actual.WriteCreateStatement(migrator, writer); + Actual.WriteCreateStatement(migrator, writer); } - } - - private static string NormalizeSql(string sql) - { - // Remove all whitespace for comparison purposes - var normalized = sql - .Replace("\r\n", "") - .Replace("\n", "") - .Replace("\r", "") - .Replace("\t", "") - .Replace(" ", "") - .Trim() - .TrimEnd(';'); - - return normalized; + // else: no previous state to restore — no-op } } From e7afae8fdcc00b6e78cdf0d8d833ceb160bccf34 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 11:01:14 -0500 Subject: [PATCH 05/10] #270 step 4: pull ForeignKey shared methods into ForeignKeyBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five provider ForeignKey classes (PG, SQL Server, Oracle, MySQL, SQLite) were carrying ~120 LOC of near-identical boilerplate each. This commit consolidates the cross-provider parts into ForeignKeyBase: - Parse(string definition[, string? defaultSchema]) — fully shared; subclasses only supply ParseLinkedTable(string) to wrap the parsed table name in the provider-specific DbObjectName subclass. ParseCascadeClause helper reads ON DELETE / ON UPDATE clauses (PG keeps a `new` overload defaulting schema to "public" for catalog rows that hand back unqualified names). - LinkColumns(columnName, referencedName) — append a (dependent, principal) pair to ColumnNames/LinkedNames. MySQL's setter-driven List still works correctly through the base because the setters re-marshal. - ReadReferentialActions(onDelete, onUpdate) — translates catalog cascade text via a virtual ReadAction default that handles every spelling used across the five providers (CASCADE / RESTRICT / NO_ACTION / NO ACTION / SET_NULL / SET NULL / SET_DEFAULT / SET DEFAULT). The static *Provider.ReadAction methods stay in place for external callers. - Equals / GetHashCode — structural across same-provider FKs (name + column lists + LinkedTable + both cascades). Parametrised on virtual NameComparer/ColumnComparer so case-folding providers (Oracle, SQLite, MySQL) just override the comparers. Hash drops the buggy array-reference hashing the per-provider versions had — array identity hashing gave different hashes to structurally-equal FKs, breaking HashSet/Dictionary; omitting the arrays gives more collisions but keeps semantics correct. Equality is widened from the previous strict same-type to "same provider root" — Equals(object?) walks up the inheritance chain to find the immediate subclass of ForeignKeyBase (the provider's concrete ForeignKey class) and uses IsAssignableFrom, so consumer-supplied marker subclasses (Marten ships one as `ForeignKeyTest` in PG tests) still compare equal to plain provider FKs as they did pre-9.0, while cross-provider comparison still returns false. This restores a regression a strict GetType()==GetType() check would have introduced. MisconfiguredForeignKeyException was defined identically and unused in four providers; the canonical type now lives in Weasel.Core and the provider duplicates are removed. (The PG-specific InvalidForeignKeyException and TryToCorrectForLink stay where they are.) MySQL's IsEquivalentTo is kept as a forwarder to the new EqualsCore for backward compatibility. Not consolidated (deferred): WriteAddStatement / WriteDropStatement / ToDDL all take a provider-specific Table parameter, blocked on TableBase (step 9). The obsolete OnDelete/OnUpdate shim properties stay in the subclasses since their per-provider local CascadeAction enums are already marked [Obsolete] and scheduled for removal. Net diff: -189 LOC across the FK files. All tests pass: - PostgreSQL: 29/29 FK + 181/182 detecting_table_deltas (1 unrelated skip) - SQL Server: 22/22 FK + 31/31 detecting_table_deltas - SQLite: 24/24 FK + 361/361 full suite - Weasel.Core: 6/6 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Weasel.Core/ForeignKeyBase.cs | 270 +++++++++++++++++- .../MisconfiguredForeignKeyException.cs | 15 + src/Weasel.MySql/Tables/ForeignKey.cs | 60 ++-- src/Weasel.Oracle/Tables/ForeignKey.cs | 117 +------- src/Weasel.Postgresql/Tables/ForeignKey.cs | 117 +------- src/Weasel.SqlServer/Tables/ForeignKey.cs | 122 +------- src/Weasel.Sqlite/Tables/ForeignKey.cs | 151 +--------- 7 files changed, 339 insertions(+), 513 deletions(-) create mode 100644 src/Weasel.Core/MisconfiguredForeignKeyException.cs diff --git a/src/Weasel.Core/ForeignKeyBase.cs b/src/Weasel.Core/ForeignKeyBase.cs index 33d06146..fbd3e0eb 100644 --- a/src/Weasel.Core/ForeignKeyBase.cs +++ b/src/Weasel.Core/ForeignKeyBase.cs @@ -1,7 +1,39 @@ +using JasperFx.Core; + namespace Weasel.Core; /// -/// Base class for foreign key definitions across all database providers +/// Cross-provider base for foreign-key constraint definitions. Owns the columns, +/// the referenced table, the cascade actions, and the boilerplate that every +/// provider was reimplementing nearly verbatim: +/// +/// +/// — parse a catalog-supplied +/// "FOREIGN KEY (..) REFERENCES schema.tbl(..) [ON DELETE …] [ON UPDATE …]" +/// string. Subclasses only supply the provider-specific +/// hook for wrapping the table name in the +/// right subclass. +/// +/// +/// — append a (dependent, principal) column pair. +/// +/// +/// — translate catalog action text +/// into via a virtual +/// hook (subclasses route to the provider's +/// static ReadAction). +/// +/// +/// / — structural +/// equality across columns, linked table and cascades, parameterised by +/// the virtual / +/// so case-sensitive providers (PostgreSQL, SQL Server) and case-insensitive +/// providers (Oracle, SQLite, MySQL) reuse the same code. +/// +/// +/// Provider-specific / +/// are still per-subclass because they take a provider-specific Table; lifting +/// those is blocked on TableBase (#270 step 9). /// public abstract class ForeignKeyBase : INamed { @@ -39,4 +71,240 @@ protected ForeignKeyBase(string name) /// The cascade action to take when a referenced row is updated /// public CascadeAction UpdateAction { get; set; } = CascadeAction.NoAction; + + /// + /// How is compared for equality / hashing. PostgreSQL and + /// SQL Server use ; Oracle, SQLite, MySQL + /// fold case (catalog metadata is case-folded on those providers). + /// + protected virtual StringComparer NameComparer => StringComparer.Ordinal; + + /// + /// How / entries are + /// compared. Defaults to ordinal; the case-insensitive providers override. + /// + protected virtual StringComparer ColumnComparer => StringComparer.Ordinal; + + /// + /// Parse a foreign-key DDL fragment of the shape + /// "FOREIGN KEY (col1, col2) REFERENCES schema.tbl(col1, col2) [ON DELETE …] [ON UPDATE …]" + /// into this instance. The format is the lowest common denominator of every + /// provider's catalog query, so the body lives here. + /// Subclasses supply for the provider-specific + /// wrapping (PostgreSQL's PostgresqlObjectName, + /// SQL Server's SqlServerObjectName, etc.). + /// + /// The DDL fragment from the catalog query. + /// + /// When the parsed table name is unqualified, prefix this schema. PostgreSQL + /// callers pass "public"; other providers pass null (and rely on + /// the catalog already qualifying the name). + /// + public virtual void Parse(string definition, string? defaultSchema = null) + { + var open1 = definition.IndexOf('('); + var closed1 = definition.IndexOf(')'); + + ColumnNames = definition.Substring(open1 + 1, closed1 - open1 - 1).ToDelimitedArray(','); + + var open2 = definition.IndexOf('(', closed1); + var closed2 = definition.IndexOf(')', open2); + + LinkedNames = definition.Substring(open2 + 1, closed2 - open2 - 1).ToDelimitedArray(','); + + const string references = "REFERENCES"; + var tableStart = definition.IndexOf(references, StringComparison.OrdinalIgnoreCase) + references.Length; + + var tableName = definition.Substring(tableStart, open2 - tableStart).Trim(); + if (defaultSchema != null && !tableName.Contains('.')) + { + tableName = $"{defaultSchema}.{tableName}"; + } + LinkedTable = ParseLinkedTable(tableName); + + DeleteAction = ParseCascadeClause(definition, "ON DELETE"); + UpdateAction = ParseCascadeClause(definition, "ON UPDATE"); + } + + /// + /// Wrap the parsed table name in the provider-specific + /// subclass. Called from . + /// + protected abstract DbObjectName ParseLinkedTable(string tableName); + + /// + /// Scan a DDL fragment for an "ON DELETE x" or "ON UPDATE x" + /// clause and return the corresponding . Providers + /// that don't support a particular action (e.g. SQL Server has no RESTRICT) + /// simply never see that text in their catalog output, so the broader check + /// here is safe. + /// + protected static CascadeAction ParseCascadeClause(string definition, string prefix) + { + if (definition.ContainsIgnoreCase($"{prefix} CASCADE")) + { + return CascadeAction.Cascade; + } + if (definition.ContainsIgnoreCase($"{prefix} RESTRICT")) + { + return CascadeAction.Restrict; + } + if (definition.ContainsIgnoreCase($"{prefix} SET NULL")) + { + return CascadeAction.SetNull; + } + if (definition.ContainsIgnoreCase($"{prefix} SET DEFAULT")) + { + return CascadeAction.SetDefault; + } + return CascadeAction.NoAction; + } + + /// + /// Append a (dependent column, referenced column) pair to + /// and . Used by table + /// fluent APIs and by catalog-introspection codepaths that build the FK row + /// by row. + /// + public virtual void LinkColumns(string columnName, string referencedName) + { + if (ColumnNames == null) + { + ColumnNames = new[] { columnName }; + LinkedNames = new[] { referencedName }; + } + else + { + ColumnNames = ColumnNames.Append(columnName).ToArray(); + LinkedNames = LinkedNames.Append(referencedName).ToArray(); + } + } + + /// + /// Translate provider-supplied catalog cascade-action text into + /// . The default handles every spelling used + /// across PostgreSQL / SQL Server / Oracle / MySQL / SQLite catalog rows + /// (CASCADE / RESTRICT / NO_ACTION / NO ACTION / SET_NULL / SET NULL / + /// SET_DEFAULT / SET DEFAULT). Providers that don't support a particular + /// action simply never see that text in their catalog, so the broader + /// mapping here is safe. Override only if a provider emits a non-standard + /// spelling. + /// + protected virtual CascadeAction ReadAction(string description) + { + return description.ToUpperInvariant().Trim() switch + { + "CASCADE" => CascadeAction.Cascade, + "RESTRICT" => CascadeAction.Restrict, + "NO ACTION" or "NO_ACTION" => CascadeAction.NoAction, + "SET NULL" or "SET_NULL" => CascadeAction.SetNull, + "SET DEFAULT" or "SET_DEFAULT" => CascadeAction.SetDefault, + _ => CascadeAction.NoAction + }; + } + + /// + /// Populate and from + /// catalog text. Either side may be null (e.g. Oracle never has an + /// ON UPDATE clause, so its row's onUpdate is null). + /// + public void ReadReferentialActions(string? onDelete, string? onUpdate = null) + { + if (onDelete != null) + { + DeleteAction = ReadAction(onDelete); + } + + if (onUpdate != null) + { + UpdateAction = ReadAction(onUpdate); + } + } + + /// + /// Structural equality across foreign keys from the same provider: name, both + /// column lists, the referenced table, and both cascade actions. Uses the + /// virtual / so + /// providers that fold case (Oracle, SQLite, MySQL) get case-insensitive + /// matching for free. + /// + /// Two foreign keys are eligible for structural comparison when they share + /// the same provider root — the immediate subclass of + /// (e.g. Weasel.Postgresql.Tables.ForeignKey). + /// This matches the pre-9.0 per-provider Equals behaviour that allowed + /// consumer-supplied marker subclasses (Marten ships one) to compare equal to + /// plain provider foreign keys, while still rejecting cross-provider comparison. + /// + /// + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + if (ReferenceEquals(this, obj)) + { + return true; + } + if (obj is not ForeignKeyBase other) + { + return false; + } + if (!GetProviderRootType(GetType()).IsAssignableFrom(other.GetType())) + { + return false; + } + return EqualsCore(other); + } + + /// + /// Walks the inheritance chain from up to the first type + /// whose immediate base is — i.e. the provider's + /// concrete ForeignKey class. Used by to + /// allow consumer-defined subclasses of a provider's ForeignKey to compare + /// equal to plain provider foreign keys. + /// + private static Type GetProviderRootType(Type t) + { + while (t.BaseType != typeof(ForeignKeyBase)) + { + t = t.BaseType!; + } + return t; + } + + /// + /// Field-by-field comparison reused by and + /// available to subclasses that need to compose it (e.g. an existing + /// IsEquivalentTo wrapper). + /// + protected bool EqualsCore(ForeignKeyBase other) + { + return NameComparer.Equals(Name, other.Name) + && ColumnNames.SequenceEqual(other.ColumnNames, ColumnComparer) + && LinkedNames.SequenceEqual(other.LinkedNames, ColumnComparer) + && Equals(LinkedTable, other.LinkedTable) + && DeleteAction == other.DeleteAction + && UpdateAction == other.UpdateAction; + } + + /// + /// Hash is built from , and both + /// cascade actions. Array contents are intentionally omitted: previous per- + /// provider implementations hashed the array reference, which gave different + /// hashes for structurally-equal foreign keys — a real bug. Omitting the + /// arrays gives more hash collisions but keeps HashSet / Dictionary + /// correct. + /// + public override int GetHashCode() + { + unchecked + { + var hashCode = Name != null ? NameComparer.GetHashCode(Name) : 0; + hashCode = (hashCode * 397) ^ (LinkedTable != null ? LinkedTable.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (int)DeleteAction; + hashCode = (hashCode * 397) ^ (int)UpdateAction; + return hashCode; + } + } } diff --git a/src/Weasel.Core/MisconfiguredForeignKeyException.cs b/src/Weasel.Core/MisconfiguredForeignKeyException.cs new file mode 100644 index 00000000..4c13a034 --- /dev/null +++ b/src/Weasel.Core/MisconfiguredForeignKeyException.cs @@ -0,0 +1,15 @@ +namespace Weasel.Core; + +/// +/// Thrown when a foreign key definition is incomplete or inconsistent +/// (for example, mismatched column counts on the dependent and principal +/// sides). Previously defined separately by each provider's +/// ForeignKey file; in 9.0 the canonical type lives here and the +/// provider-specific duplicates were removed. +/// +public class MisconfiguredForeignKeyException: Exception +{ + public MisconfiguredForeignKeyException(string? message): base(message) + { + } +} diff --git a/src/Weasel.MySql/Tables/ForeignKey.cs b/src/Weasel.MySql/Tables/ForeignKey.cs index c3d6bff0..592e34b9 100644 --- a/src/Weasel.MySql/Tables/ForeignKey.cs +++ b/src/Weasel.MySql/Tables/ForeignKey.cs @@ -33,6 +33,12 @@ public override string[] LinkedNames } } + /// + protected override StringComparer NameComparer => StringComparer.OrdinalIgnoreCase; + + /// + protected override StringComparer ColumnComparer => StringComparer.OrdinalIgnoreCase; + #pragma warning disable CS0618 // Type or member is obsolete /// /// The cascade action to take when a referenced row is deleted @@ -79,11 +85,14 @@ private static CascadeAction ToLocalCascadeAction(Core.CascadeAction action) } #pragma warning restore CS0618 // Type or member is obsolete - public void LinkColumns(string columnName, string linkedName) - { - _columnNames.Add(columnName); - _linkedNames.Add(linkedName); - } + /// + /// + /// MySQL's catalog returns FK metadata as separate columns rather than a + /// pre-formatted DDL string, so Parse is never called in practice — + /// but the contract is here in case a caller does use it. + /// + protected override DbObjectName ParseLinkedTable(string tableName) + => DbObjectName.Parse(MySqlProvider.Instance, tableName); public string ToDDL(Table parent) { @@ -124,39 +133,10 @@ private static string GetCascadeActionSql(CascadeAction action) }; } - public void ReadReferentialActions(string onDelete, string onUpdate) - { - OnDelete = MySqlProvider.ReadAction(onDelete); - OnUpdate = MySqlProvider.ReadAction(onUpdate); - } - - public bool IsEquivalentTo(ForeignKey other) - { - if (!Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (LinkedTable?.QualifiedName != other.LinkedTable?.QualifiedName) - { - return false; - } - - if (!_columnNames.SequenceEqual(other._columnNames, StringComparer.OrdinalIgnoreCase)) - { - return false; - } - - if (!_linkedNames.SequenceEqual(other._linkedNames, StringComparer.OrdinalIgnoreCase)) - { - return false; - } - - if (OnDelete != other.OnDelete || OnUpdate != other.OnUpdate) - { - return false; - } - - return true; - } + /// + /// Pre-existing helper kept for backward compatibility. Equivalent to + /// on this type since 9.0; new callers + /// should prefer plain Equals. + /// + public bool IsEquivalentTo(ForeignKey other) => EqualsCore(other); } diff --git a/src/Weasel.Oracle/Tables/ForeignKey.cs b/src/Weasel.Oracle/Tables/ForeignKey.cs index dd37d7e9..8f5f356a 100644 --- a/src/Weasel.Oracle/Tables/ForeignKey.cs +++ b/src/Weasel.Oracle/Tables/ForeignKey.cs @@ -1,16 +1,8 @@ using JasperFx.Core; -using JasperFx.Core.Reflection; using Weasel.Core; namespace Weasel.Oracle.Tables; -public class MisconfiguredForeignKeyException: Exception -{ - public MisconfiguredForeignKeyException(string? message): base(message) - { - } -} - public class ForeignKey: ForeignKeyBase { private string[] _columnNames = null!; @@ -32,6 +24,12 @@ public override string[] LinkedNames set => _linkedNames = value.OrderBy(x => x).ToArray(); } + /// + protected override StringComparer NameComparer => StringComparer.OrdinalIgnoreCase; + + /// + protected override StringComparer ColumnComparer => StringComparer.OrdinalIgnoreCase; + #pragma warning disable CS0618 // Type or member is obsolete /// /// The cascade action to take when a referenced row is deleted @@ -77,84 +75,9 @@ private static CascadeAction ToLocalCascadeAction(Core.CascadeAction action) } #pragma warning restore CS0618 // Type or member is obsolete - protected bool Equals(ForeignKey other) - { - return string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase) && - ColumnNames.SequenceEqual(other.ColumnNames, StringComparer.OrdinalIgnoreCase) && - LinkedNames.SequenceEqual(other.LinkedNames, StringComparer.OrdinalIgnoreCase) && - Equals(LinkedTable, other.LinkedTable) && - OnDelete == other.OnDelete && OnUpdate == other.OnUpdate; - } - - public override bool Equals(object? obj) - { - if (ReferenceEquals(null, obj)) - { - return false; - } - - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (!obj.GetType().CanBeCastTo()) - { - return false; - } - - return Equals((ForeignKey)obj); - } - - public override int GetHashCode() - { - unchecked - { - var hashCode = Name != null ? Name.ToUpperInvariant().GetHashCode() : 0; - hashCode = (hashCode * 397) ^ (ColumnNames != null ? ColumnNames.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (LinkedNames != null ? LinkedNames.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (LinkedTable != null ? LinkedTable.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (int)OnDelete; - hashCode = (hashCode * 397) ^ (int)OnUpdate; - return hashCode; - } - } - - /// - /// Read the DDL definition from the server - /// - /// - public void Parse(string definition) - { - var open1 = definition.IndexOf('('); - var closed1 = definition.IndexOf(')'); - - ColumnNames = definition.Substring(open1 + 1, closed1 - open1 - 1).ToDelimitedArray(','); - - var open2 = definition.IndexOf('(', closed1); - var closed2 = definition.IndexOf(')', open2); - - LinkedNames = definition.Substring(open2 + 1, closed2 - open2 - 1).ToDelimitedArray(','); - - var references = "REFERENCES"; - var tableStart = definition.IndexOf(references, StringComparison.OrdinalIgnoreCase) + references.Length; - - var tableName = definition.Substring(tableStart, open2 - tableStart).Trim(); - LinkedTable = DbObjectName.Parse(OracleProvider.Instance, tableName); - - if (definition.ContainsIgnoreCase("ON DELETE CASCADE")) - { - OnDelete = CascadeAction.Cascade; - } - else if (definition.ContainsIgnoreCase("ON DELETE SET NULL")) - { - OnDelete = CascadeAction.SetNull; - } - else if (definition.ContainsIgnoreCase("ON DELETE SET DEFAULT")) - { - OnDelete = CascadeAction.SetDefault; - } - } + /// + protected override DbObjectName ParseLinkedTable(string tableName) + => DbObjectName.Parse(OracleProvider.Instance, tableName); public string ToDDL(Table parent) { @@ -177,26 +100,4 @@ public void WriteDropStatement(Table parent, TextWriter writer) { writer.WriteLine($"ALTER TABLE {parent.Identifier} DROP CONSTRAINT {Name}"); } - - public void LinkColumns(string columnName, string referencedName) - { - if (ColumnNames == null) - { - ColumnNames = new[] { columnName }; - LinkedNames = new[] { referencedName }; - } - else - { - ColumnNames = ColumnNames.Append(columnName).ToArray(); - LinkedNames = LinkedNames.Append(referencedName).ToArray(); - } - } - - public void ReadReferentialActions(string? onDelete) - { - if (onDelete != null) - { - OnDelete = OracleProvider.ReadAction(onDelete); - } - } } diff --git a/src/Weasel.Postgresql/Tables/ForeignKey.cs b/src/Weasel.Postgresql/Tables/ForeignKey.cs index 1fe87062..28139575 100644 --- a/src/Weasel.Postgresql/Tables/ForeignKey.cs +++ b/src/Weasel.Postgresql/Tables/ForeignKey.cs @@ -1,16 +1,8 @@ using JasperFx.Core; -using JasperFx.Core.Reflection; using Weasel.Core; namespace Weasel.Postgresql.Tables; -public class MisconfiguredForeignKeyException: Exception -{ - public MisconfiguredForeignKeyException(string? message): base(message) - { - } -} - public class ForeignKey: ForeignKeyBase { private string[] _columnNames = null!; @@ -78,110 +70,17 @@ private static CascadeAction ToLocalCascadeAction(Core.CascadeAction action) } #pragma warning restore CS0618 // Type or member is obsolete - protected bool Equals(ForeignKey other) - { - return Name == other.Name && ColumnNames.SequenceEqual(other.ColumnNames) && - LinkedNames.SequenceEqual(other.LinkedNames) && Equals(LinkedTable, other.LinkedTable) && - OnDelete == other.OnDelete && OnUpdate == other.OnUpdate; - } - - public override bool Equals(object? obj) - { - if (ReferenceEquals(null, obj)) - { - return false; - } - - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (!obj.GetType().CanBeCastTo()) - { - return false; - } - - return Equals((ForeignKey)obj); - } - - public override int GetHashCode() - { - unchecked - { - var hashCode = Name != null ? Name.GetHashCode() : 0; - hashCode = (hashCode * 397) ^ (ColumnNames != null ? ColumnNames.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (LinkedNames != null ? LinkedNames.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (LinkedTable != null ? LinkedTable.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (int)OnDelete; - hashCode = (hashCode * 397) ^ (int)OnUpdate; - return hashCode; - } - } + /// + protected override DbObjectName ParseLinkedTable(string tableName) + => DbObjectName.Parse(PostgresqlProvider.Instance, tableName); /// - /// Read the DDL definition from the server + /// PostgreSQL-specific convenience overload that defaults + /// to "public" when the catalog row hands back an unqualified table name. + /// Calls into for the shared body. /// - /// - /// - public void Parse(string definition, string schema = "public") - { - var open1 = definition.IndexOf('('); - var closed1 = definition.IndexOf(')'); - - ColumnNames = definition.Substring(open1 + 1, closed1 - open1 - 1).ToDelimitedArray(','); - - var open2 = definition.IndexOf('(', closed1); - var closed2 = definition.IndexOf(')', open2); - - LinkedNames = definition.Substring(open2 + 1, closed2 - open2 - 1).ToDelimitedArray(','); - - - var references = "REFERENCES"; - var tableStart = definition.IndexOf(references) + references.Length; - - var tableName = definition.Substring(tableStart, open2 - tableStart).Trim(); - if (!tableName.Contains('.')) - { - tableName = $"{schema}.{tableName}"; - } - - LinkedTable = DbObjectName.Parse(PostgresqlProvider.Instance, tableName); - - if (definition.ContainsIgnoreCase("ON DELETE CASCADE")) - { - OnDelete = CascadeAction.Cascade; - } - else if (definition.ContainsIgnoreCase("ON DELETE RESTRICT")) - { - OnDelete = CascadeAction.Restrict; - } - else if (definition.ContainsIgnoreCase("ON DELETE SET NULL")) - { - OnDelete = CascadeAction.SetNull; - } - else if (definition.ContainsIgnoreCase("ON DELETE SET DEFAULT")) - { - OnDelete = CascadeAction.SetDefault; - } - - if (definition.ContainsIgnoreCase("ON UPDATE CASCADE")) - { - OnUpdate = CascadeAction.Cascade; - } - else if (definition.ContainsIgnoreCase("ON UPDATE RESTRICT")) - { - OnUpdate = CascadeAction.Restrict; - } - else if (definition.ContainsIgnoreCase("ON UPDATE SET NULL")) - { - OnUpdate = CascadeAction.SetNull; - } - else if (definition.ContainsIgnoreCase("ON UPDATE SET DEFAULT")) - { - OnUpdate = CascadeAction.SetDefault; - } - } + public new void Parse(string definition, string schema = "public") + => base.Parse(definition, schema); public string ToDDL(Table parent) { diff --git a/src/Weasel.SqlServer/Tables/ForeignKey.cs b/src/Weasel.SqlServer/Tables/ForeignKey.cs index 96a5ad57..0c47f44b 100644 --- a/src/Weasel.SqlServer/Tables/ForeignKey.cs +++ b/src/Weasel.SqlServer/Tables/ForeignKey.cs @@ -1,16 +1,8 @@ using JasperFx.Core; -using JasperFx.Core.Reflection; using Weasel.Core; namespace Weasel.SqlServer.Tables; -public class MisconfiguredForeignKeyException: Exception -{ - public MisconfiguredForeignKeyException(string? message): base(message) - { - } -} - public class ForeignKey: ForeignKeyBase { private string[] _columnNames = null!; @@ -77,97 +69,9 @@ private static CascadeAction ToLocalCascadeAction(Core.CascadeAction action) } #pragma warning restore CS0618 // Type or member is obsolete - protected bool Equals(ForeignKey other) - { - return Name == other.Name && ColumnNames.SequenceEqual(other.ColumnNames) && - LinkedNames.SequenceEqual(other.LinkedNames) && Equals(LinkedTable, other.LinkedTable) && - OnDelete == other.OnDelete && OnUpdate == other.OnUpdate; - } - - public override bool Equals(object? obj) - { - if (ReferenceEquals(null, obj)) - { - return false; - } - - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (!obj.GetType().CanBeCastTo()) - { - return false; - } - - return Equals((ForeignKey)obj); - } - - public override int GetHashCode() - { - unchecked - { - var hashCode = Name != null ? Name.GetHashCode() : 0; - hashCode = (hashCode * 397) ^ (ColumnNames != null ? ColumnNames.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (LinkedNames != null ? LinkedNames.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (LinkedTable != null ? LinkedTable.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (int)OnDelete; - hashCode = (hashCode * 397) ^ (int)OnUpdate; - return hashCode; - } - } - - /// - /// Read the DDL definition from the server - /// - /// - /// - public void Parse(string definition) - { - var open1 = definition.IndexOf('('); - var closed1 = definition.IndexOf(')'); - - ColumnNames = definition.Substring(open1 + 1, closed1 - open1 - 1).ToDelimitedArray(','); - - var open2 = definition.IndexOf('(', closed1); - var closed2 = definition.IndexOf(')', open2); - - LinkedNames = definition.Substring(open2 + 1, closed2 - open2 - 1).ToDelimitedArray(','); - - - var references = "REFERENCES"; - var tableStart = definition.IndexOf(references) + references.Length; - - var tableName = definition.Substring(tableStart, open2 - tableStart).Trim(); - LinkedTable = DbObjectName.Parse(SqlServerProvider.Instance, tableName); - - if (definition.ContainsIgnoreCase("ON DELETE CASCADE")) - { - OnDelete = CascadeAction.Cascade; - } - else if (definition.ContainsIgnoreCase("ON DELETE SET NULL")) - { - OnDelete = CascadeAction.SetNull; - } - else if (definition.ContainsIgnoreCase("ON DELETE SET DEFAULT")) - { - OnDelete = CascadeAction.SetDefault; - } - - if (definition.ContainsIgnoreCase("ON UPDATE CASCADE")) - { - OnUpdate = CascadeAction.Cascade; - } - else if (definition.ContainsIgnoreCase("ON UPDATE SET NULL")) - { - OnUpdate = CascadeAction.SetNull; - } - else if (definition.ContainsIgnoreCase("ON UPDATE SET DEFAULT")) - { - OnUpdate = CascadeAction.SetDefault; - } - } + /// + protected override DbObjectName ParseLinkedTable(string tableName) + => DbObjectName.Parse(SqlServerProvider.Instance, tableName); public string ToDDL(Table parent) { @@ -192,24 +96,4 @@ public void WriteDropStatement(Table parent, TextWriter writer) { writer.WriteLine($"ALTER TABLE {parent.Identifier} DROP CONSTRAINT IF EXISTS {Name};"); } - - public void LinkColumns(string columnName, string referencedName) - { - if (ColumnNames == null) - { - ColumnNames = new[] { columnName }; - LinkedNames = new[] { referencedName }; - } - else - { - ColumnNames = ColumnNames.Append(columnName).ToArray(); - LinkedNames = LinkedNames.Append(referencedName).ToArray(); - } - } - - public void ReadReferentialActions(string onDelete, string onUpdate) - { - OnDelete = SqlServerProvider.ReadAction(onDelete); - OnUpdate = SqlServerProvider.ReadAction(onUpdate); - } } diff --git a/src/Weasel.Sqlite/Tables/ForeignKey.cs b/src/Weasel.Sqlite/Tables/ForeignKey.cs index 11d023cd..5ed4d1bd 100644 --- a/src/Weasel.Sqlite/Tables/ForeignKey.cs +++ b/src/Weasel.Sqlite/Tables/ForeignKey.cs @@ -1,16 +1,8 @@ using JasperFx.Core; -using JasperFx.Core.Reflection; using Weasel.Core; namespace Weasel.Sqlite.Tables; -public class MisconfiguredForeignKeyException: Exception -{ - public MisconfiguredForeignKeyException(string? message): base(message) - { - } -} - public class ForeignKey: ForeignKeyBase { private string[] _columnNames = null!; @@ -32,6 +24,18 @@ public override string[] LinkedNames set => _linkedNames = value.OrderBy(x => x).ToArray(); } + /// + protected override StringComparer NameComparer => StringComparer.OrdinalIgnoreCase; + + /// + protected override StringComparer ColumnComparer => StringComparer.OrdinalIgnoreCase; + + /// + /// SQLite uses directly — there's no + /// provider-local enum shim like PostgreSQL / SQL Server / Oracle / MySQL. + /// These aliases exist so call sites that still spell the cascade as + /// OnDelete / OnUpdate continue to compile. + /// public CascadeAction OnDelete { get => DeleteAction; @@ -44,107 +48,9 @@ public CascadeAction OnUpdate set => UpdateAction = value; } - protected bool Equals(ForeignKey other) - { - return string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase) && - ColumnNames.SequenceEqual(other.ColumnNames, StringComparer.OrdinalIgnoreCase) && - LinkedNames.SequenceEqual(other.LinkedNames, StringComparer.OrdinalIgnoreCase) && - Equals(LinkedTable, other.LinkedTable) && - DeleteAction == other.DeleteAction && UpdateAction == other.UpdateAction; - } - - public override bool Equals(object? obj) - { - if (ReferenceEquals(null, obj)) - { - return false; - } - - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (!obj.GetType().CanBeCastTo()) - { - return false; - } - - return Equals((ForeignKey)obj); - } - - public override int GetHashCode() - { - unchecked - { - var hashCode = Name != null ? Name.ToLowerInvariant().GetHashCode() : 0; - hashCode = (hashCode * 397) ^ (ColumnNames != null ? ColumnNames.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (LinkedNames != null ? LinkedNames.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (LinkedTable != null ? LinkedTable.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (int)DeleteAction; - hashCode = (hashCode * 397) ^ (int)UpdateAction; - return hashCode; - } - } - - /// - /// Read the DDL definition from the server - /// - /// - public void Parse(string definition) - { - var open1 = definition.IndexOf('('); - var closed1 = definition.IndexOf(')'); - - ColumnNames = definition.Substring(open1 + 1, closed1 - open1 - 1).ToDelimitedArray(','); - - var open2 = definition.IndexOf('(', closed1); - var closed2 = definition.IndexOf(')', open2); - - LinkedNames = definition.Substring(open2 + 1, closed2 - open2 - 1).ToDelimitedArray(','); - - var references = "REFERENCES"; - var tableStart = definition.IndexOf(references, StringComparison.OrdinalIgnoreCase) + references.Length; - - var tableName = definition.Substring(tableStart, open2 - tableStart).Trim(); - LinkedTable = DbObjectName.Parse(SqliteProvider.Instance, tableName); - - // Parse ON DELETE - if (definition.ContainsIgnoreCase("ON DELETE CASCADE")) - { - OnDelete = CascadeAction.Cascade; - } - else if (definition.ContainsIgnoreCase("ON DELETE SET NULL")) - { - OnDelete = CascadeAction.SetNull; - } - else if (definition.ContainsIgnoreCase("ON DELETE SET DEFAULT")) - { - OnDelete = CascadeAction.SetDefault; - } - else if (definition.ContainsIgnoreCase("ON DELETE RESTRICT")) - { - OnDelete = CascadeAction.Restrict; - } - - // Parse ON UPDATE - if (definition.ContainsIgnoreCase("ON UPDATE CASCADE")) - { - OnUpdate = CascadeAction.Cascade; - } - else if (definition.ContainsIgnoreCase("ON UPDATE SET NULL")) - { - OnUpdate = CascadeAction.SetNull; - } - else if (definition.ContainsIgnoreCase("ON UPDATE SET DEFAULT")) - { - OnUpdate = CascadeAction.SetDefault; - } - else if (definition.ContainsIgnoreCase("ON UPDATE RESTRICT")) - { - OnUpdate = CascadeAction.Restrict; - } - } + /// + protected override DbObjectName ParseLinkedTable(string tableName) + => DbObjectName.Parse(SqliteProvider.Instance, tableName); public string ToDDL(Table parent) { @@ -197,31 +103,4 @@ public void WriteDropStatement(Table parent, TextWriter writer) "SQLite does not support ALTER TABLE DROP CONSTRAINT for foreign keys. " + $"Table '{parent.Identifier}' must be recreated to remove foreign key '{Name}'."); } - - public void LinkColumns(string columnName, string referencedName) - { - if (ColumnNames == null) - { - ColumnNames = new[] { columnName }; - LinkedNames = new[] { referencedName }; - } - else - { - ColumnNames = ColumnNames.Append(columnName).ToArray(); - LinkedNames = LinkedNames.Append(referencedName).ToArray(); - } - } - - public void ReadReferentialActions(string? onDelete, string? onUpdate = null) - { - if (onDelete != null) - { - DeleteAction = SqliteProvider.ReadAction(onDelete); - } - - if (onUpdate != null) - { - UpdateAction = SqliteProvider.ReadAction(onUpdate); - } - } } From 02a64404e778400c8f9a1ee4dce8edad9ca87e64 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 11:05:31 -0500 Subject: [PATCH 06/10] #270 step 5: MySQL TableDelta now inherits SchemaObjectDelta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL.TableDelta was the only provider TableDelta still implementing ISchemaObjectDelta by hand (PG, SQL Server, Oracle, SQLite all inherit from SchemaObjectDelta
). This commit brings MySQL onto the same pattern so the five deltas now compose uniformly: - Constructor forwards (expected, actual) to base - Differencing happens in the protected compare(expected, actual) override (the base ctor calls it and stores the SchemaPatchDifference on Difference); side-effect-populates Columns/Indexes/ForeignKeys/ PrimaryKeyDifference as before - WriteUpdate / WriteRollback overrides preserve the MySQL-specific DDL (MODIFY COLUMN, backtick quoting, DROP FOREIGN KEY syntax, PartitionStrategy = SchemaPatchDifference.Invalid path) - WriteRestorationOfPreviousState overridden because MySQL has historically no-op'd when Actual is null (the base default would NRE) - Expected/Actual properties drop from this file — inherited from SchemaObjectDelta The public surface stays compatible: Columns / Indexes / ForeignKeys / PrimaryKeyDifference / HasChanges are still public (HasChanges is no longer required by ISchemaObjectDelta but is kept for callers that ask "anything to do?" without unpacking the Difference enum). All MySQL tests pass: 188/188 (33 integration tests against MySQL 8.0 container, 155 unit tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Weasel.MySql/Tables/TableDelta.cs | 71 ++++++++++++++------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/src/Weasel.MySql/Tables/TableDelta.cs b/src/Weasel.MySql/Tables/TableDelta.cs index ca696513..9476265b 100644 --- a/src/Weasel.MySql/Tables/TableDelta.cs +++ b/src/Weasel.MySql/Tables/TableDelta.cs @@ -3,17 +3,31 @@ namespace Weasel.MySql.Tables; -public class TableDelta: ISchemaObjectDelta +/// +/// MySQL table delta. Brought into the standard +/// shape in 9.0 so it composes uniformly with the other providers' deltas (PG, +/// SQL Server, Oracle, SQLite) — same constructor signature, same Expected/Actual +/// properties from the base, same protected hook that +/// populates the per-item deltas as a side-effect. The override surface is just +/// the MySQL-specific update / rollback DDL. +/// +public class TableDelta: SchemaObjectDelta
{ - public TableDelta(Table expected, Table? actual) + public TableDelta(Table expected, Table? actual): base(expected, actual) { - Expected = expected; - Actual = actual; + } + + public ItemDelta? Columns { get; private set; } + public ItemDelta? Indexes { get; private set; } + public ItemDelta? ForeignKeys { get; private set; } + public SchemaPatchDifference PrimaryKeyDifference { get; private set; } = SchemaPatchDifference.None; + + protected override SchemaPatchDifference compare(Table expected, Table? actual) + { if (actual == null) { - Difference = SchemaPatchDifference.Create; - return; + return SchemaPatchDifference.Create; } Columns = new ItemDelta( @@ -40,36 +54,21 @@ public TableDelta(Table expected, Table? actual) PrimaryKeyDifference = SchemaPatchDifference.Update; } - // Check partition differences + // Partition strategy can't be altered in place — flag as needing manual intervention if (expected.PartitionStrategy != actual.PartitionStrategy) { - Difference = SchemaPatchDifference.Invalid; - return; + return SchemaPatchDifference.Invalid; } - // Determine overall difference - if (HasChanges()) - { - Difference = SchemaPatchDifference.Update; - } - else - { - Difference = SchemaPatchDifference.None; - } + return HasChanges() ? SchemaPatchDifference.Update : SchemaPatchDifference.None; } - public Table Expected { get; } - public Table? Actual { get; } - - public ItemDelta? Columns { get; } - public ItemDelta? Indexes { get; } - public ItemDelta? ForeignKeys { get; } - - public SchemaPatchDifference PrimaryKeyDifference { get; } = SchemaPatchDifference.None; - - public ISchemaObject SchemaObject => Expected; - public SchemaPatchDifference Difference { get; } - + /// + /// True when at least one column, index, foreign key or the primary key + /// differs between Expected and Actual. Public because callers (e.g. + /// migration runners) want to query "anything to do?" without unpacking + /// the SchemaPatchDifference enum. + /// public bool HasChanges() { if (Actual == null) return true; @@ -82,7 +81,7 @@ public bool HasChanges() return false; } - public void WriteUpdate(Migrator migrator, TextWriter writer) + public override void WriteUpdate(Migrator migrator, TextWriter writer) { if (Difference == SchemaPatchDifference.Create) { @@ -176,7 +175,7 @@ public void WriteUpdate(Migrator migrator, TextWriter writer) } } - public void WriteRollback(Migrator migrator, TextWriter writer) + public override void WriteRollback(Migrator migrator, TextWriter writer) { if (Actual == null) { @@ -235,7 +234,13 @@ public void WriteRollback(Migrator migrator, TextWriter writer) } } - public void WriteRestorationOfPreviousState(Migrator migrator, TextWriter writer) + /// + /// The base default would throw NRE if + /// is null (which it is for a Create delta). MySQL has historically been + /// tolerant — a Create delta has no "previous state" to restore, so this + /// is a no-op rather than a throw. + /// + public override void WriteRestorationOfPreviousState(Migrator migrator, TextWriter writer) { Actual?.WriteCreateStatement(migrator, writer); } From 9c49c1eae4f3e9eea1e53822bc46966758a72f14 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 11:16:30 -0500 Subject: [PATCH 07/10] #270 step 7: extract DatabaseProvider memo-lookup helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five provider ResolveDatabaseType + ResolveXxxDbType methods were all carrying the same memo-lookup prologue: try DatabaseTypeMemo / ParameterTypeMemo; if absent and the type is Nullable, look up T instead, copy the result to the nullable's slot for next-time, and return. Only the fallback after that miss differs per provider (PG → Npgsql plugin type map; SS/Oracle/MySQL → NotSupportedException; SQLite → null/Text). Move the lookup-with-nullable-promote into DatabaseProvider as two shared protected helpers — ResolveDatabaseTypeFromMemo and ResolveParameterTypeFromMemo — and rewrite each provider's two Resolve* methods as one-or-two-liners that combine the helper with the provider- specific fallback. The audit also suggested templating determineParameterType but I held back: in SS/Oracle/MySQL/SQLite the post-Resolve branches (IsEnum, IsArray, DBNull, IsConstructedGenericType) are largely unreachable because their Resolve always returns a sentinel fallback rather than null. Removing that dead code would technically preserve behaviour but risks edge cases that aren't covered by the current tests, so I left those bodies alone. Net diff: -97 LOC across the five provider files. No behaviour change. Tests pass: - PostgreSQL: 69/69 (Provider + TypeMapping + ToParameterType filter) - SQL Server: 8/8 (+8 pre-existing skips) - SQLite: 45/45 - MySQL: 37/37 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Weasel.Core/DatabaseProvider.cs | 48 +++++++++++++++++++++ src/Weasel.MySql/MySqlProvider.cs | 29 ++----------- src/Weasel.Oracle/OracleProvider.cs | 29 ++----------- src/Weasel.Postgresql/PostgresqlProvider.cs | 22 +++++----- src/Weasel.SqlServer/SqlServerProvider.cs | 30 ++----------- src/Weasel.Sqlite/SqliteProvider.cs | 31 ++----------- 6 files changed, 76 insertions(+), 113 deletions(-) diff --git a/src/Weasel.Core/DatabaseProvider.cs b/src/Weasel.Core/DatabaseProvider.cs index bd6f6deb..35747a38 100644 --- a/src/Weasel.Core/DatabaseProvider.cs +++ b/src/Weasel.Core/DatabaseProvider.cs @@ -1,6 +1,7 @@ using System.Data.Common; using ImTools; using JasperFx.Core; +using JasperFx.Core.Reflection; using Weasel.Core.Names; namespace Weasel.Core; @@ -170,6 +171,53 @@ public void RegisterMapping(Type type, string databaseType, TParameterType? para ParameterTypeMemo.Swap(d => d.AddOrUpdate(type, parameterType)); } + /// + /// Shared memo lookup for the database-type string of a CLR type, with the + /// standard nullable-promote fallback (if T? is asked but T is + /// in the memo, copy the entry to T? and return it). Returns null + /// when neither the type nor its inner-nullable is mapped — subclasses then + /// apply provider-specific fallbacks (PG queries Npgsql's plugin type map, + /// SS/Oracle/MySQL throw, SQLite returns null to signal "TEXT/JSON"). + /// + protected string? ResolveDatabaseTypeFromMemo(Type type) + { + if (DatabaseTypeMemo.Value.TryFind(type, out var value)) + { + return value; + } + + if (type.IsNullable() && DatabaseTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var inner)) + { + DatabaseTypeMemo.Swap(d => d.AddOrUpdate(type, inner)); + return inner; + } + + return null; + } + + /// + /// Shared memo lookup for the provider parameter-type enum of a CLR type, + /// mirror of . Subclasses apply + /// their own fallback when this returns null (PG queries Npgsql plugin; + /// SS returns Variant; Oracle returns Varchar2; MySQL returns + /// VarChar; SQLite returns Text). + /// + protected TParameterType? ResolveParameterTypeFromMemo(Type type) + { + if (ParameterTypeMemo.Value.TryFind(type, out var value)) + { + return value; + } + + if (type.IsNullable() && ParameterTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var inner)) + { + ParameterTypeMemo.Swap(d => d.AddOrUpdate(type, inner)); + return inner; + } + + return null; + } + protected abstract Type[] determineClrTypesForParameterType(TParameterType dbType); public TParameter AddParameter(TCommand command, object? value, TParameterType? dbType = null) diff --git a/src/Weasel.MySql/MySqlProvider.cs b/src/Weasel.MySql/MySqlProvider.cs index 3f2e637a..10c28227 100644 --- a/src/Weasel.MySql/MySqlProvider.cs +++ b/src/Weasel.MySql/MySqlProvider.cs @@ -36,35 +36,14 @@ protected override void storeMappings() private string? ResolveDatabaseType(Type type) { - if (DatabaseTypeMemo.Value.TryFind(type, out var value)) - { - return value; - } - - if (!type.IsNullable() || - !DatabaseTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var databaseType)) - throw new NotSupportedException( - $"Weasel.MySql does not (yet) support database type mapping to {type.FullNameInCode()}"); - - DatabaseTypeMemo.Swap(d => d.AddOrUpdate(type, databaseType)); - return databaseType; + return ResolveDatabaseTypeFromMemo(type) + ?? throw new NotSupportedException( + $"Weasel.MySql does not (yet) support database type mapping to {type.FullNameInCode()}"); } private MySqlDbType? ResolveMySqlDbType(Type type) { - if (ParameterTypeMemo.Value.TryFind(type, out var value)) - { - return value; - } - - if (type.IsNullable() && - ParameterTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var parameterType)) - { - ParameterTypeMemo.Swap(d => d.AddOrUpdate(type, parameterType)); - return parameterType; - } - - return MySqlDbType.VarChar; + return ResolveParameterTypeFromMemo(type) ?? MySqlDbType.VarChar; } protected override Type[] determineClrTypesForParameterType(MySqlDbType dbType) diff --git a/src/Weasel.Oracle/OracleProvider.cs b/src/Weasel.Oracle/OracleProvider.cs index 61f12206..e899ab24 100644 --- a/src/Weasel.Oracle/OracleProvider.cs +++ b/src/Weasel.Oracle/OracleProvider.cs @@ -36,35 +36,14 @@ protected override void storeMappings() private string? ResolveDatabaseType(Type type) { - if (DatabaseTypeMemo.Value.TryFind(type, out var value)) - { - return value; - } - - if (!type.IsNullable() || - !DatabaseTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var databaseType)) - throw new NotSupportedException( - $"Weasel.Oracle does not (yet) support database type mapping to {type.FullNameInCode()}"); - - DatabaseTypeMemo.Swap(d => d.AddOrUpdate(type, databaseType)); - return databaseType; + return ResolveDatabaseTypeFromMemo(type) + ?? throw new NotSupportedException( + $"Weasel.Oracle does not (yet) support database type mapping to {type.FullNameInCode()}"); } private OracleDbType? ResolveOracleDbType(Type type) { - if (ParameterTypeMemo.Value.TryFind(type, out var value)) - { - return value; - } - - if (type.IsNullable() && - ParameterTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var parameterType)) - { - ParameterTypeMemo.Swap(d => d.AddOrUpdate(type, parameterType)); - return parameterType; - } - - return OracleDbType.Varchar2; + return ResolveParameterTypeFromMemo(type) ?? OracleDbType.Varchar2; } protected override Type[] determineClrTypesForParameterType(OracleDbType dbType) diff --git a/src/Weasel.Postgresql/PostgresqlProvider.cs b/src/Weasel.Postgresql/PostgresqlProvider.cs index e3d5a8e1..6b259f01 100644 --- a/src/Weasel.Postgresql/PostgresqlProvider.cs +++ b/src/Weasel.Postgresql/PostgresqlProvider.cs @@ -51,29 +51,31 @@ protected override void storeMappings() // custom npgsql mappings prior to execution. private string? ResolveDatabaseType(Type type) { - if (DatabaseTypeMemo.Value.TryFind(type, out var value)) + // Try the shared base memo (with nullable-promote) first; on a miss, ask + // Npgsql's type-mapping plugin (handles JsonB, NpgsqlRange, …) and + // cache the answer so subsequent lookups are O(1). + var cached = ResolveDatabaseTypeFromMemo(type); + if (cached != null) { - return value; + return cached; } - value = GetTypeMapping(type)?.DataTypeName; - + var value = GetTypeMapping(type)?.DataTypeName; DatabaseTypeMemo.Swap(d => d.AddOrUpdate(type, value)); - return value; } private NpgsqlDbType? ResolveNpgsqlDbType(Type type) { - if (ParameterTypeMemo.Value.TryFind(type, out var value)) + // Same pattern as ResolveDatabaseType but for the NpgsqlDbType enum. + var cached = ResolveParameterTypeFromMemo(type); + if (cached != null) { - return value; + return cached; } - value = GetTypeMapping(type)?.NpgsqlDbType; - + var value = GetTypeMapping(type)?.NpgsqlDbType; ParameterTypeMemo.Swap(d => d.AddOrUpdate(type, value)); - return value; } diff --git a/src/Weasel.SqlServer/SqlServerProvider.cs b/src/Weasel.SqlServer/SqlServerProvider.cs index 30765492..d1c18fb6 100644 --- a/src/Weasel.SqlServer/SqlServerProvider.cs +++ b/src/Weasel.SqlServer/SqlServerProvider.cs @@ -37,36 +37,14 @@ protected override void storeMappings() // custom Sql mappings prior to execution. private string? ResolveDatabaseType(Type type) { - if (DatabaseTypeMemo.Value.TryFind(type, out var value)) - { - return value; - } - - if (!type.IsNullable() || - !DatabaseTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var databaseType)) - throw new NotSupportedException( - $"Weasel.SqlServer does not (yet) support database type mapping to {type.FullNameInCode()}"); - - DatabaseTypeMemo.Swap(d => d.AddOrUpdate(type, databaseType)); - return databaseType; - + return ResolveDatabaseTypeFromMemo(type) + ?? throw new NotSupportedException( + $"Weasel.SqlServer does not (yet) support database type mapping to {type.FullNameInCode()}"); } private SqlDbType? ResolveSqlDbType(Type type) { - if (ParameterTypeMemo.Value.TryFind(type, out var value)) - { - return value; - } - - if (type.IsNullable() && - ParameterTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var parameterType)) - { - ParameterTypeMemo.Swap(d => d.AddOrUpdate(type, parameterType)); - return parameterType; - } - - return SqlDbType.Variant; + return ResolveParameterTypeFromMemo(type) ?? SqlDbType.Variant; } diff --git a/src/Weasel.Sqlite/SqliteProvider.cs b/src/Weasel.Sqlite/SqliteProvider.cs index 0befa9ae..292184cb 100644 --- a/src/Weasel.Sqlite/SqliteProvider.cs +++ b/src/Weasel.Sqlite/SqliteProvider.cs @@ -47,37 +47,14 @@ protected override void storeMappings() private string? ResolveDatabaseType(Type type) { - if (DatabaseTypeMemo.Value.TryFind(type, out var value)) - { - return value; - } - - if (!type.IsNullable() || - !DatabaseTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var databaseType)) - { - // For unmapped types, default to TEXT for JSON storage - return null; - } - - DatabaseTypeMemo.Swap(d => d.AddOrUpdate(type, databaseType)); - return databaseType; + // Unmapped types fall through to TEXT in GetDatabaseType (for JSON storage), + // so we just return null on a memo miss and let the caller decide. + return ResolveDatabaseTypeFromMemo(type); } private SqliteType? ResolveSqliteType(Type type) { - if (ParameterTypeMemo.Value.TryFind(type, out var value)) - { - return value; - } - - if (type.IsNullable() && - ParameterTypeMemo.Value.TryFind(type.GetInnerTypeFromNullable(), out var parameterType)) - { - ParameterTypeMemo.Swap(d => d.AddOrUpdate(type, parameterType)); - return parameterType; - } - - return SqliteType.Text; + return ResolveParameterTypeFromMemo(type) ?? SqliteType.Text; } protected override Type[] determineClrTypesForParameterType(SqliteType dbType) From 1319cf44de78968aea90661e67f5f66ee50e3315 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 12:21:42 -0500 Subject: [PATCH 08/10] #270 step 8: prototype IDdlSyntaxStrategy on PostgreSQL + SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces IDdlSyntaxStrategy in Weasel.Core — the pluggable strategy object the #270 plan calls out for owning provider-specific DDL syntax decisions. Step 8 deliberately covers only the parts of CREATE / DROP that genuinely differ between PostgreSQL and SQLite (the two providers at opposite ends of the feature spectrum, per the audit's "prototype against the extremes" guidance). If the shape holds for those two, step 9 will extend it across the remaining three providers when the full CREATE algorithm moves into TableBase. Strategy surface (intentionally minimal for the prototype): QuoteIdentifier(name) — provider quoting rules WriteDropTable(writer, identifier) — PG appends CASCADE, SQLite doesn't WriteCreateTableHeader(writer, id, style) — CREATE TABLE [IF NOT EXISTS] x ( InlineForeignKeyConstraints — true for SQLite (no ALTER ADD FK) AutoIncrementToken — SERIAL / AUTOINCREMENT / ... StatementTerminator — ";", or "/" for Oracle PostgresqlDdlSyntax (singleton) implements the PG side; SqliteDdlSyntax the SQLite side. Both Table classes expose Syntax => instance and route WriteDropStatement plus the CREATE-header part of WriteCreateStatement through it. The remaining CREATE body (columns / PK / inline FKs / indexes) is unchanged for now — it'll move into TableBase in step 9 once we've validated the strategy shape is workable. This is a behaviour-preserving change: the emitted SQL is byte-for-byte identical to before. Tests pass: - PostgreSQL: 185/188 (3 pre-existing concurrent-index skips) - SQLite: 361/361 (full suite) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Weasel.Core/IDdlSyntaxStrategy.cs | 87 ++++++++++++++++++++ src/Weasel.Postgresql/PostgresqlDdlSyntax.cs | 69 ++++++++++++++++ src/Weasel.Postgresql/Tables/Table.cs | 18 ++-- src/Weasel.Sqlite/SqliteDdlSyntax.cs | 72 ++++++++++++++++ src/Weasel.Sqlite/Tables/Table.cs | 21 +++-- 5 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 src/Weasel.Core/IDdlSyntaxStrategy.cs create mode 100644 src/Weasel.Postgresql/PostgresqlDdlSyntax.cs create mode 100644 src/Weasel.Sqlite/SqliteDdlSyntax.cs diff --git a/src/Weasel.Core/IDdlSyntaxStrategy.cs b/src/Weasel.Core/IDdlSyntaxStrategy.cs new file mode 100644 index 00000000..001a7066 --- /dev/null +++ b/src/Weasel.Core/IDdlSyntaxStrategy.cs @@ -0,0 +1,87 @@ +namespace Weasel.Core; + +/// +/// Pluggable strategy for the provider-specific DDL syntax decisions that +/// vary between databases. The intent is to let a single shared algorithm in +/// TableBase drive the CREATE / DROP / column-rendering flow, with the +/// strategy supplying the actual SQL tokens. +/// +/// Status: prototype. Step 8 of #270 introduces this interface and wires +/// it through PostgreSQL and SQLite — the two providers at the extremes of +/// feature support. If the shape holds for those two, step 9 (TableBase + +/// canonical ColumnExpression) extends it to the remaining three providers +/// and migrates the full CREATE algorithm here. Until then the surface is +/// deliberately small: it covers the parts of Table.WriteCreateStatement +/// / WriteDropStatement that genuinely differ between PG and SQLite +/// (DROP framing, CREATE header, whether FKs are inlined, auto-increment +/// spelling, statement terminator), not the parts that are identical. +/// +/// +/// Why not just more virtual methods on a base? The audit (see #270) +/// identified that ~85% of Table.WriteCreateStatement is the same +/// algorithm across providers — only the syntax tokens vary. A strategy +/// object is easier to test in isolation, easier to mock for unit tests of +/// the algorithm, and keeps TableBase from accumulating ~12 abstract +/// hooks. The trade-off is one extra indirection per token; given DDL is +/// emitted at migration time and never on a hot path, that's fine. +/// +/// +public interface IDdlSyntaxStrategy +{ + /// + /// Quote an identifier (column name, constraint name, etc.) per the + /// provider's rules. PostgreSQL uses double-quotes, MySQL backticks, + /// SQL Server square-brackets, SQLite double-quotes. + /// + string QuoteIdentifier(string name); + + /// + /// Write the full DROP TABLE statement(s) for an existing table. Includes + /// the terminator. PostgreSQL appends CASCADE (to drop dependent + /// views / foreign keys / sequences atomically); SQLite has no CASCADE + /// and simply emits DROP TABLE IF EXISTS …;. + /// + void WriteDropTable(TextWriter writer, DbObjectName identifier); + + /// + /// Write the CREATE TABLE header line up to and including the open + /// paren — e.g. CREATE TABLE IF NOT EXISTS "x" (. Caller writes + /// the column body and the close. + /// + /// + /// When , the caller has + /// already emitted a DROP via and the header + /// should omit IF NOT EXISTS. When + /// , the header should + /// include the guard. + /// + void WriteCreateTableHeader(TextWriter writer, DbObjectName identifier, CreationStyle style); + + /// + /// True when foreign-key CONSTRAINT … FOREIGN KEY (…) REFERENCES … + /// clauses must be emitted inline inside the CREATE TABLE body (SQLite — + /// it has no ALTER TABLE ADD CONSTRAINT for FKs). False when FKs + /// are emitted as separate ALTER TABLE ADD CONSTRAINT statements + /// after the CREATE (PostgreSQL, SQL Server, Oracle, MySQL). + /// + bool InlineForeignKeyConstraints { get; } + + /// + /// Token spelling for column-level auto-increment / identity: PostgreSQL + /// SERIAL / BIGSERIAL, SQLite AUTOINCREMENT, SQL + /// Server / Oracle IDENTITY(1,1), MySQL AUTO_INCREMENT. + /// Surfaced here so a unified ColumnExpression.AutoIncrement() + /// fluent call in Weasel.Core can resolve the right SQL token + /// via the active strategy (step 10 of #270 standardises the fluent + /// naming on AutoIncrement and keeps PG's Serial() as an + /// [Obsolete] alias). + /// + string AutoIncrementToken { get; } + + /// + /// Per-statement terminator. ";" on every provider except Oracle, + /// where a PL/SQL-style "/" on its own line terminates each + /// statement in a script. Used by the batched DDL writers. + /// + string StatementTerminator { get; } +} diff --git a/src/Weasel.Postgresql/PostgresqlDdlSyntax.cs b/src/Weasel.Postgresql/PostgresqlDdlSyntax.cs new file mode 100644 index 00000000..d762789a --- /dev/null +++ b/src/Weasel.Postgresql/PostgresqlDdlSyntax.cs @@ -0,0 +1,69 @@ +using Weasel.Core; + +namespace Weasel.Postgresql; + +/// +/// PostgreSQL implementation of . +/// +/// Wired through Tables.Table.WriteDropStatement and the CREATE-TABLE +/// header in WriteCreateStatement as part of #270 step 8 (prototype). +/// Once TableBase lands in step 9 the shared CREATE algorithm will +/// consume the full strategy from Weasel.Core and these per-provider +/// Table.WriteCreateStatement overrides shrink to provider-specific +/// extras (partitioning, etc.). +/// +/// +public sealed class PostgresqlDdlSyntax: IDdlSyntaxStrategy +{ + /// Process-wide singleton — strategy is stateless. + public static readonly PostgresqlDdlSyntax Instance = new(); + + private PostgresqlDdlSyntax() { } + + /// + public string QuoteIdentifier(string name) => SchemaUtils.QuoteName(name, SchemaUtils.IdentifierUsage.General); + + /// + /// + /// PostgreSQL appends CASCADE so dependent views, foreign keys and + /// owned sequences are dropped atomically with the table — matches the + /// pre-strategy hand-written line in Table.WriteDropStatement. + /// + public void WriteDropTable(TextWriter writer, DbObjectName identifier) + { + writer.WriteLine($"DROP TABLE IF EXISTS {identifier} CASCADE;"); + } + + /// + public void WriteCreateTableHeader(TextWriter writer, DbObjectName identifier, CreationStyle style) + { + // When the caller already emitted a DROP (CreationStyle.DropThenCreate), the + // header is the unguarded CREATE TABLE; otherwise we use IF NOT EXISTS. + if (style == CreationStyle.DropThenCreate) + { + writer.WriteLine($"CREATE TABLE {identifier} ("); + } + else + { + writer.WriteLine($"CREATE TABLE IF NOT EXISTS {identifier} ("); + } + } + + /// + /// + /// PostgreSQL emits foreign keys as separate ALTER TABLE ADD CONSTRAINT + /// statements after the CREATE TABLE, not inline. + /// + public bool InlineForeignKeyConstraints => false; + + /// + /// + /// Returns SERIAL — the historical PostgreSQL spelling. PG 10+ + /// also supports GENERATED BY DEFAULT AS IDENTITY; that may + /// become a configurable variant of this strategy in a later step. + /// + public string AutoIncrementToken => "SERIAL"; + + /// + public string StatementTerminator => ";"; +} diff --git a/src/Weasel.Postgresql/Tables/Table.cs b/src/Weasel.Postgresql/Tables/Table.cs index 2aeefbbd..1b1e0f93 100644 --- a/src/Weasel.Postgresql/Tables/Table.cs +++ b/src/Weasel.Postgresql/Tables/Table.cs @@ -94,17 +94,21 @@ public string PrimaryKeyName set => _primaryKeyName = value; } + /// + /// The pluggable DDL syntax strategy this table uses. Routed through for + /// DROP and CREATE-header emission as part of #270 step 8 (prototype); + /// step 9 will move the full CREATE algorithm to TableBase and the + /// strategy will own more of the emission. + /// + public IDdlSyntaxStrategy Syntax => PostgresqlDdlSyntax.Instance; + public void WriteCreateStatement(Migrator migrator, TextWriter writer) { if (migrator.TableCreation == CreationStyle.DropThenCreate) { - writer.WriteLine("DROP TABLE IF EXISTS {0} CASCADE;", Identifier); - writer.WriteLine("CREATE TABLE {0} (", Identifier); - } - else - { - writer.WriteLine("CREATE TABLE IF NOT EXISTS {0} (", Identifier); + Syntax.WriteDropTable(writer, Identifier); } + Syntax.WriteCreateTableHeader(writer, Identifier, migrator.TableCreation); if (migrator.Formatting == SqlFormatting.Pretty) { @@ -176,7 +180,7 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) public void WriteDropStatement(Migrator rules, TextWriter writer) { - writer.WriteLine($"DROP TABLE IF EXISTS {Identifier} CASCADE;"); + Syntax.WriteDropTable(writer, Identifier); } public DbObjectName Identifier { get; private set; } diff --git a/src/Weasel.Sqlite/SqliteDdlSyntax.cs b/src/Weasel.Sqlite/SqliteDdlSyntax.cs new file mode 100644 index 00000000..96cfec67 --- /dev/null +++ b/src/Weasel.Sqlite/SqliteDdlSyntax.cs @@ -0,0 +1,72 @@ +using Weasel.Core; + +namespace Weasel.Sqlite; + +/// +/// SQLite implementation of . +/// +/// Wired through Tables.Table.WriteDropStatement and the CREATE-TABLE +/// header in WriteCreateStatement as part of #270 step 8 (prototype). +/// The SQLite side is the more interesting half of the validation: it has +/// = true (no ALTER TABLE for FKs) +/// and uses a different auto-increment spelling, exercising the parts of the +/// strategy that genuinely have to be different from PostgreSQL. +/// +/// +public sealed class SqliteDdlSyntax: IDdlSyntaxStrategy +{ + /// Process-wide singleton — strategy is stateless. + public static readonly SqliteDdlSyntax Instance = new(); + + private SqliteDdlSyntax() { } + + /// + public string QuoteIdentifier(string name) => SchemaUtils.QuoteName(name); + + /// + /// + /// SQLite has no CASCADE on DROP TABLE — dependent objects must be + /// dropped manually if they exist (in practice they don't, because + /// SQLite views can't reference tables in attached databases and FKs + /// are intra-table). + /// + public void WriteDropTable(TextWriter writer, DbObjectName identifier) + { + writer.WriteLine($"DROP TABLE IF EXISTS {identifier};"); + } + + /// + public void WriteCreateTableHeader(TextWriter writer, DbObjectName identifier, CreationStyle style) + { + if (style == CreationStyle.DropThenCreate) + { + writer.WriteLine($"CREATE TABLE {identifier} ("); + } + else + { + writer.WriteLine($"CREATE TABLE IF NOT EXISTS {identifier} ("); + } + } + + /// + /// + /// SQLite requires inline CONSTRAINT … FOREIGN KEY (…) REFERENCES … + /// clauses inside the CREATE TABLE body. It has no ALTER TABLE ADD + /// CONSTRAINT for foreign keys (and limited ALTER TABLE generally). + /// + public bool InlineForeignKeyConstraints => true; + + /// + /// + /// SQLite's AUTOINCREMENT applies only to INTEGER PRIMARY KEY + /// columns and forces the rowid allocator to never reuse values. For + /// simple auto-increment without that guarantee, INTEGER PRIMARY KEY + /// alone is sufficient — but the strategy returns the explicit keyword + /// since that's what users opt in to via the fluent + /// .AutoIncrement() call. + /// + public string AutoIncrementToken => "AUTOINCREMENT"; + + /// + public string StatementTerminator => ";"; +} diff --git a/src/Weasel.Sqlite/Tables/Table.cs b/src/Weasel.Sqlite/Tables/Table.cs index 72cf1a92..07c3ad24 100644 --- a/src/Weasel.Sqlite/Tables/Table.cs +++ b/src/Weasel.Sqlite/Tables/Table.cs @@ -93,17 +93,24 @@ public void MoveToSchema(string schemaName) Identifier = new SqliteObjectName(schemaName, Identifier.Name); } + /// + /// The pluggable DDL syntax strategy this table uses. Routed through for + /// DROP and CREATE-header emission as part of #270 step 8 (prototype); + /// step 9 will move the full CREATE algorithm to TableBase and the + /// strategy will own more of the emission. Surfaces SQLite's + /// = true + /// trait so cross-provider code can ask the table how its FKs are + /// emitted without having to know it's SQLite-specifically. + /// + public IDdlSyntaxStrategy Syntax => SqliteDdlSyntax.Instance; + public void WriteCreateStatement(Migrator migrator, TextWriter writer) { if (migrator.TableCreation == CreationStyle.DropThenCreate) { - writer.WriteLine($"DROP TABLE IF EXISTS {Identifier};"); - writer.WriteLine($"CREATE TABLE {Identifier} ("); - } - else - { - writer.WriteLine($"CREATE TABLE IF NOT EXISTS {Identifier} ("); + Syntax.WriteDropTable(writer, Identifier); } + Syntax.WriteCreateTableHeader(writer, Identifier, migrator.TableCreation); var lines = new List(); @@ -177,7 +184,7 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) public void WriteDropStatement(Migrator rules, TextWriter writer) { - writer.WriteLine($"DROP TABLE IF EXISTS {Identifier};"); + Syntax.WriteDropTable(writer, Identifier); } // Implemented in Table.Deltas.cs partial class From 99485c1d243270cda621384283236b8664234fdf Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 12:41:38 -0500 Subject: [PATCH 09/10] #270 step 9: TableBase, refactor all five provider Tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces Weasel.Core.TableBase — the cross-provider base for the five concrete Table classes that the audit predicted would absorb ~600-700 LOC of duplication. This commit lifts the high-confidence shared boilerplate; the full CREATE algorithm body is still per-provider for now (step 8's IDdlSyntaxStrategy will absorb that incrementally once the strategy shape has settled). TableBase owns: - _columns field + Columns getter (generic over TColumn : ITableColumn) - ForeignKeys + Indexes + IgnoredIndexes - MaxIdentifierLength + TruncatedNameIdentifier - ColumnFor / HasColumn / IndexFor / HasIndex / HasIgnoredIndex / IgnoreIndex - RemoveColumn (always case-insensitive — matches every provider's pre-existing impl, distinct from the per-provider NameComparison) - PrimaryKeyName with DefaultPrimaryKeyName() hook (PG/SS use pkey_{name}_{cols}, Oracle/MySQL use pk_{name}_{cols}, SQLite uses pk_{name}) - NameComparison virtual (Ordinal default — PG only; SS/Oracle/MySQL/ SQLite override to OrdinalIgnoreCase to match their pre-existing ColumnFor/HasColumn behaviour) - ToString() returning $"Table: {Identifier}" (PG/SQLite had this override; SS/Oracle/MySQL inherit it now) - ToBasicCreateTableSql() template via GetDefaultMigratorForBasicSql() hook - All ITable explicit interface implementations (AddColumn(string,string), AddColumn(string,Type), AddPrimaryKeyColumn variants, ForeignKeys, AddForeignKey) — providers supply just three abstract hooks: CreateForeignKey(name), AddColumnAndReturn(name,type), AddPrimaryKeyColumnAndReturn(name,type), and GetDatabaseTypeFor(Type). PrimaryKeyColumns stays abstract per provider — PG / SQLite store an explicit _primaryKeyColumns field, SS / Oracle / MySQL derive from Columns.Where(IsPrimaryKey). Both shapes preserved. Provider-specific items retained: - Constructor (provider wraps Identifier in its DbObjectName subclass) - WriteCreateStatement / WriteDropStatement (step 8 strategy partially consumes; rest stays per-provider until follow-up) - AllNames() override (provider-specific DbObjectName wrapping for indexes and FKs) - ColumnExpression nested class - AddColumn fluent overloads returning ColumnExpression - PrimaryKeyDeclaration() (provider-specific quoting / formatting) - Partition properties / Engine / Charset / WithoutRowId / StrictTypes / etc. SQLite's TableDelta touched _columns directly via internal access; swapped those to call the public AddColumn(TableColumn) factory which properly sets Parent and adds via the base list. IgnoredIndexes was previously a PG- and SQLite-only feature; now uniform across all providers via TableBase. Empty set on SS / Oracle / MySQL is a no-op until those providers wire it up. Diff: -398 LOC net across the five Table.cs files (-565 deletions, +167 insertions excluding the new TableBase.cs). All tests pass: - PostgreSQL: 653/656 (3 pre-existing skips) - SQL Server: 244/252 (8 pre-existing skips) - SQLite: 361/361 - MySQL: 188/188 - Weasel.Core: 6/6 - Oracle: build-only (no container available locally) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Weasel.Core/TableBase.cs | 244 ++++++++++++++++++ src/Weasel.MySql/Tables/Table.Deltas.cs | 2 +- .../Tables/Table.FetchExisting.cs | 2 +- src/Weasel.MySql/Tables/Table.cs | 119 ++------- src/Weasel.Oracle/Tables/Table.Deltas.cs | 2 +- .../Tables/Table.FetchExisting.cs | 2 +- src/Weasel.Oracle/Tables/Table.cs | 126 ++------- src/Weasel.Postgresql/Tables/Table.Deltas.cs | 2 +- .../Tables/Table.FetchExisting.cs | 2 +- src/Weasel.Postgresql/Tables/Table.cs | 167 +++--------- src/Weasel.SqlServer/Tables/Table.Deltas.cs | 2 +- .../Tables/Table.FetchExisting.cs | 2 +- src/Weasel.SqlServer/Tables/Table.cs | 135 +++------- src/Weasel.Sqlite/Tables/Table.Deltas.cs | 2 +- .../Tables/Table.FetchExisting.cs | 2 +- src/Weasel.Sqlite/Tables/Table.cs | 161 +++--------- src/Weasel.Sqlite/Tables/TableDelta.cs | 4 +- 17 files changed, 411 insertions(+), 565 deletions(-) create mode 100644 src/Weasel.Core/TableBase.cs diff --git a/src/Weasel.Core/TableBase.cs b/src/Weasel.Core/TableBase.cs new file mode 100644 index 00000000..f9064a6f --- /dev/null +++ b/src/Weasel.Core/TableBase.cs @@ -0,0 +1,244 @@ +using JasperFx.Core; + +namespace Weasel.Core; + +/// +/// Cross-provider base for the five concrete Table classes +/// (PostgreSQL, SQL Server, Oracle, MySQL, SQLite). Owns the parts of the +/// table model that have been re-implemented identically (or nearly so) in +/// every provider: the column / index / foreign-key collections, the +/// navigation helpers (, , +/// , ), the +/// / +/// pair, and the interface boilerplate that wraps +/// AddColumn(name, columnType) / AddColumn(name, dotnetType) / +/// AddForeignKey behind explicit interface implementations. +/// +/// Three pieces stay subclass-controlled because they genuinely differ: +/// +/// +/// — PG and SQLite store the PK +/// columns as an explicit ; SQL Server, Oracle +/// and MySQL derive them from Columns.Where(IsPrimaryKey). +/// Both shapes are preserved via this abstract property. +/// +/// +/// — providers spell the +/// auto-generated PK constraint name differently +/// (pkey_{name}_{cols} on PG / SS, pk_{name}_{cols} on +/// Oracle / MySQL, pk_{name} on SQLite). +/// +/// +/// / +/// — the CREATE / DROP algorithm itself; step 8 introduced +/// to start routing the +/// syntax-only parts through a pluggable strategy, with PG and SQLite +/// wired in as the prototype. +/// +/// +/// +/// +/// The audit at #270 predicted ~600–700 LOC of removable duplication from +/// Table.cs alone. This step lifts the high-confidence shared state +/// and helpers; the remaining WriteCreateStatement body lifts in a +/// follow-up when the strategy interface has settled across all five +/// providers. +/// +/// +/// +/// The provider's concrete TableColumn type (each provider currently +/// defines its own; #270 step 10 may unify these under a +/// TableColumnBase). +/// +/// +/// The provider's concrete IndexDefinition type. +/// is the lowest common denominator the navigation helpers need. +/// +/// +/// The provider's concrete ForeignKey type, constrained to +/// so the +/// contravariance works. +/// +public abstract class TableBase: SchemaObjectBase, ITable + where TColumn : ITableColumn + where TIndex : INamed + where TForeignKey : ForeignKeyBase +{ + protected readonly List _columns = new(); + private string? _primaryKeyName; + + protected TableBase(DbObjectName identifier) : base(identifier) + { + } + + public IReadOnlyList Columns => _columns; + public IList ForeignKeys { get; } = new List(); + public IList Indexes { get; } = new List(); + + /// + /// Names of indexes that this table intentionally ignores during delta + /// comparison — useful when a third party (e.g. pg_partman on + /// PostgreSQL, an external migration tool) owns those indexes and + /// Weasel should not try to drop or recreate them. Previously a + /// PostgreSQL- and SQLite-only property; lifted here for uniform + /// access. SS / Oracle / MySQL inherit an empty set, which is a no-op + /// until they need the feature. + /// + public ISet IgnoredIndexes { get; } = new HashSet(); + + /// + public abstract IReadOnlyList PrimaryKeyColumns { get; } + + /// + /// Max identifier length supported by the underlying engine. PostgreSQL + /// defaults to 63, SQL Server to 128, Oracle 12c+ to 128, MySQL to 64, + /// SQLite is effectively unlimited but 64 is a sensible practical cap. + /// Subclasses adjust via the public setter if needed. + /// + public int MaxIdentifierLength { get; set; } = 63; + + /// + /// Truncate a candidate identifier to at most + /// characters. Used by the partition / index / FK naming helpers to + /// stay within engine limits. + /// + public string TruncatedNameIdentifier(string nameIdentifier) + => nameIdentifier.Substring(0, Math.Min(MaxIdentifierLength, nameIdentifier.Length)); + + public string PrimaryKeyName + { + get => _primaryKeyName.IsNotEmpty() ? _primaryKeyName : DefaultPrimaryKeyName(); + set => _primaryKeyName = value; + } + + /// + /// Provider-specific default for the auto-generated primary-key + /// constraint name. PG / SS use pkey_{name}_{cols}, Oracle / + /// MySQL use pk_{name}_{cols}, SQLite uses pk_{name}. + /// + protected abstract string DefaultPrimaryKeyName(); + + /// + /// How column / index / FK names are compared during lookup. PostgreSQL, + /// SQL Server, Oracle and MySQL use ; + /// SQLite overrides to + /// because SQLite identifiers are case-folded. + /// + protected virtual StringComparison NameComparison => StringComparison.Ordinal; + + public TColumn? ColumnFor(string columnName) + => Columns.FirstOrDefault(x => x.Name.Equals(columnName, NameComparison)); + + public bool HasColumn(string columnName) + => Columns.Any(x => x.Name.Equals(columnName, NameComparison)); + + public TIndex? IndexFor(string indexName) + => Indexes.FirstOrDefault(x => x.Name.Equals(indexName, NameComparison)); + + public bool HasIndex(string indexName) + => Indexes.Any(x => x.Name.Equals(indexName, NameComparison)); + + public bool HasIgnoredIndex(string indexName) + => IgnoredIndexes.Contains(indexName); + + public void IgnoreIndex(string indexName) + { + if (HasIndex(indexName)) + { + throw new ArgumentException($"Cannot ignore defined index {indexName} on table {Identifier}"); + } + IgnoredIndexes.Add(indexName); + } + + /// + /// Remove a column by name. Always case-insensitive — every concrete + /// provider used EqualsIgnoreCase in its own implementation, so + /// the base preserves that, distinct from the case-sensitivity of + /// / which is per- + /// provider via . + /// + public virtual void RemoveColumn(string columnName) + { + _columns.RemoveAll(x => x.Name.EqualsIgnoreCase(columnName)); + } + + public override string ToString() => $"Table: {Identifier}"; + + /// + /// Generate the CREATE TABLE DDL with the provider's default formatting + /// ("concise"). Useful for diagnostics and tests. + /// + public string ToBasicCreateTableSql() + { + var writer = new StringWriter(); + var rules = GetDefaultMigratorForBasicSql(); + WriteCreateStatement(rules, writer); + return writer.ToString(); + } + + /// + /// Provider-specific concise for + /// . + /// + protected abstract Migrator GetDefaultMigratorForBasicSql(); + + // ---- ITable explicit interface implementations ------------------------- + // + // These wrap the provider's typed AddColumn / AddForeignKey via abstract + // hooks so the ITable surface is implemented exactly once here and providers + // only specialise the type-resolution + factory calls. + + IReadOnlyList ITable.ForeignKeys + => ForeignKeys.Cast().ToList(); + + ForeignKeyBase ITable.AddForeignKey(string name, DbObjectName linkedTable, string[] columnNames, string[] linkedColumnNames) + { + var fk = CreateForeignKey(name); + fk.LinkedTable = linkedTable; + fk.ColumnNames = columnNames; + fk.LinkedNames = linkedColumnNames; + ForeignKeys.Add(fk); + return fk; + } + + ITableColumn ITable.AddColumn(string name, string columnType) + => AddColumnAndReturn(name, columnType); + + ITableColumn ITable.AddColumn(string name, Type dotnetType) + => AddColumnAndReturn(name, GetDatabaseTypeFor(dotnetType)); + + ITableColumn ITable.AddPrimaryKeyColumn(string name, string columnType) + => AddPrimaryKeyColumnAndReturn(name, columnType); + + ITableColumn ITable.AddPrimaryKeyColumn(string name, Type dotnetType) + => AddPrimaryKeyColumnAndReturn(name, GetDatabaseTypeFor(dotnetType)); + + /// + /// Factory hook for the provider-specific ForeignKey subclass. + /// Used by . Subclasses just + /// => new ForeignKey(name). + /// + protected abstract TForeignKey CreateForeignKey(string name); + + /// + /// Add a column with a fully-resolved provider-specific type string and + /// return the typed column (the provider's AddColumn(...) path + /// adds the column to and returns the column + /// wrapped inside its ColumnExpression; this hook unwraps to + /// the column itself for the contract). + /// + protected abstract ITableColumn AddColumnAndReturn(string name, string columnType); + + /// + /// Same as but immediately flags the + /// column as a primary key. + /// + protected abstract ITableColumn AddPrimaryKeyColumnAndReturn(string name, string columnType); + + /// + /// Resolve a .NET type to the provider-specific database type string + /// (e.g. typeof(Guid)"uuid" on PG, "UNIQUEIDENTIFIER" + /// on SS). Subclasses route to their Provider.Instance.GetDatabaseType. + /// + protected abstract string GetDatabaseTypeFor(Type dotnetType); +} diff --git a/src/Weasel.MySql/Tables/Table.Deltas.cs b/src/Weasel.MySql/Tables/Table.Deltas.cs index 72df67c1..d9dc26b3 100644 --- a/src/Weasel.MySql/Tables/Table.Deltas.cs +++ b/src/Weasel.MySql/Tables/Table.Deltas.cs @@ -6,7 +6,7 @@ namespace Weasel.MySql.Tables; public partial class Table { - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) + public override async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) { var existing = await readExistingAsync(reader, ct).ConfigureAwait(false); return new TableDelta(this, existing); diff --git a/src/Weasel.MySql/Tables/Table.FetchExisting.cs b/src/Weasel.MySql/Tables/Table.FetchExisting.cs index ca323308..c4ce4332 100644 --- a/src/Weasel.MySql/Tables/Table.FetchExisting.cs +++ b/src/Weasel.MySql/Tables/Table.FetchExisting.cs @@ -6,7 +6,7 @@ namespace Weasel.MySql.Tables; public partial class Table { - public void ConfigureQueryCommand(Core.DbCommandBuilder builder) + public override void ConfigureQueryCommand(Core.DbCommandBuilder builder) { var schemaParam = builder.AddParameter(Identifier.Schema).ParameterName; var nameParam = builder.AddParameter(Identifier.Name).ParameterName; diff --git a/src/Weasel.MySql/Tables/Table.cs b/src/Weasel.MySql/Tables/Table.cs index fc18159d..ded94d92 100644 --- a/src/Weasel.MySql/Tables/Table.cs +++ b/src/Weasel.MySql/Tables/Table.cs @@ -14,67 +14,47 @@ public enum PartitionStrategy Key } -public partial class Table: ITable +public partial class Table: TableBase { - private readonly List _columns = new(); - private string? _primaryKeyName; - public Table(DbObjectName name) + : base(name ?? throw new ArgumentNullException(nameof(name))) { - Identifier = name ?? throw new ArgumentNullException(nameof(name)); } public Table(string tableName): this(DbObjectName.Parse(MySqlProvider.Instance, tableName)) { } - ITableColumn ITable.AddColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType); - return expression.Column; - } + /// + public override IReadOnlyList PrimaryKeyColumns => + _columns.Where(x => x.IsPrimaryKey).Select(x => x.Name).ToList(); - ITableColumn ITable.AddColumn(string name, Type dotnetType) - { - var type = MySqlProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type); - return expression.Column; - } + /// + protected override string DefaultPrimaryKeyName() + => $"pk_{Identifier.Name}_{PrimaryKeyColumns.Join("_")}"; - ITableColumn ITable.AddPrimaryKeyColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType).AsPrimaryKey(); - return expression.Column; - } + /// + protected override ForeignKey CreateForeignKey(string name) => new ForeignKey(name); - ITableColumn ITable.AddPrimaryKeyColumn(string name, Type dotnetType) - { - var type = MySqlProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type).AsPrimaryKey(); - return expression.Column; - } + /// + protected override ITableColumn AddColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).Column; - IReadOnlyList ITable.ForeignKeys => ForeignKeys.Cast().ToList(); + /// + protected override ITableColumn AddPrimaryKeyColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).AsPrimaryKey().Column; - ForeignKeyBase ITable.AddForeignKey(string name, DbObjectName linkedTable, string[] columnNames, string[] linkedColumnNames) - { - var fk = new ForeignKey(name) - { - LinkedTable = linkedTable, - ColumnNames = columnNames, - LinkedNames = linkedColumnNames - }; - ForeignKeys.Add(fk); - return fk; - } + /// + protected override string GetDatabaseTypeFor(Type dotnetType) + => MySqlProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - public IReadOnlyList Columns => _columns; + /// + protected override Migrator GetDefaultMigratorForBasicSql() + => new MySqlMigrator { Formatting = SqlFormatting.Concise }; - public IList ForeignKeys { get; } = new List(); - public IList Indexes { get; } = new List(); - - public IReadOnlyList PrimaryKeyColumns => - _columns.Where(x => x.IsPrimaryKey).Select(x => x.Name).ToList(); + /// + /// MySQL identifier comparison is case-insensitive by default on most platforms. + protected override StringComparison NameComparison => StringComparison.OrdinalIgnoreCase; public IList PartitionExpressions { get; } = new List(); @@ -100,15 +80,7 @@ ForeignKeyBase ITable.AddForeignKey(string name, DbObjectName linkedTable, strin /// public string? Collation { get; set; } - public string PrimaryKeyName - { - get => _primaryKeyName.IsNotEmpty() - ? _primaryKeyName - : $"pk_{Identifier.Name}_{PrimaryKeyColumns.Join("_")}"; - set => _primaryKeyName = value; - } - - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { if (migrator.TableCreation == CreationStyle.DropThenCreate) { @@ -261,14 +233,12 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) } } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteDropStatement(Migrator rules, TextWriter writer) { writer.WriteLine($"DROP TABLE IF EXISTS {Identifier.QualifiedName};"); } - public DbObjectName Identifier { get; } - - public IEnumerable AllNames() + public override IEnumerable AllNames() { yield return Identifier; @@ -283,35 +253,12 @@ public IEnumerable AllNames() } } - public string ToBasicCreateTableSql() - { - var writer = new StringWriter(); - var rules = new MySqlMigrator { Formatting = SqlFormatting.Concise }; - WriteCreateStatement(rules, writer); - return writer.ToString(); - } - internal string PrimaryKeyDeclaration() { var columns = PrimaryKeyColumns.Select(c => $"`{c}`").Join(", "); return $" PRIMARY KEY ({columns})"; } - public TableColumn? ColumnFor(string columnName) - { - return Columns.FirstOrDefault(x => x.Name.EqualsIgnoreCase(columnName)); - } - - public bool HasColumn(string columnName) - { - return Columns.Any(x => x.Name.EqualsIgnoreCase(columnName)); - } - - public IndexDefinition? IndexFor(string indexName) - { - return Indexes.FirstOrDefault(x => x.Name.EqualsIgnoreCase(indexName)); - } - public ColumnExpression AddColumn(TableColumn column) { _columns.Add(column); @@ -355,11 +302,6 @@ public async Task ExistsInDatabaseAsync(MySqlConnection conn, Cancellation return Convert.ToInt64(result) > 0; } - public void RemoveColumn(string columnName) - { - _columns.RemoveAll(x => x.Name.EqualsIgnoreCase(columnName)); - } - public ColumnExpression ModifyColumn(string columnName) { var column = ColumnFor(columnName) ?? @@ -368,11 +310,6 @@ public ColumnExpression ModifyColumn(string columnName) return new ColumnExpression(this, column); } - public bool HasIndex(string indexName) - { - return Indexes.Any(x => x.Name.EqualsIgnoreCase(indexName)); - } - public void PartitionByRange(params string[] columnOrExpressions) { PartitionStrategy = PartitionStrategy.Range; diff --git a/src/Weasel.Oracle/Tables/Table.Deltas.cs b/src/Weasel.Oracle/Tables/Table.Deltas.cs index fba2159d..f36b3657 100644 --- a/src/Weasel.Oracle/Tables/Table.Deltas.cs +++ b/src/Weasel.Oracle/Tables/Table.Deltas.cs @@ -11,7 +11,7 @@ public partial class Table /// not PKs, FKs, or indexes, since Oracle doesn't support multiple result sets. /// For full schema detection, use FindDeltaAsync instead. /// - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) + public override async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) { var existing = await ReadExistingFromReaderAsync(reader, ct).ConfigureAwait(false); return new TableDelta(this, existing); diff --git a/src/Weasel.Oracle/Tables/Table.FetchExisting.cs b/src/Weasel.Oracle/Tables/Table.FetchExisting.cs index f879fb7d..f40e0d5f 100644 --- a/src/Weasel.Oracle/Tables/Table.FetchExisting.cs +++ b/src/Weasel.Oracle/Tables/Table.FetchExisting.cs @@ -10,7 +10,7 @@ public partial class Table // Oracle doesn't support multiple result sets in a single command like PostgreSQL or SQL Server. // ConfigureQueryCommand includes columns and primary key info in a single query via LEFT JOIN. // For full schema detection (including FKs and indexes), use FetchExistingAsync directly. - public void ConfigureQueryCommand(DbCommandBuilder builder) + public override void ConfigureQueryCommand(DbCommandBuilder builder) { var schemaParam = builder.AddParameter(Identifier.Schema.ToUpperInvariant()).ParameterName; var nameParam = builder.AddParameter(Identifier.Name.ToUpperInvariant()).ParameterName; diff --git a/src/Weasel.Oracle/Tables/Table.cs b/src/Weasel.Oracle/Tables/Table.cs index dca8720b..753600ba 100644 --- a/src/Weasel.Oracle/Tables/Table.cs +++ b/src/Weasel.Oracle/Tables/Table.cs @@ -28,68 +28,47 @@ public enum PartitionStrategy List } -public partial class Table: ITable +public partial class Table: TableBase { - private readonly List _columns = new(); - - private string? _primaryKeyName; - public Table(DbObjectName name) + : base(name ?? throw new ArgumentNullException(nameof(name))) { - Identifier = name ?? throw new ArgumentNullException(nameof(name)); } public Table(string tableName): this(DbObjectName.Parse(OracleProvider.Instance, tableName)) { } - ITableColumn ITable.AddColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType); - return expression.Column; - } + /// + public override IReadOnlyList PrimaryKeyColumns => + _columns.Where(x => x.IsPrimaryKey).Select(x => x.Name).ToList(); - ITableColumn ITable.AddColumn(string name, Type dotnetType) - { - var type = OracleProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type); - return expression.Column; - } + /// + protected override string DefaultPrimaryKeyName() + => $"pk_{Identifier.Name}_{PrimaryKeyColumns.Join("_")}"; - ITableColumn ITable.AddPrimaryKeyColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType).AsPrimaryKey(); - return expression.Column; - } + /// + protected override ForeignKey CreateForeignKey(string name) => new ForeignKey(name); - ITableColumn ITable.AddPrimaryKeyColumn(string name, Type dotnetType) - { - var type = OracleProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type).AsPrimaryKey(); - return expression.Column; - } + /// + protected override ITableColumn AddColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).Column; - IReadOnlyList ITable.ForeignKeys => ForeignKeys.Cast().ToList(); + /// + protected override ITableColumn AddPrimaryKeyColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).AsPrimaryKey().Column; - ForeignKeyBase ITable.AddForeignKey(string name, DbObjectName linkedTable, string[] columnNames, string[] linkedColumnNames) - { - var fk = new ForeignKey(name) - { - LinkedTable = linkedTable, - ColumnNames = columnNames, - LinkedNames = linkedColumnNames - }; - ForeignKeys.Add(fk); - return fk; - } - - public IReadOnlyList Columns => _columns; + /// + protected override string GetDatabaseTypeFor(Type dotnetType) + => OracleProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - public IList ForeignKeys { get; } = new List(); - public IList Indexes { get; } = new List(); + /// + protected override Migrator GetDefaultMigratorForBasicSql() + => new OracleMigrator { Formatting = SqlFormatting.Concise }; - public IReadOnlyList PrimaryKeyColumns => - _columns.Where(x => x.IsPrimaryKey).Select(x => x.Name).ToList(); + /// + /// Oracle identifier comparison is case-insensitive by default. + protected override StringComparison NameComparison => StringComparison.OrdinalIgnoreCase; public IList PartitionExpressions { get; } = new List(); @@ -98,15 +77,7 @@ ForeignKeyBase ITable.AddForeignKey(string name, DbObjectName linkedTable, strin /// public PartitionStrategy PartitionStrategy { get; set; } = PartitionStrategy.None; - public string PrimaryKeyName - { - get => _primaryKeyName.IsNotEmpty() - ? _primaryKeyName - : $"pk_{Identifier.Name}_{PrimaryKeyColumns.Join("_")}"; - set => _primaryKeyName = value; - } - - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { if (migrator.TableCreation == CreationStyle.DropThenCreate) { @@ -220,7 +191,7 @@ IF v_count > 0 THEN } } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteDropStatement(Migrator rules, TextWriter writer) { writer.WriteLine($@" DECLARE @@ -235,9 +206,7 @@ IF v_count > 0 THEN "); } - public DbObjectName Identifier { get; } - - public IEnumerable AllNames() + public override IEnumerable AllNames() { yield return Identifier; @@ -246,40 +215,11 @@ public IEnumerable AllNames() foreach (var fk in ForeignKeys) yield return new OracleObjectName(Identifier.Schema, fk.Name); } - /// - /// Generate the CREATE TABLE SQL expression with default - /// DDL rules. This is useful for quick diagnostics - /// - /// - public string ToBasicCreateTableSql() - { - var writer = new StringWriter(); - var rules = new OracleMigrator { Formatting = SqlFormatting.Concise }; - WriteCreateStatement(rules, writer); - - return writer.ToString(); - } - internal string PrimaryKeyDeclaration() { return $"CONSTRAINT {PrimaryKeyName} PRIMARY KEY ({PrimaryKeyColumns.Join(", ")})"; } - public TableColumn? ColumnFor(string columnName) - { - return Columns.FirstOrDefault(x => x.Name.EqualsIgnoreCase(columnName)); - } - - public bool HasColumn(string columnName) - { - return Columns.Any(x => x.Name.EqualsIgnoreCase(columnName)); - } - - public IndexDefinition? IndexFor(string indexName) - { - return Indexes.FirstOrDefault(x => x.Name.EqualsIgnoreCase(indexName)); - } - public ColumnExpression AddColumn(TableColumn column) { _columns.Add(column); @@ -324,11 +264,6 @@ public async Task ExistsInDatabaseAsync(OracleConnection conn, Cancellatio return Convert.ToInt32(result) > 0; } - public void RemoveColumn(string columnName) - { - _columns.RemoveAll(x => x.Name.EqualsIgnoreCase(columnName)); - } - public ColumnExpression ModifyColumn(string columnName) { var column = ColumnFor(columnName) ?? @@ -337,11 +272,6 @@ public ColumnExpression ModifyColumn(string columnName) return new ColumnExpression(this, column); } - public bool HasIndex(string indexName) - { - return Indexes.Any(x => x.Name.EqualsIgnoreCase(indexName)); - } - public void PartitionByRange(params string[] columnOrExpressions) { PartitionStrategy = PartitionStrategy.Range; diff --git a/src/Weasel.Postgresql/Tables/Table.Deltas.cs b/src/Weasel.Postgresql/Tables/Table.Deltas.cs index 27cf8a4b..2e03f5a3 100644 --- a/src/Weasel.Postgresql/Tables/Table.Deltas.cs +++ b/src/Weasel.Postgresql/Tables/Table.Deltas.cs @@ -6,7 +6,7 @@ namespace Weasel.Postgresql.Tables; public partial class Table { - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) + public override async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) { var existing = await readExistingAsync(reader, ct).ConfigureAwait(false); return new TableDelta(this, existing); diff --git a/src/Weasel.Postgresql/Tables/Table.FetchExisting.cs b/src/Weasel.Postgresql/Tables/Table.FetchExisting.cs index 4619542a..24ca900c 100644 --- a/src/Weasel.Postgresql/Tables/Table.FetchExisting.cs +++ b/src/Weasel.Postgresql/Tables/Table.FetchExisting.cs @@ -8,7 +8,7 @@ namespace Weasel.Postgresql.Tables; public partial class Table { - public void ConfigureQueryCommand(DbCommandBuilder builder) + public override void ConfigureQueryCommand(DbCommandBuilder builder) { var schemaParam = builder.AddParameter(Identifier.Schema).ParameterName; var nameParam = builder.AddParameter(Identifier.Name).ParameterName; diff --git a/src/Weasel.Postgresql/Tables/Table.cs b/src/Weasel.Postgresql/Tables/Table.cs index 1b1e0f93..bec593b9 100644 --- a/src/Weasel.Postgresql/Tables/Table.cs +++ b/src/Weasel.Postgresql/Tables/Table.cs @@ -7,92 +7,50 @@ namespace Weasel.Postgresql.Tables; -public partial class Table: ISchemaObjectWithPostProcessing, ITable +public partial class Table: TableBase, ISchemaObjectWithPostProcessing { - private readonly List _columns = new(); - private readonly List _primaryKeyColumns = new(); - private string? _primaryKeyName; - public Table(DbObjectName name) + : base(PostgresqlObjectName.From(name ?? throw new ArgumentNullException(nameof(name)))) { - if (name == null) - { - throw new ArgumentNullException(nameof(name)); - } - - Identifier = PostgresqlObjectName.From(name); } public Table(string tableName): this(DbObjectName.Parse(PostgresqlProvider.Instance, tableName)) { } - ITableColumn ITable.AddColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType); - return expression.Column; - } - - ITableColumn ITable.AddColumn(string name, Type dotnetType) - { - var type = PostgresqlProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type); - return expression.Column; - } - - ITableColumn ITable.AddPrimaryKeyColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType).AsPrimaryKey(); - return expression.Column; - } - - ITableColumn ITable.AddPrimaryKeyColumn(string name, Type dotnetType) - { - var type = PostgresqlProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type).AsPrimaryKey(); - return expression.Column; - } - - IReadOnlyList ITable.ForeignKeys => ForeignKeys.Cast().ToList(); - - ForeignKeyBase ITable.AddForeignKey(string name, DbObjectName linkedTable, string[] columnNames, string[] linkedColumnNames) - { - var fk = new ForeignKey(name) - { - LinkedTable = linkedTable, - ColumnNames = columnNames, - LinkedNames = linkedColumnNames - }; - ForeignKeys.Add(fk); - return fk; - } - /// /// Default is false. If true, Weasel assumes that *something* else like pg_partman is controlling /// the database partitions outside of Weasel control /// public bool IgnorePartitionsInMigration { get; set; } - public IReadOnlyList Columns => _columns; + /// + public override IReadOnlyList PrimaryKeyColumns => _primaryKeyColumns; - public IList ForeignKeys { get; } = new List(); - public IList Indexes { get; } = new List(); - public ISet IgnoredIndexes { get; } = new HashSet(); + /// + protected override string DefaultPrimaryKeyName() + => $"pkey_{Identifier.Name}_{PrimaryKeyColumns.Join("_")}"; - /// - /// Max identifier length for identifiers like table name, column name, constraints, primary key etc - /// - public int MaxIdentifierLength { get; set; } = 63; + /// + protected override ForeignKey CreateForeignKey(string name) => new ForeignKey(name); - public IReadOnlyList PrimaryKeyColumns => _primaryKeyColumns; + /// + protected override ITableColumn AddColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).Column; - public string PrimaryKeyName - { - get => _primaryKeyName.IsNotEmpty() ? _primaryKeyName : $"pkey_{Identifier.Name}_{PrimaryKeyColumns.Join("_")}"; - set => _primaryKeyName = value; - } + /// + protected override ITableColumn AddPrimaryKeyColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).AsPrimaryKey().Column; + + /// + protected override string GetDatabaseTypeFor(Type dotnetType) + => PostgresqlProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); + + /// + protected override Migrator GetDefaultMigratorForBasicSql() + => new PostgresqlMigrator { Formatting = SqlFormatting.Concise }; /// /// The pluggable DDL syntax strategy this table uses. Routed through for @@ -102,7 +60,7 @@ public string PrimaryKeyName /// public IDdlSyntaxStrategy Syntax => PostgresqlDdlSyntax.Instance; - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { if (migrator.TableCreation == CreationStyle.DropThenCreate) { @@ -178,15 +136,12 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) } } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteDropStatement(Migrator rules, TextWriter writer) { Syntax.WriteDropTable(writer, Identifier); } - public DbObjectName Identifier { get; private set; } - - - public IEnumerable AllNames() + public override IEnumerable AllNames() { yield return Identifier; @@ -212,11 +167,6 @@ public void PostProcess(ISchemaObject[] allObjects) } } - public override string ToString() - { - return $"Table: {Identifier}"; - } - internal void ReadPrimaryKeyColumns(List pks) { _primaryKeyColumns.Clear(); @@ -246,42 +196,11 @@ public void MoveToSchema(string schemaName) } } - /// - /// Generate the CREATE TABLE SQL expression with default - /// DDL rules. This is useful for quick diagnostics - /// - /// - public string ToBasicCreateTableSql() - { - var writer = new StringWriter(); - var rules = new PostgresqlMigrator { Formatting = SqlFormatting.Concise }; - WriteCreateStatement(rules, writer); - - return writer.ToString(); - } - - internal string PrimaryKeyDeclaration() { return $"CONSTRAINT {PrimaryKeyName} PRIMARY KEY ({PrimaryKeyColumns.Join(", ")})"; } - public TableColumn? ColumnFor(string columnName) - { - return Columns.FirstOrDefault(x => x.Name == columnName); - } - - - public bool HasColumn(string columnName) - { - return Columns.Any(x => x.Name == columnName); - } - - public IndexDefinition? IndexFor(string indexName) - { - return Indexes.FirstOrDefault(x => x.Name == indexName); - } - public ColumnExpression AddColumn(TableColumn column) { _columns.Add(column); @@ -328,14 +247,14 @@ public async Task ExistsInDatabaseAsync(NpgsqlConnection conn, Cancellatio return any; } - public string TruncatedNameIdentifier(string nameIdentifier) - { - return nameIdentifier.Substring(0, Math.Min(MaxIdentifierLength, nameIdentifier.Length)); - } - - public void RemoveColumn(string columnName) + /// + /// + /// PostgreSQL also clears the column from + /// since the PK list is stored as an explicit field on this provider. + /// + public override void RemoveColumn(string columnName) { - _columns.RemoveAll(x => x.Name.EqualsIgnoreCase(columnName)); + base.RemoveColumn(columnName); _primaryKeyColumns.Remove(columnName); } @@ -347,26 +266,6 @@ public ColumnExpression ModifyColumn(string columnName) return new ColumnExpression(this, column); } - public void IgnoreIndex(string indexName) - { - if (Indexes.Any(idx => idx.Name == indexName)) - { - throw new ArgumentException($"Cannot ignore defined index {indexName} on table {Identifier}"); - } - - IgnoredIndexes.Add(indexName); - } - - public bool HasIndex(string indexName) - { - return Indexes.Any(x => x.Name == indexName); - } - - public bool HasIgnoredIndex(string indexName) - { - return IgnoredIndexes.Contains(indexName); - } - public IEnumerable PartitionTableNames() { if (Partitioning == null) diff --git a/src/Weasel.SqlServer/Tables/Table.Deltas.cs b/src/Weasel.SqlServer/Tables/Table.Deltas.cs index f0840e01..a8e6eee2 100644 --- a/src/Weasel.SqlServer/Tables/Table.Deltas.cs +++ b/src/Weasel.SqlServer/Tables/Table.Deltas.cs @@ -6,7 +6,7 @@ namespace Weasel.SqlServer.Tables; public partial class Table { - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) + public override async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) { var existing = await readExistingAsync(reader, ct).ConfigureAwait(false); return new TableDelta(this, existing); diff --git a/src/Weasel.SqlServer/Tables/Table.FetchExisting.cs b/src/Weasel.SqlServer/Tables/Table.FetchExisting.cs index 542c3162..6ac946d3 100644 --- a/src/Weasel.SqlServer/Tables/Table.FetchExisting.cs +++ b/src/Weasel.SqlServer/Tables/Table.FetchExisting.cs @@ -7,7 +7,7 @@ namespace Weasel.SqlServer.Tables; public partial class Table { - public void ConfigureQueryCommand(DbCommandBuilder builder) + public override void ConfigureQueryCommand(DbCommandBuilder builder) { var schemaParam = builder.AddParameter(Identifier.Schema).ParameterName; var nameParam = builder.AddParameter(Identifier.Name).ParameterName; diff --git a/src/Weasel.SqlServer/Tables/Table.cs b/src/Weasel.SqlServer/Tables/Table.cs index c5925ee1..d55bc83d 100644 --- a/src/Weasel.SqlServer/Tables/Table.cs +++ b/src/Weasel.SqlServer/Tables/Table.cs @@ -22,68 +22,53 @@ public enum PartitionStrategy } -public partial class Table: ITable +public partial class Table: TableBase { - private readonly List _columns = new(); - - private string? _primaryKeyName; - public Table(DbObjectName name) + : base(name ?? throw new ArgumentNullException(nameof(name))) { - Identifier = name ?? throw new ArgumentNullException(nameof(name)); } public Table(string tableName): this(DbObjectName.Parse(SqlServerProvider.Instance, tableName)) { } - ITableColumn ITable.AddColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType); - return expression.Column; - } + /// + /// + /// SQL Server derives the primary key column list from + /// rather + /// than storing it separately, so the list refreshes automatically as + /// columns are flagged with IsPrimaryKey. + /// + public override IReadOnlyList PrimaryKeyColumns => + _columns.Where(x => x.IsPrimaryKey).Select(x => x.Name).ToList(); - ITableColumn ITable.AddColumn(string name, Type dotnetType) - { - var type = SqlServerProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type); - return expression.Column; - } + /// + protected override string DefaultPrimaryKeyName() + => $"pkey_{Identifier.Name}_{PrimaryKeyColumns.Join("_")}"; - ITableColumn ITable.AddPrimaryKeyColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType).AsPrimaryKey(); - return expression.Column; - } + /// + protected override ForeignKey CreateForeignKey(string name) => new ForeignKey(name); - ITableColumn ITable.AddPrimaryKeyColumn(string name, Type dotnetType) - { - var type = SqlServerProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type).AsPrimaryKey(); - return expression.Column; - } + /// + protected override ITableColumn AddColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).Column; - IReadOnlyList ITable.ForeignKeys => ForeignKeys.Cast().ToList(); + /// + protected override ITableColumn AddPrimaryKeyColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).AsPrimaryKey().Column; - ForeignKeyBase ITable.AddForeignKey(string name, DbObjectName linkedTable, string[] columnNames, string[] linkedColumnNames) - { - var fk = new ForeignKey(name) - { - LinkedTable = linkedTable, - ColumnNames = columnNames, - LinkedNames = linkedColumnNames - }; - ForeignKeys.Add(fk); - return fk; - } + /// + protected override string GetDatabaseTypeFor(Type dotnetType) + => SqlServerProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - public IReadOnlyList Columns => _columns; + /// + protected override Migrator GetDefaultMigratorForBasicSql() + => new SqlServerMigrator { Formatting = SqlFormatting.Concise }; - public IList ForeignKeys { get; } = new List(); - public IList Indexes { get; } = new List(); - - public IReadOnlyList PrimaryKeyColumns => - _columns.Where(x => x.IsPrimaryKey).Select(x => x.Name).ToList(); + /// + /// SQL Server identifier comparison is case-insensitive by default. + protected override StringComparison NameComparison => StringComparison.OrdinalIgnoreCase; public IList PartitionExpressions { get; } = new List(); @@ -109,15 +94,7 @@ public Partitioning.RangePartitioning PartitionByRange(string column, string sql return partitioning; } - public string PrimaryKeyName - { - get => _primaryKeyName.IsNotEmpty() - ? _primaryKeyName - : $"pkey_{Identifier.Name}_{PrimaryKeyColumns.Join("_")}"; - set => _primaryKeyName = value; - } - - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { // Write partition function and scheme DDL before the table if partitioning is configured if (SqlServerPartitioning != null) @@ -228,15 +205,12 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) } } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteDropStatement(Migrator rules, TextWriter writer) { writer.WriteLine($"DROP TABLE IF EXISTS {Identifier};"); } - public DbObjectName Identifier { get; } - - - public IEnumerable AllNames() + public override IEnumerable AllNames() { yield return Identifier; @@ -245,42 +219,11 @@ public IEnumerable AllNames() foreach (var fk in ForeignKeys) yield return new SqlServerObjectName(Identifier.Schema, fk.Name); } - /// - /// Generate the CREATE TABLE SQL expression with default - /// DDL rules. This is useful for quick diagnostics - /// - /// - public string ToBasicCreateTableSql() - { - var writer = new StringWriter(); - var rules = new SqlServerMigrator { Formatting = SqlFormatting.Concise }; - WriteCreateStatement(rules, writer); - - return writer.ToString(); - } - - internal string PrimaryKeyDeclaration() { return $"CONSTRAINT {PrimaryKeyName} PRIMARY KEY ({PrimaryKeyColumns.Join(", ")})"; } - public TableColumn? ColumnFor(string columnName) - { - return Columns.FirstOrDefault(x => x.Name.EqualsIgnoreCase(columnName)); - } - - - public bool HasColumn(string columnName) - { - return Columns.Any(x => x.Name.EqualsIgnoreCase(columnName)); - } - - public IndexDefinition? IndexFor(string indexName) - { - return Indexes.FirstOrDefault(x => x.Name == indexName); - } - public ColumnExpression AddColumn(TableColumn column) { _columns.Add(column); @@ -329,11 +272,6 @@ public async Task ExistsInDatabaseAsync(SqlConnection conn, CancellationTo } } - public void RemoveColumn(string columnName) - { - _columns.RemoveAll(x => x.Name.EqualsIgnoreCase(columnName)); - } - public ColumnExpression ModifyColumn(string columnName) { var column = ColumnFor(columnName) ?? @@ -342,11 +280,6 @@ public ColumnExpression ModifyColumn(string columnName) return new ColumnExpression(this, column); } - public bool HasIndex(string indexName) - { - return Indexes.Any(x => x.Name == indexName); - } - public void PartitionByRange(params string[] columnOrExpressions) { PartitionStrategy = PartitionStrategy.Range; diff --git a/src/Weasel.Sqlite/Tables/Table.Deltas.cs b/src/Weasel.Sqlite/Tables/Table.Deltas.cs index ca667b3b..7289c02c 100644 --- a/src/Weasel.Sqlite/Tables/Table.Deltas.cs +++ b/src/Weasel.Sqlite/Tables/Table.Deltas.cs @@ -6,7 +6,7 @@ namespace Weasel.Sqlite.Tables; public partial class Table { - public async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) + public override async Task CreateDeltaAsync(DbDataReader reader, CancellationToken ct = default) { var existing = await readExistingAsync(reader, ct).ConfigureAwait(false); return new TableDelta(this, existing); diff --git a/src/Weasel.Sqlite/Tables/Table.FetchExisting.cs b/src/Weasel.Sqlite/Tables/Table.FetchExisting.cs index 83d910f5..a856b1f9 100644 --- a/src/Weasel.Sqlite/Tables/Table.FetchExisting.cs +++ b/src/Weasel.Sqlite/Tables/Table.FetchExisting.cs @@ -7,7 +7,7 @@ namespace Weasel.Sqlite.Tables; public partial class Table { - public void ConfigureQueryCommand(DbCommandBuilder builder) + public override void ConfigureQueryCommand(DbCommandBuilder builder) { // SQLite PRAGMA statements don't support parameter binding, so we use the table name directly // Sanitize the table name to prevent SQL injection by escaping single quotes diff --git a/src/Weasel.Sqlite/Tables/Table.cs b/src/Weasel.Sqlite/Tables/Table.cs index 07c3ad24..11c38c3c 100644 --- a/src/Weasel.Sqlite/Tables/Table.cs +++ b/src/Weasel.Sqlite/Tables/Table.cs @@ -7,66 +7,44 @@ namespace Weasel.Sqlite.Tables; /// Represents a SQLite table with support for JSON columns, foreign keys, indexes, and generated columns. /// Note: SQLite has limited ALTER TABLE support, many schema changes require table recreation. /// -public partial class Table: ITable +public partial class Table: TableBase { - internal readonly List _columns = new(); internal readonly List _primaryKeyColumns = new(); - private string? _primaryKeyName; public Table(DbObjectName name) + : base(name ?? throw new ArgumentNullException(nameof(name))) { - Identifier = name ?? throw new ArgumentNullException(nameof(name)); } public Table(string tableName): this(DbObjectName.Parse(SqliteProvider.Instance, tableName)) { } - // ITable interface implementation - ITableColumn ITable.AddColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType); - return expression.Column; - } + /// + public override IReadOnlyList PrimaryKeyColumns => _primaryKeyColumns; - ITableColumn ITable.AddColumn(string name, Type dotnetType) - { - var type = SqliteProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type); - return expression.Column; - } + /// + /// SQLite spells the auto-PK constraint name as pk_{tableName}. + protected override string DefaultPrimaryKeyName() => $"pk_{Identifier.Name}"; - ITableColumn ITable.AddPrimaryKeyColumn(string name, string columnType) - { - var expression = AddColumn(name, columnType).AsPrimaryKey(); - return expression.Column; - } + /// + protected override ForeignKey CreateForeignKey(string name) => new ForeignKey(name); - ITableColumn ITable.AddPrimaryKeyColumn(string name, Type dotnetType) - { - var type = SqliteProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - var expression = AddColumn(name, type).AsPrimaryKey(); - return expression.Column; - } + /// + protected override ITableColumn AddColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).Column; - public IReadOnlyList Columns => _columns; + /// + protected override ITableColumn AddPrimaryKeyColumnAndReturn(string name, string columnType) + => AddColumn(name, columnType).AsPrimaryKey().Column; - private readonly List _foreignKeys = new(); + /// + protected override string GetDatabaseTypeFor(Type dotnetType) + => SqliteProvider.Instance.GetDatabaseType(dotnetType, EnumStorage.AsInteger); - IReadOnlyList ITable.ForeignKeys => _foreignKeys; - - public IList ForeignKeys => _foreignKeys; - - public IList Indexes { get; } = new List(); - public ISet IgnoredIndexes { get; } = new HashSet(); - - public IReadOnlyList PrimaryKeyColumns => _primaryKeyColumns; - - public string PrimaryKeyName - { - get => _primaryKeyName.IsNotEmpty() ? _primaryKeyName : $"pk_{Identifier.Name}"; - set => _primaryKeyName = value; - } + /// + protected override Migrator GetDefaultMigratorForBasicSql() + => new SqliteMigrator { Formatting = SqlFormatting.Concise }; /// /// Enable foreign key constraints (disabled by default in SQLite) @@ -83,8 +61,6 @@ public string PrimaryKeyName /// public bool StrictTypes { get; set; } - public DbObjectName Identifier { get; private set; } - /// /// Change the table's schema (supports "main" and "temp" schemas in SQLite) /// @@ -104,7 +80,7 @@ public void MoveToSchema(string schemaName) /// public IDdlSyntaxStrategy Syntax => SqliteDdlSyntax.Instance; - public void WriteCreateStatement(Migrator migrator, TextWriter writer) + public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { if (migrator.TableCreation == CreationStyle.DropThenCreate) { @@ -182,64 +158,23 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer) } } - public void WriteDropStatement(Migrator rules, TextWriter writer) + public override void WriteDropStatement(Migrator rules, TextWriter writer) { Syntax.WriteDropTable(writer, Identifier); } // Implemented in Table.Deltas.cs partial class - public bool HasColumn(string columnName) - { - return _columns.Any(x => x.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)); - } + /// + /// + /// SQLite identifiers are case-insensitive — override the + /// + /// hook so HasColumn, ColumnFor, IndexFor and + /// HasIndex all do case-folded lookups. + /// + protected override StringComparison NameComparison => StringComparison.OrdinalIgnoreCase; - public void RemoveColumn(string columnName) - { - var column = _columns.FirstOrDefault(x => x.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)); - if (column != null) - { - _columns.Remove(column); - } - } - - /// - /// Mark an index as ignored for delta detection purposes - /// - public void IgnoreIndex(string indexName) - { - IgnoredIndexes.Add(indexName); - } - - /// - /// Check if the table has an index with the given name - /// - public bool HasIndex(string indexName) - { - return Indexes.Any(x => x.Name.Equals(indexName, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Check if an index is in the ignored list - /// - public bool HasIgnoredIndex(string indexName) - { - return IgnoredIndexes.Contains(indexName); - } - - public ForeignKeyBase AddForeignKey(string name, DbObjectName linkedTable, string[] columnNames, string[] linkedColumnNames) - { - var fk = new ForeignKey(name) - { - LinkedTable = linkedTable, - ColumnNames = columnNames, - LinkedNames = linkedColumnNames - }; - _foreignKeys.Add(fk); - return fk; - } - - public IEnumerable AllNames() + public override IEnumerable AllNames() { yield return Identifier; @@ -255,38 +190,6 @@ private string PrimaryKeyDeclaration() return $" CONSTRAINT {SchemaUtils.QuoteName(PrimaryKeyName)} PRIMARY KEY ({pkCols})"; } - public override string ToString() - { - return $"Table: {Identifier}"; - } - - /// - /// Find a column by name (case-insensitive) - /// - public TableColumn? ColumnFor(string columnName) - { - return _columns.FirstOrDefault(x => x.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Find an index by name (case-insensitive) - /// - public IndexDefinition? IndexFor(string indexName) - { - return Indexes.FirstOrDefault(x => x.Name.Equals(indexName, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Generate a basic CREATE TABLE statement for diagnostics - /// - public string ToBasicCreateTableSql() - { - var writer = new StringWriter(); - var migrator = new SqliteMigrator(); - WriteCreateStatement(migrator, writer); - return writer.ToString(); - } - /// /// Modify an existing column's properties /// Note: SQLite has very limited ALTER COLUMN support, so this mostly just finds the column diff --git a/src/Weasel.Sqlite/Tables/TableDelta.cs b/src/Weasel.Sqlite/Tables/TableDelta.cs index 877b11b5..502f1556 100644 --- a/src/Weasel.Sqlite/Tables/TableDelta.cs +++ b/src/Weasel.Sqlite/Tables/TableDelta.cs @@ -291,7 +291,7 @@ private void writeTableRecreation(Migrator rules, TextWriter writer) var tempTable = new Table(tempName); foreach (var column in Expected.Columns) { - tempTable._columns.Add(column); + tempTable.AddColumn(column); } foreach (var pk in Expected.PrimaryKeyColumns) { @@ -444,7 +444,7 @@ private void writeTableRecreationRollback(Migrator rules, TextWriter writer) var tempTable = new Table(tempName); foreach (var column in Actual.Columns) { - tempTable._columns.Add(column); + tempTable.AddColumn(column); } foreach (var pk in Actual.PrimaryKeyColumns) { From 6d92f6945de19f30ee9201c3b8cba9ecef4bb210 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 12 May 2026 12:45:39 -0500 Subject: [PATCH 10/10] #270 step 10: canonical AutoIncrement() across providers, doc trait matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardises the identity / auto-increment fluent spelling. Before 9.0 the same conceptual operation was spelled four different ways across providers: PostgreSQL: .Serial() / .BigSerial() / .SmallSerial() SQL Server: .AutoNumber() Oracle: .AutoNumber() MySQL: .AutoNumber() SQLite: .AutoIncrement() In 9.0 every provider's ColumnExpression exposes a canonical .AutoIncrement() (plus .BigAutoIncrement() / .SmallAutoIncrement() on PostgreSQL for the 64-bit and 16-bit variants). The historical names are kept as [Obsolete] aliases for one major-version cycle, with deprecation messages pointing at the canonical name. Polymorphic schema-building code now works without provider-specific casts: table.AddColumn("id").AsPrimaryKey().AutoIncrement(); // works on all 5 SQL emitted is unchanged per provider — only the C# spelling on the caller side is normalised. Also adds docs/core/provider-trait-matrix.md (linked in the sidebar) that explicitly documents the per-provider differences the audit catalogued: quoting, case-folding, ALTER TABLE limitations, partitioning, JSON storage, identity spelling, primary-key name defaults, the new TableBase / ForeignKeyBase / SequenceBase / FunctionBase / ViewBase / IDdlSyntaxStrategy class hierarchy, and the constructor pattern. End users can now answer "what works on every provider vs what's provider-specific" without reading source. DocSamples updated to use the canonical AutoIncrement() spelling. Test code still uses the old names (Serial() / AutoNumber()) for now to validate the [Obsolete] forwarders work — those warnings will surface in CI as a gentle nudge to migrate but they're not failures. This completes the #270 work plan. Sequence of commits on this branch: 83cf822 JasperFx 2.0.0-alpha.8 upgrade 5ea60f3 step 1: SchemaObjectBase + SequenceBase 639628f step 2: FunctionBase 2967556 step 3: ViewBase + SQLite ViewDelta e7afae8 step 4: ForeignKeyBase (-174 LOC net) 02a6440 step 5: MySQL TableDelta ( +5 LOC net) 9c49c1e step 7: DatabaseProvider memo helpers ( -37 LOC net) 1319cf4 step 8: IDdlSyntaxStrategy prototype 99485c1 step 9: TableBase (-154 LOC net) step 10: AutoIncrement + trait matrix Step 6 (TableDeltaBase shared helpers) was absorbed into step 9 since the helpers needed TableBase to exist before they could decompose cleanly. All tests pass: - PostgreSQL: 653/656 (3 pre-existing skips) - SQL Server: 244/252 (8 pre-existing skips) - SQLite: 361/361 - MySQL: 188/188 - Weasel.Core: 6/6 - Oracle: build-clean (no container locally) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/.vitepress/config.ts | 3 +- docs/core/provider-trait-matrix.md | 139 ++++++++++++++++++++++++++ src/DocSamples/MySqlSamples.cs | 2 +- src/DocSamples/SqlServerSamples.cs | 4 +- src/Weasel.MySql/Tables/Table.cs | 18 +++- src/Weasel.Oracle/Tables/Table.cs | 18 +++- src/Weasel.Postgresql/Tables/Table.cs | 40 +++++++- src/Weasel.SqlServer/Tables/Table.cs | 18 +++- 8 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 docs/core/provider-trait-matrix.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index eb8ea971..fce6d172 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -54,7 +54,8 @@ export default withMermaid( { text: 'Schema Migrations', link: '/core/schema-migrations' }, { text: 'Command Builders & Batching', link: '/core/command-builders' }, { text: 'Extension Methods', link: '/core/extension-methods' }, - { text: 'Multi-Tenancy', link: '/core/multi-tenancy' } + { text: 'Multi-Tenancy', link: '/core/multi-tenancy' }, + { text: 'Provider Trait Matrix', link: '/core/provider-trait-matrix' } ] }, { diff --git a/docs/core/provider-trait-matrix.md b/docs/core/provider-trait-matrix.md new file mode 100644 index 00000000..7017fe73 --- /dev/null +++ b/docs/core/provider-trait-matrix.md @@ -0,0 +1,139 @@ +# Provider Trait Matrix + +Weasel supports five database providers — PostgreSQL, SQL Server, Oracle, +MySQL, and SQLite — each with subtly different feature sets. The audit at +[#270](https://github.com/JasperFx/weasel/issues/270) documented this matrix +explicitly so users can write provider-agnostic schema code with confidence +about what works everywhere versus what's provider-specific. + +## At a glance + +| Feature | PostgreSQL | SQL Server | Oracle | MySQL | SQLite | +| --- | --- | --- | --- | --- | --- | +| Default schema | `public` | `dbo` | `WEASEL` | `public` | `main` | +| Identifier quoting | `"name"` | `[name]` | `"NAME"` | `` `name` `` | `"name"` | +| Identifier case | Sensitive | Folded | Folded (upper) | Folded (lower) | Folded | +| Max identifier length | 63 | 128 | 128 | 64 | (unlimited) | +| Auto-increment SQL | `SERIAL` | `IDENTITY(1,1)` | `GENERATED…AS IDENTITY` | `AUTO_INCREMENT` | `AUTOINCREMENT` | +| `ALTER TABLE ADD COLUMN` | ✓ | ✓ | ✓ | ✓ | ✓ | +| `ALTER TABLE DROP COLUMN` | ✓ | ✓ | ✓ | ✓ | ✓ (3.35+) | +| `ALTER TABLE ALTER COLUMN` | ✓ | ✓ | ✓ | ✓ | ✗ (recreate) | +| `ALTER TABLE ADD CONSTRAINT FK` | ✓ | ✓ | ✓ | ✓ | ✗ (inline only) | +| `ALTER TABLE DROP CONSTRAINT FK` | ✓ | ✓ | ✓ | ✓ | ✗ (recreate) | +| Cascading actions: `RESTRICT` | ✓ | ✗ (→ NoAction) | ✗ (→ NoAction) | ✓ | ✓ | +| Native sequences | ✓ | ✓ | ✓ | Table-emulated | Table-emulated | +| Views | ✓ + materialized | ✓ | ✓ | ✓ | ✓ (read-only) | +| Functions / Stored Procedures | ✓ (PL/pgSQL) | ✓ (T-SQL) | ✓ (PL/SQL) | (limited) | Connection-scoped | +| Partitioning | ✓ (hash/range/list) | ✓ (range) | ✓ | ✓ | ✗ | +| JSON storage | `jsonb` native | `nvarchar(max)` | `CLOB` | `JSON` | `TEXT` (JSON1) | +| Array columns | ✓ | ✗ | ✗ | ✗ | ✗ (except `byte[]`) | +| Statement terminator | `;` | `;` | `/` (PL/SQL blocks) | `;` | `;` | + +## Identity / auto-increment + +In 9.0 every provider exposes a canonical `.AutoIncrement()` fluent method on +`Table.ColumnExpression`. Previously each provider used a different spelling; +the old names are kept as `[Obsolete]` aliases for one major-version cycle. + +| Provider | Canonical 9.0 | Pre-9.0 alias (`[Obsolete]`) | SQL Emitted | +| --- | --- | --- | --- | +| PostgreSQL | `AutoIncrement()` | `Serial()` | `SERIAL` | +| PostgreSQL | `BigAutoIncrement()` | `BigSerial()` | `BIGSERIAL` | +| PostgreSQL | `SmallAutoIncrement()` | `SmallSerial()` | `SMALLSERIAL` | +| SQL Server | `AutoIncrement()` | `AutoNumber()` | `IDENTITY(1,1)` | +| Oracle | `AutoIncrement()` | `AutoNumber()` | `GENERATED BY DEFAULT AS IDENTITY` | +| MySQL | `AutoIncrement()` | `AutoNumber()` | `AUTO_INCREMENT` | +| SQLite | `AutoIncrement()` | (no change) | `AUTOINCREMENT` | + +```csharp +// Same code compiles against any of the five providers +table.AddColumn("id").AsPrimaryKey().AutoIncrement(); +``` + +## Identifier comparison + +`Table.ColumnFor` / `HasColumn` / `IndexFor` / `HasIndex` use a per-provider +`NameComparison`: + +| Provider | `NameComparison` | Rationale | +| --- | --- | --- | +| PostgreSQL | `Ordinal` | PG is case-sensitive when quoted (the default for Weasel-generated DDL) | +| SQL Server | `OrdinalIgnoreCase` | SS identifier comparison is case-insensitive by default | +| Oracle | `OrdinalIgnoreCase` | Oracle folds unquoted identifiers to upper case | +| MySQL | `OrdinalIgnoreCase` | MySQL identifier comparison is case-insensitive on most platforms | +| SQLite | `OrdinalIgnoreCase` | SQLite identifiers are case-folded | + +`RemoveColumn` is always case-insensitive on every provider — every concrete +provider used `EqualsIgnoreCase` historically. + +## Primary-key name defaults + +When you don't supply a `PrimaryKeyName`, each provider generates one with +its own convention: + +| Provider | Default `PrimaryKeyName` | +| --- | --- | +| PostgreSQL | `pkey_{table}_{cols}` | +| SQL Server | `pkey_{table}_{cols}` | +| Oracle | `pk_{table}_{cols}` | +| MySQL | `pk_{table}_{cols}` | +| SQLite | `pk_{table}` | + +## Schema-object base types + +Most schema objects share a base class in `Weasel.Core`: + +| Object | Core base | Notes | +| --- | --- | --- | +| Tables | `TableBase` | New in 9.0 (#270 step 9) | +| Foreign keys | `ForeignKeyBase` | Owns `Parse`, `LinkColumns`, `Equals`, `ReadReferentialActions` | +| Sequences | `SequenceBase` | PG / SS / Oracle native, MySQL / SQLite table-emulated | +| Functions | `FunctionBase` | PG + SS only; Oracle uses procedures; SQLite functions are connection-scoped | +| Views | `ViewBase` | All providers; SQLite is read-only | +| Indexes | `INamed` only | Provider-specific (PG is ~4× richer than the others) | +| Table columns | `ITableColumn` only | Provider-specific declaration formatting | + +## DDL syntax strategy + +The shared CREATE / DROP algorithm is gradually migrating to consume a +[`IDdlSyntaxStrategy`](../../../src/Weasel.Core/IDdlSyntaxStrategy.cs) object +per provider (#270 step 8). Currently routed through it: + +- `WriteDropTable` (PG appends `CASCADE`, SQLite doesn't) +- `WriteCreateTableHeader` (with / without `IF NOT EXISTS`) + +The remainder of `WriteCreateStatement` is still per-provider while the +strategy shape settles. Plan is to migrate column / PK / FK emission as a +follow-up. + +## Constructor pattern + +All five `Table` classes share the same constructor signatures: + +```csharp +new Table(DbObjectName identifier); // identifier wrapped in provider-specific subclass internally +new Table(string tableName); // parsed via provider's DbObjectName.Parse +``` + +The PostgreSQL and SQLite implementations wrap the supplied `DbObjectName` in +`PostgresqlObjectName` / `SqliteObjectName` respectively; SS / Oracle / MySQL +pass through the `DbObjectName` directly. Either way, the public surface is +uniform — polymorphic schema-building code doesn't need to know which provider +it's targeting until DDL is emitted. + +## When you do need provider-specific code + +Provider-specific extensions stay on the concrete `Table` / `ColumnExpression`: + +- **PostgreSQL**: `MaterializedView`, `Partitioning` (hash / range / list), + `IgnorePartitionsInMigration`, `FullTextIndex`, `UpsertFunction`, + `NpgsqlRange` columns +- **SQL Server**: `SqlServerPartitioning` (range), `PartitionByRange` +- **Oracle**: `PartitionStrategy`, `PartitionExpressions` +- **MySQL**: `Engine`, `Charset`, `Collation`, `PartitionCount`, + `AddFulltextIndex` +- **SQLite**: `WithoutRowId`, `StrictTypes`, `GeneratedAs`, JSON expression + indexes via `ForJsonPath` + +Polymorphic code uses the shared `ITable` / `TableBase` surface; code that +needs provider extras casts to the concrete `Table` type. diff --git a/src/DocSamples/MySqlSamples.cs b/src/DocSamples/MySqlSamples.cs index b3aadeeb..94cf5da2 100644 --- a/src/DocSamples/MySqlSamples.cs +++ b/src/DocSamples/MySqlSamples.cs @@ -42,7 +42,7 @@ public void mysql_define_table() #region sample_mysql_define_table var table = new Table("users"); - table.AddColumn("id").AsPrimaryKey().AutoNumber(); + table.AddColumn("id").AsPrimaryKey().AutoIncrement(); table.AddColumn("name").NotNull(); table.AddColumn("email").NotNull().AddIndex(idx => idx.IsUnique = true); table.AddColumn("created_at"); diff --git a/src/DocSamples/SqlServerSamples.cs b/src/DocSamples/SqlServerSamples.cs index 846173a9..c9fbf2be 100644 --- a/src/DocSamples/SqlServerSamples.cs +++ b/src/DocSamples/SqlServerSamples.cs @@ -53,7 +53,7 @@ public void ss_define_table() #region sample_ss_define_table var table = new Table("dbo.users"); - table.AddColumn("id").AsPrimaryKey().AutoNumber(); + table.AddColumn("id").AsPrimaryKey().AutoIncrement(); table.AddColumn("name").NotNull(); table.AddColumn("email").NotNull().AddIndex(idx => idx.IsUnique = true); table.AddColumn("created_at").DefaultValueByExpression("GETUTCDATE()"); @@ -64,7 +64,7 @@ public void ss_foreign_keys() { #region sample_ss_foreign_keys var orders = new Table("dbo.orders"); - orders.AddColumn("id").AsPrimaryKey().AutoNumber(); + orders.AddColumn("id").AsPrimaryKey().AutoIncrement(); orders.AddColumn("user_id").NotNull() .ForeignKeyTo("dbo.users", "id", onDelete: Weasel.SqlServer.CascadeAction.Cascade); orders.AddColumn("total").NotNull(); diff --git a/src/Weasel.MySql/Tables/Table.cs b/src/Weasel.MySql/Tables/Table.cs index ded94d92..fe018124 100644 --- a/src/Weasel.MySql/Tables/Table.cs +++ b/src/Weasel.MySql/Tables/Table.cs @@ -465,12 +465,28 @@ public ColumnExpression AddSpatialIndex(Action? configure = nul return this; } - public ColumnExpression AutoNumber() + /// + /// Mark this column as an auto-incrementing identity column. MySQL + /// renders this as AUTO_INCREMENT. + /// + /// Canonical cross-provider spelling — every provider's + /// ColumnExpression exposes AutoIncrement() with + /// provider-appropriate SQL emission (#270 step 10). + /// + /// + public ColumnExpression AutoIncrement() { Column.IsAutoNumber = true; return this; } + /// + /// Historical MySQL-specific spelling for . + /// Kept as an alias for backward compatibility. + /// + [Obsolete("Use AutoIncrement() — the cross-provider canonical name. AutoNumber() will be removed in a future major.")] + public ColumnExpression AutoNumber() => AutoIncrement(); + public ColumnExpression DefaultValueByString(string value) { return DefaultValueByExpression($"'{value}'"); diff --git a/src/Weasel.Oracle/Tables/Table.cs b/src/Weasel.Oracle/Tables/Table.cs index 753600ba..4b0d11df 100644 --- a/src/Weasel.Oracle/Tables/Table.cs +++ b/src/Weasel.Oracle/Tables/Table.cs @@ -394,12 +394,28 @@ public ColumnExpression AddIndex(Action? configure = null) return this; } - public ColumnExpression AutoNumber() + /// + /// Mark this column as an auto-incrementing identity column. Oracle + /// renders this as GENERATED BY DEFAULT AS IDENTITY. + /// + /// Canonical cross-provider spelling — every provider's + /// ColumnExpression exposes AutoIncrement() with + /// provider-appropriate SQL emission (#270 step 10). + /// + /// + public ColumnExpression AutoIncrement() { Column.IsAutoNumber = true; return this; } + /// + /// Historical Oracle-specific spelling for . + /// Kept as an alias for backward compatibility. + /// + [Obsolete("Use AutoIncrement() — the cross-provider canonical name. AutoNumber() will be removed in a future major.")] + public ColumnExpression AutoNumber() => AutoIncrement(); + public ColumnExpression DefaultValueByString(string value) { return DefaultValueByExpression($"'{value}'"); diff --git a/src/Weasel.Postgresql/Tables/Table.cs b/src/Weasel.Postgresql/Tables/Table.cs index bec593b9..997ff4a3 100644 --- a/src/Weasel.Postgresql/Tables/Table.cs +++ b/src/Weasel.Postgresql/Tables/Table.cs @@ -411,24 +411,58 @@ public ColumnExpression AddFullTextIndex( return this; } - public ColumnExpression Serial() + /// + /// Mark this column as an auto-incrementing integer identity column. + /// PostgreSQL renders this as SERIAL (32-bit). For 64-bit + /// BIGSERIAL or 16-bit SMALLSERIAL, use + /// or . + /// + /// Canonical cross-provider spelling — every provider's + /// ColumnExpression exposes AutoIncrement() with + /// provider-appropriate SQL emission, so polymorphic schema-building + /// code works without provider-specific casts (#270 step 10). + /// + /// + public ColumnExpression AutoIncrement() { Column.Type = "SERIAL"; return this; } - public ColumnExpression BigSerial() + /// Mark this column as a 64-bit auto-incrementing identity (BIGSERIAL). + public ColumnExpression BigAutoIncrement() { Column.Type = "BIGSERIAL"; return this; } - public ColumnExpression SmallSerial() + /// Mark this column as a 16-bit auto-incrementing identity (SMALLSERIAL). + public ColumnExpression SmallAutoIncrement() { Column.Type = "SMALLSERIAL"; return this; } + /// + /// Historical PostgreSQL-only spelling for . + /// Kept as an alias for backward compatibility; the cross-provider + /// canonical name is AutoIncrement() (#270 step 10). + /// + [Obsolete("Use AutoIncrement() — the cross-provider canonical name. Serial() will be removed in a future major.")] + public ColumnExpression Serial() => AutoIncrement(); + + /// + /// Historical PostgreSQL-only spelling for . + /// + [Obsolete("Use BigAutoIncrement() — the cross-provider canonical name. BigSerial() will be removed in a future major.")] + public ColumnExpression BigSerial() => BigAutoIncrement(); + + /// + /// Historical PostgreSQL-only spelling for . + /// + [Obsolete("Use SmallAutoIncrement() — the cross-provider canonical name. SmallSerial() will be removed in a future major.")] + public ColumnExpression SmallSerial() => SmallAutoIncrement(); + public ColumnExpression DefaultValueByString(string value) { return DefaultValueByExpression($"'{value}'"); diff --git a/src/Weasel.SqlServer/Tables/Table.cs b/src/Weasel.SqlServer/Tables/Table.cs index d55bc83d..98ff03b4 100644 --- a/src/Weasel.SqlServer/Tables/Table.cs +++ b/src/Weasel.SqlServer/Tables/Table.cs @@ -388,12 +388,28 @@ public ColumnExpression AddIndex(Action? configure = null) return this; } - public ColumnExpression AutoNumber() + /// + /// Mark this column as an auto-incrementing identity column. SQL Server + /// renders this as IDENTITY(1,1). + /// + /// Canonical cross-provider spelling — every provider's + /// ColumnExpression exposes AutoIncrement() with + /// provider-appropriate SQL emission (#270 step 10). + /// + /// + public ColumnExpression AutoIncrement() { Column.IsAutoNumber = true; return this; } + /// + /// Historical SQL Server-specific spelling for . + /// Kept as an alias for backward compatibility. + /// + [Obsolete("Use AutoIncrement() — the cross-provider canonical name. AutoNumber() will be removed in a future major.")] + public ColumnExpression AutoNumber() => AutoIncrement(); + public ColumnExpression DefaultValueByString(string value) { return DefaultValueByExpression($"'{value}'");