Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/rules/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
- Merge by squash so each feature arrives as a single logical commit on the default branch, consistent with the "one logical change per commit" rule above; the squash commit message keeps the imperative, *why*-focused form.
- Versioning follows semantic versioning: each phase gets its own minor version (Phase 1 → `0.1.x`, Phase 2 → `0.2.x`, …; Phase 6 ships `1.0.0`).
- When a phase completes, tag it on the default branch with an annotated tag (e.g., `git tag -a v0.1.0 -m "..."`) and push the tag to GitHub for reference.
- Each roadmap milestone has a matching GitHub milestone (created per phase); the milestone's PR is associated on creation and the milestone closed on merge.
- Update this file when a new convention or correction is established.
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,8 @@ dotnet_naming_rule.locals_and_parameters_should_be_camel_case.style = camel_case
dotnet_naming_symbols.locals_and_parameters_symbol.applicable_kinds = parameter, local

dotnet_naming_style.camel_case_style.capitalization = camel_case

# Test methods follow testing.md's Should_ExpectedOutcome_When_Scenario convention instead of
# the Async suffix rule above, even when they are themselves async.
[test/**/*.cs]
dotnet_naming_rule.async_methods_should_be_async_suffixed.severity = none
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
61 changes: 61 additions & 0 deletions src/SqlBound/SqlParameters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace SqlBound;

/// <summary>
/// An immutable, named set of SQL parameter values. Null values are normalized to
/// <see cref="DBNull"/> so callers can bind them to an ADO.NET parameter without a
/// separate null check.
/// </summary>
public sealed class SqlParameters
{
private readonly KeyValuePair<string, object>[] _items;

/// <summary>
/// Initializes a new instance with the given named parameter values. Names must be
/// non-empty and unique.
/// </summary>
/// <exception cref="ArgumentException">
/// A parameter name is null, empty, whitespace, or duplicated.
/// </exception>
public SqlParameters(params (string Name, object? Value)[] parameters)
{
ArgumentNullException.ThrowIfNull(parameters);

var items = new KeyValuePair<string, object>[parameters.Length];
var seenNames = new HashSet<string>(StringComparer.Ordinal);

for (var i = 0; i < parameters.Length; i++)
{
var (name, value) = parameters[i];

if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Parameter name must not be null or whitespace.", nameof(parameters));
}

if (!seenNames.Add(name))
{
throw new ArgumentException($"Duplicate parameter name '{name}'.", nameof(parameters));
}

items[i] = new KeyValuePair<string, object>(name, value ?? DBNull.Value);
}

_items = items;
}

/// <summary>
/// An empty parameter set.
/// </summary>
public static SqlParameters Empty { get; } = new();

/// <summary>
/// The number of parameters in this set.
/// </summary>
public int Count => _items.Length;

/// <summary>
/// The parameters, in the order they were given. Values are never null; a null
/// value provided at construction is normalized to <see cref="DBNull.Value"/>.
/// </summary>
public IReadOnlyList<KeyValuePair<string, object>> Items => _items;
}
125 changes: 125 additions & 0 deletions src/SqlBound/SqlSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System.Data;
using System.Data.Common;

namespace SqlBound;

/// <summary>
/// Executes SQL against an already-open <see cref="DbConnection"/>. SqlSession never opens,
/// closes, or otherwise owns the connection or its lifecycle — that responsibility always
/// stays with the caller, so the same connection (and transaction) can be shared safely with
/// other data-access libraries.
/// </summary>
public sealed class SqlSession
{
private readonly DbConnection _connection;
private readonly DbTransaction? _transaction;

/// <summary>
/// Initializes a new session over the given connection and, optionally, an existing
/// transaction to enlist every command in.
/// </summary>
public SqlSession(DbConnection connection, DbTransaction? transaction = null)
{
ArgumentNullException.ThrowIfNull(connection);

_connection = connection;
_transaction = transaction;
}

/// <summary>
/// Executes a non-query statement and returns the number of affected rows.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="sql"/> is null or whitespace.</exception>
/// <exception cref="InvalidOperationException">The connection is not open.</exception>
public async Task<int> RunAsync(
string sql,
SqlParameters? parameters = null,
CancellationToken cancellationToken = default)
{
ValidateAndPrepare(sql, cancellationToken);

using var command = CreateCommand(sql, parameters);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Executes a statement and returns its first column, first row as <typeparamref name="T"/>.
/// A database null converts to <see langword="default"/> when <typeparamref name="T"/> can
/// represent it (a reference type or <see cref="Nullable{T}"/>); otherwise it throws.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="sql"/> is null or whitespace.</exception>
/// <exception cref="InvalidOperationException">
/// The connection is not open, or the result is a database null and <typeparamref name="T"/>
/// is a non-nullable value type.
/// </exception>
public async Task<T?> FetchScalarAsync<T>(
string sql,
SqlParameters? parameters = null,
CancellationToken cancellationToken = default)
{
ValidateAndPrepare(sql, cancellationToken);

using var command = CreateCommand(sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);

return ConvertScalar<T>(result);
}

private static T? ConvertScalar<T>(object? result)
{
if (result is null or DBNull)
{
if (default(T) is not null)
{
throw new InvalidOperationException(
$"The query returned no value, but '{typeof(T)}' is a non-nullable value type.");
}

return default;
}

if (result is T typed)
{
return typed;
}

var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
return (T)Convert.ChangeType(result, targetType);
}

private void ValidateAndPrepare(string sql, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sql);
cancellationToken.ThrowIfCancellationRequested();
EnsureConnectionOpen();
}

private void EnsureConnectionOpen()
{
if (_connection.State != ConnectionState.Open)
{
throw new InvalidOperationException(
$"The connection must be open before executing a command. Current state: {_connection.State}.");
}
}

private DbCommand CreateCommand(string sql, SqlParameters? parameters)
{
var command = _connection.CreateCommand();
command.CommandText = sql;
command.Transaction = _transaction;

if (parameters is not null)
{
foreach (var (name, value) in parameters.Items)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.Value = value;
command.Parameters.Add(parameter);
}
}

return command;
}
}
102 changes: 102 additions & 0 deletions test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;

