Skip to content
Open
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
75 changes: 71 additions & 4 deletions src/LibRed/LibRed.Ado/LibRedCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,16 @@ public override void Prepare() { }

protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
{
// SchemaOnly asks what the command WOULD return: the reader carries the columns and no rows, and
// nothing runs — not the INSERT in a batch, and not the stored procedure a name stands for.
if (behavior.HasFlag(CommandBehavior.SchemaOnly))
return new LibRedDataReader(DescribeBatch(), recordsAffected: -1, behavior, Connection);

// Route through Execute so the reader path also handles DML/DDL: EF Core runs inserts through
// ExecuteReader and inspects RecordsAffected. A query yields rows (RecordsAffected -1); an
// INSERT/CREATE runs and yields an empty result carrying its rows-affected count.
Engine.CommandResult result = ExecuteBatch();
return new LibRedDataReader(result.Rows, result.RecordsAffected);
return new LibRedDataReader(result.Rows, result.RecordsAffected, behavior, Connection);
}

/// <summary>
Expand All @@ -70,7 +75,7 @@ private Engine.CommandResult ExecuteBatch()
IReadOnlyDictionary<string, object?> parameters = BuildParameters();

Engine.CommandResult? last = null;
foreach (string statement in SplitStatements(CommandText))
foreach (string statement in SplitStatements(StatementText()))
{
// A fragment holding no statement (only comments) is skipped rather than run: it must not become
// the batch's last result, or `INSERT …; -- done` would report the comment's zero rows instead of
Expand Down Expand Up @@ -104,15 +109,66 @@ private Engine.CommandResult ExecuteBatch()
return last ?? new Engine.CommandResult(Engine.Execution.ResultSet.Empty, RecordsAffected: 0);
}

/// <summary>
/// The shape the command's batch would return, running none of it. As in <see cref="ExecuteBatch"/> the
/// batch's result is its <em>last</em> statement's — and since nothing runs, that is the only one worth
/// describing.
/// </summary>
private Engine.Execution.ResultSet DescribeBatch()
{
ValidateTransaction();
Engine.QueryEngine engine = RequireEngine();

string? last = SplitStatements(StatementText()).LastOrDefault(s => !engine.IsStatementless(s));
return last is null
? Engine.Execution.ResultSet.Empty
: engine.Describe(last, BuildParameters());
}

/// <summary>
/// The SQL this command runs, which for the two non-text command types is built from the name in
/// <see cref="CommandText"/>: a stored procedure — an Access stored query — is executed by name with each
/// of the command's parameters bound to the procedure's parameter of the same name, and a table is read
/// whole. A name is bracket-quoted, so one containing spaces works as it does in Access.
/// </summary>
private string StatementText() => CommandType switch
{
CommandType.Text => CommandText,
CommandType.TableDirect => $"SELECT * FROM {Quote(CommandText)}",
CommandType.StoredProcedure => BuildExecute(),
_ => throw new NotSupportedException($"CommandType.{CommandType} is not supported."),
};

/// <summary>An <c>EXECUTE</c> for the named stored query, naming each parameter so the order the caller
/// added them in does not matter. A procedure taking none runs bare.</summary>
private string BuildExecute()
{
var arguments = _parameters.Cast<LibRedParameter>()
.Where(p => p.Direction is ParameterDirection.Input or ParameterDirection.InputOutput)
.Select(p => $"{Quote(p.ParameterName.TrimStart('@'))} = @{p.ParameterName.TrimStart('@')}")
.ToList();

return arguments.Count == 0
? $"EXECUTE {Quote(CommandText)}"
: $"EXECUTE {Quote(CommandText)} {string.Join(", ", arguments)}";
}

private static string Quote(string name) => $"[{name.Trim().Trim('[', ']')}]";