namespace SqlBound.UnitTests.Fakes;

internal sealed class FakeDbCommand : DbCommand
{
private readonly FakeDbParameterCollection _parameters = new();

[AllowNull]
public override string CommandText { get; set; } = string.Empty;

public override int CommandTimeout { get; set; }

public override CommandType CommandType { get; set; } = CommandType.Text;

public override bool DesignTimeVisible { get; set; }

public override UpdateRowSource UpdatedRowSource { get; set; }

public int ExecuteNonQueryResult { get; set; }

public object? ExecuteScalarResult { get; set; }

public Exception? ExceptionToThrow { get; set; }

public CancellationToken ReceivedCancellationToken { get; private set; }

protected override DbConnection? DbConnection { get; set; }

protected override DbParameterCollection DbParameterCollection => _parameters;

protected override DbTransaction? DbTransaction { get; set; }

public override void Cancel()
{
}

public override int ExecuteNonQuery()
{
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}

return ExecuteNonQueryResult;
}

public override Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
{
ReceivedCancellationToken = cancellationToken;

if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled<int>(cancellationToken);
}

if (ExceptionToThrow is not null)
{
return Task.FromException<int>(ExceptionToThrow);
}

return Task.FromResult(ExecuteNonQueryResult);
}

public override object? ExecuteScalar()
{
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}

return ExecuteScalarResult;
}

public override Task<object?> ExecuteScalarAsync(CancellationToken cancellationToken)
{
ReceivedCancellationToken = cancellationToken;

if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled<object?>(cancellationToken);
}

if (ExceptionToThrow is not null)
{
return Task.FromException<object?>(ExceptionToThrow);
}

return Task.FromResult(ExecuteScalarResult);
}

public override void Prepare()
{
}

protected override DbParameter CreateDbParameter() => new FakeDbParameter();

protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
throw new NotSupportedException();
}
52 changes: 52 additions & 0 deletions test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;

namespace SqlBound.UnitTests.Fakes;

internal sealed class FakeDbConnection : DbConnection
{
public ConnectionState StateOverride { get; set; } = ConnectionState.Open;

public int ExecuteNonQueryResult { get; set; }

public object? ExecuteScalarResult { get; set; }

public Exception? ExecuteException { get; set; }

public FakeDbCommand? LastCreatedCommand { get; private set; }

[AllowNull]
public override string ConnectionString { get; set; } = string.Empty;

public override string Database => string.Empty;

public override string DataSource => string.Empty;

public override string ServerVersion => string.Empty;

public override ConnectionState State => StateOverride;

public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();

public override void Close() => StateOverride = ConnectionState.Closed;

public override void Open() => StateOverride = ConnectionState.Open;

protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
throw new NotSupportedException();

protected override DbCommand CreateDbCommand()
{
var command = new FakeDbCommand
{
Connection = this,
ExecuteNonQueryResult = ExecuteNonQueryResult,
ExecuteScalarResult = ExecuteScalarResult,
ExceptionToThrow = ExecuteException,
};

LastCreatedCommand = command;
return command;
}
}
30 changes: 30 additions & 0 deletions test/SqlBound.UnitTests/Fakes/FakeDbParameter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;

namespace SqlBound.UnitTests.Fakes;

internal sealed class FakeDbParameter : DbParameter
{
public override DbType DbType { get; set; }

public override ParameterDirection Direction { get; set; } = ParameterDirection.Input;

public override bool IsNullable { get; set; }

[AllowNull]
public override string ParameterName { get; set; } = string.Empty;

public override int Size { get; set; }

[AllowNull]
public override string SourceColumn { get; set; } = string.Empty;

public override bool SourceColumnNullMapping { get; set; }

public override DataRowVersion SourceVersion { get; set; }

public override object? Value { get; set; }

public override void ResetDbType() => DbType = default;
}
Loading
Loading