/// <summary>
/// Splits a batch on top-level <c>;</c> separators, ignoring semicolons inside string literals
/// (<c>'…'</c> / <c>"…"</c>) and quoted identifiers (<c>[…]</c> / <c>`…`</c>). Blank statements
/// (e.g. a trailing <c>;</c>) are dropped. The single-statement common case returns one item.
/// <para>An Access <c>PARAMETERS …;</c> clause is <em>not</em> a statement of its own: its semicolon
/// ends the clause, and the query it declares for follows. Such a fragment is carried onto the next one
/// so the pair reaches the engine as the one statement it is — the form every stored parameterized
/// query reads back as.</para>
/// </summary>
public static IEnumerable<string> SplitStatements(string sql)
{
int start = 0;
char quote = '\0'; // the closing delimiter we're inside, or '\0' at top level
string prefix = string.Empty; // a PARAMETERS clause awaiting its query
for (int i = 0; i < sql.Length; i++)
{
char c = sql[i];
Expand All @@ -125,15 +181,26 @@ public static IEnumerable<string> SplitStatements(string sql)
else if (c == ';')
{
string part = sql[start..i].Trim();
if (part.Length > 0) yield return part;
if (part.Length > 0)
{
if (IsParametersClause(part)) prefix += part + "; ";
else { yield return prefix + part; prefix = string.Empty; }
}
start = i + 1;
}
}

string tail = sql[start..].Trim();
if (tail.Length > 0) yield return tail;
// A clause with nothing after it is yielded as-is, so the engine reports it rather than the batch
// silently running nothing.
if (tail.Length > 0) yield return prefix + tail;
else if (prefix.Length > 0) yield return prefix.TrimEnd(' ', ';');
}

private static bool IsParametersClause(string statement) =>
statement.StartsWith("PARAMETERS", StringComparison.OrdinalIgnoreCase)
&& (statement.Length == "PARAMETERS".Length || char.IsWhiteSpace(statement["PARAMETERS".Length]));

private Engine.QueryEngine RequireEngine() =>
Connection?.Engine ?? throw new InvalidOperationException("Connection is not open.");

Expand Down
17 changes: 17 additions & 0 deletions src/LibRed/LibRed.Ado/LibRedConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,23 @@ public override void Close()
OnStateChange(new StateChangeEventArgs(ConnectionState.Open, ConnectionState.Closed));
}

/// <summary>The names of the metadata collections this provider serves.</summary>
public override DataTable GetSchema() => GetSchema(LibRedSchema.MetaDataCollections, null);

/// <inheritdoc cref="GetSchema()"/>
public override DataTable GetSchema(string collectionName) => GetSchema(collectionName, null);

/// <summary>A metadata collection, filtered by <paramref name="restrictionValues"/>. The collections match
/// the ones ACE's OLE DB provider serves, column for column, so code written against that provider reads
/// the same metadata here. A restriction naming a catalog or schema matches everything: a Jet file holds
/// one nameless catalog and no schemas.</summary>
public override DataTable GetSchema(string collectionName, string?[]? restrictionValues)
{
if (_database is null || _state != ConnectionState.Open)
throw new InvalidOperationException("The connection must be open to read schema metadata.");
return LibRedSchema.Get(collectionName, restrictionValues, _database);
}

public override void ChangeDatabase(string databaseName) =>
throw new NotSupportedException("A Jet/ACE connection maps to a single file.");

Expand Down
98 changes: 92 additions & 6 deletions src/LibRed/LibRed.Ado/LibRedDataReader.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,45 @@
using System.Collections;
using System.Collections.ObjectModel;
using System.Data;
using System.Data.Common;
using LibRed.Engine.Execution;

namespace LibRed.Data;

/// <summary>Forward-only reader projecting an engine <see cref="ResultSet"/> as ADO.NET rows.</summary>
public sealed class LibRedDataReader : DbDataReader
public sealed class LibRedDataReader : DbDataReader, IDbColumnSchemaGenerator
{
private readonly ResultSet _result;
private readonly IEnumerator<object?[]> _rows;
private readonly int _recordsAffected;
private readonly bool _singleRow;
private readonly LibRedConnection? _ownedConnection;
private object?[] _current = [];
private bool _pendingFirst;
private bool _hadRows;
private bool _closed;

/// <param name="recordsAffected">Rows affected for a DML command; -1 for a query (ADO convention).</param>
internal LibRedDataReader(ResultSet result, int recordsAffected = -1)
/// <param name="behavior">
/// The behavior the command was executed with. Two of its flags reach the reader: <c>SingleRow</c> caps the
/// result at the one row (the constructor has already buffered it, so nothing further is ever read), and
/// <c>CloseConnection</c> hands the reader <paramref name="connection"/>'s lifetime — closing the reader
/// then closes it, as ACE does. <c>SingleResult</c> is already the only shape this reader has (a batch
/// returns its last statement's rows and <c>NextResult</c> is always false), <c>SequentialAccess</c> asks
/// for a restriction on a row that is already in memory, and the key and base-column information
/// <c>KeyInfo</c> asks for costs a catalog lookup that <c>GetSchemaTable</c> makes anyway — so those three
/// are accepted and need nothing.
/// </param>
/// <param name="connection">The connection to close when this reader closes, for <c>CloseConnection</c>.</param>
internal LibRedDataReader(
ResultSet result, int recordsAffected = -1,
CommandBehavior behavior = CommandBehavior.Default, LibRedConnection? connection = null)
{
_result = result;
_rows = result.Rows.GetEnumerator();
_recordsAffected = recordsAffected;
_singleRow = behavior.HasFlag(CommandBehavior.SingleRow);
_ownedConnection = behavior.HasFlag(CommandBehavior.CloseConnection) ? connection : null;

// Buffer the first row eagerly so column types (GetFieldType/GetDataTypeName) are available
// before the first Read — EF's BufferedDataReader reads that metadata before reading any rows.
Expand All @@ -44,6 +63,7 @@ internal LibRedDataReader(ResultSet result, int recordsAffected = -1)
public override bool Read()
{
if (_pendingFirst) { _pendingFirst = false; return true; } // yield the pre-buffered first row
if (_singleRow) return false; // that buffered row was the only one asked for
if (!_rows.MoveNext()) return false;
_current = _rows.Current;
return true;
Expand Down Expand Up @@ -109,7 +129,66 @@ public override Type GetFieldType(int ordinal)
: ordinal < _current.Length && _current[ordinal] is { } value ? value.GetType() : typeof(object);
}

public override string GetDataTypeName(int ordinal) => GetFieldType(ordinal).Name;
/// <summary>
/// The provider's name for the column's type — <c>VarChar</c>, <c>Char</c>, <c>Long</c>, <c>Currency</c> —
/// the same name <see cref="GetColumnSchema"/>, <see cref="GetSchemaTable"/> and the <c>DataTypes</c>
/// metadata collection give it, so one type has one name across the whole surface. (ACE's OLE DB provider
/// answers this with the OLE DB spelling, <c>DBTYPE_WVARCHAR</c>; its own schema rowsets use these names,
/// and matching them is what keeps this provider self-consistent.) A result with nothing described behind
/// it — a system-variable select, say — falls back to the CLR type's name.
/// </summary>
public override string GetDataTypeName(int ordinal) =>
_result.Columns[ordinal].TypeName is { Length: > 0 } name ? name : GetFieldType(ordinal).Name;

/// <summary>The result's columns as <see cref="DbColumn"/>s: each column's type, and for one read straight
/// from a table the stored column behind it — its table, its own name, and whether it is a key, unique,
/// an AutoNumber or computed.</summary>
public ReadOnlyCollection<DbColumn> GetColumnSchema() =>
new(_result.Columns.Select((c, i) => (DbColumn)new LibRedDbColumn(c, i)).ToList());

/// <summary>The same description in the older <c>DataTable</c> form, with the column set ADO.NET
/// defines for it.</summary>
public override DataTable GetSchemaTable()
{
var table = new DataTable("SchemaTable") { Locale = System.Globalization.CultureInfo.InvariantCulture };
foreach ((string name, Type type) in new (string, Type)[]
{
(SchemaTableColumn.ColumnName, typeof(string)), (SchemaTableColumn.ColumnOrdinal, typeof(int)),
(SchemaTableColumn.ColumnSize, typeof(int)), (SchemaTableColumn.NumericPrecision, typeof(short)),
(SchemaTableColumn.NumericScale, typeof(short)), (SchemaTableColumn.IsUnique, typeof(bool)),
(SchemaTableColumn.IsKey, typeof(bool)), (SchemaTableOptionalColumn.BaseServerName, typeof(string)),
(SchemaTableOptionalColumn.BaseCatalogName, typeof(string)), (SchemaTableColumn.BaseColumnName, typeof(string)),
(SchemaTableColumn.BaseSchemaName, typeof(string)), (SchemaTableColumn.BaseTableName, typeof(string)),
(SchemaTableColumn.DataType, typeof(Type)), (SchemaTableColumn.AllowDBNull, typeof(bool)),
(SchemaTableColumn.ProviderType, typeof(int)), (SchemaTableColumn.IsAliased, typeof(bool)),
(SchemaTableColumn.IsExpression, typeof(bool)), (SchemaTableOptionalColumn.IsAutoIncrement, typeof(bool)),
(SchemaTableOptionalColumn.IsRowVersion, typeof(bool)), (SchemaTableOptionalColumn.IsHidden, typeof(bool)),
(SchemaTableColumn.IsLong, typeof(bool)), (SchemaTableOptionalColumn.IsReadOnly, typeof(bool)),
("DataTypeName", typeof(string)),
})
table.Columns.Add(name, type);

for (int i = 0; i < _result.Columns.Count; i++)
{
ResultColumn c = _result.Columns[i];
table.Rows.Add(
c.Name, i, (object?)c.Size ?? DBNull.Value,
c.Precision is { } p ? (short)p : DBNull.Value, c.Scale is { } s ? (short)s : DBNull.Value,
c.IsUnique, c.IsKey,
DBNull.Value, DBNull.Value, // a Jet file has no server or catalog
(object?)c.BaseColumnName ?? DBNull.Value, DBNull.Value,
(object?)c.BaseTableName ?? DBNull.Value,
c.ClrType, c.AllowNull, c.ProviderType,
// Aliased when the query renamed the stored column it reads.
c.BaseColumnName is not null && !string.Equals(c.BaseColumnName, c.Name, StringComparison.OrdinalIgnoreCase),
c.IsExpression, c.IsAutoIncrement,
false, false, // Jet has no rowversion, and hides no column
c.IsLong, c.IsReadOnly,
c.TypeName);
}

return table;
}

public override bool GetBoolean(int ordinal)
{
Expand Down Expand Up @@ -187,12 +266,19 @@ private static void ValidateCopyArguments(long dataOffset, int bufferLength, int

public override IEnumerator GetEnumerator() => new DbEnumerator(this, closeReader: false);

public override void Close() => _closed = true;
/// <summary>Releases the cursors the rows are read through, and — under
/// <see cref="CommandBehavior.CloseConnection"/> — closes the connection they came from.</summary>
public override void Close()
{
if (_closed) return;
_closed = true;
_rows.Dispose();
_ownedConnection?.Close();
}

protected override void Dispose(bool disposing)
{
if (disposing) _rows.Dispose();
Close();
if (disposing) Close();
base.Dispose(disposing);
}
}
49 changes: 49 additions & 0 deletions src/LibRed/LibRed.Ado/LibRedDbColumn.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Data.Common;
using LibRed.Engine.Execution;

namespace LibRed.Data;

/// <summary>
/// One column of a result as <see cref="DbColumn"/> — the modern half of <c>GetSchemaTable</c>, from the same
/// description. What the engine could not know is left null rather than guessed: a Jet file has no server,
/// catalog or schema, and nothing in it is a row version or a hidden column.
/// </summary>
internal sealed class LibRedDbColumn : DbColumn
{
internal LibRedDbColumn(ResultColumn column, int ordinal)
{
ColumnName = column.Name;
ColumnOrdinal = ordinal;
ColumnSize = column.Size;
NumericPrecision = column.Precision;
NumericScale = column.Scale;
DataType = column.ClrType;
DataTypeName = column.TypeName;
AllowDBNull = column.AllowNull;
BaseColumnName = column.BaseColumnName;
BaseTableName = column.BaseTableName;
IsAliased = column.BaseColumnName is not null
&& !string.Equals(column.BaseColumnName, column.Name, StringComparison.OrdinalIgnoreCase);
IsExpression = column.IsExpression;
IsAutoIncrement = column.IsAutoIncrement;
IsKey = column.IsKey;
IsUnique = column.IsUnique;
IsLong = column.IsLong;
IsReadOnly = column.IsReadOnly;
IsIdentity = column.IsAutoIncrement;
// Not applicable to a Jet file rather than unknown: no server, catalog or schema, no row versions,
// and every column a query returns is one the caller asked for.
BaseServerName = null;
BaseCatalogName = null;
BaseSchemaName = null;
IsHidden = false;
ProviderType = column.ProviderType;
}

/// <summary>The OLE DB type code, the same one the <c>Columns</c> schema collection reports. Named as
/// <c>GetSchemaTable</c> names it, and reachable through the indexer as well as directly.</summary>
public int ProviderType { get; }

public override object? this[string property] =>
property == nameof(ProviderType) ? ProviderType : base[property];
}
Loading
Loading