| Lesson 6 | Direct Database Access |
| Objective | Execute database commands directly, without a Design Time Control standing in the way. |
SqlCommand directly: a connection string, a parameterized statement, a call to ExecuteNonQueryAsync or ExecuteScalarAsync, and nothing standing between that code and VirtualBookShelf's own Customer and Inventory tables. That's worth stopping to name explicitly, because it's exactly what this lesson is about. There's no drag-and-drop control generating that code on your behalf, no bound GridView or SqlDataSource wiring up the query for you behind the scenes, and no designer surface to configure. You've been doing direct database access since Module 4, one handler at a time, without a visual tool standing between your code and the database.
DE. The Data Environment was a centralized definition of a database connection and a set of named command objects, configured once and stored in the site's Global.asa file. Once defined, every one of those named commands became callable as if it were a method directly on the DE object itself: a command named GetInventory could be run with a line as short as DE.GetInventory, and if that command returned rows, the Data Environment runtime automatically built a Recordset for you, named by convention with an rs prefix, such as DE.rsGetInventory, ready to loop over immediately.
<%
thisPage.CreateDE()
DE.GetInventory
set rs = DE.rsGetInventory
rs.moveFirst
%>
The first line creates a reference to the page's Data Environment object. The second line runs the named GetInventory command, which the Data Environment had been configured to execute as a query returning every row in the Inventory table. The third line points a local rs variable at the Recordset the Data Environment built automatically from that query's results. The fourth line calls the Recordset's moveFirst method to make sure iteration starts at the first row before any of the navigation or display techniques covered earlier in this course run against it.
DE.GetInventory runs a named, pre-configured command with no inline SQL visible at the call site. A direct SqlCommand call does the same thing, just with the SQL visible right there in the handler instead of hidden behind a name configured elsewhere:
using SqlConnection connection = new(_connectionString);
await connection.OpenAsync();
SqlCommand command = new(
"SELECT BookID, Title, QuantityOnHand FROM Inventory",
connection);
using SqlDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
// read each row directly, the same role rs.moveFirst and
// the Recordset's own navigation played in the DE example above
}
Nothing here is new material. It's the same shape every Update, Insert, and Delete handler in this module has already used: open a connection, build a command against a real table, run it, and read or act on the result directly. The Data Environment's job was to let a developer skip the DTC and talk to the database directly. A hand-written SqlCommand is that same thing, written in a different language against a different database engine, two decades later.
SqlCommand built so far in this module has lived inline inside its own handler, which is the right choice for a command a page uses exactly once. A query that several different handlers or several different pages need, on the other hand, is worth naming the same way a Data Environment command was named: as a small, private helper method on the page model.
private async Task<List<InventoryItem>> GetLowStockInventoryAsync(int threshold)
{
List<InventoryItem> items = new();
using SqlConnection connection = new(_connectionString);
await connection.OpenAsync();
SqlCommand command = new(
"SELECT BookID, Title, QuantityOnHand FROM Inventory " +
"WHERE QuantityOnHand < @Threshold",
connection);
command.Parameters.AddWithValue("@Threshold", threshold);
using SqlDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
items.Add(new InventoryItem
{
BookId = reader.GetInt32(0),
Title = reader.GetString(1),
QuantityOnHand = reader.GetInt32(2)
});
}
return items;
}
Calling await GetLowStockInventoryAsync(5) from an OnGetAsync handler reads almost as directly as DE.GetInventory did, with the SQL, the parameter, and the row-reading logic defined once and reused by name rather than retyped at every call site:
public async Task OnGetAsync()
{
LowStockItems = await GetLowStockInventoryAsync(5);
}
This isn't a repository pattern and it isn't an ORM; it's a single private method wrapping a single SqlCommand, which is as far as this course's stack needs to go to recover the one genuinely useful idea the Data Environment was built around.
SqlCommand in a project.
Global.asa, defined once at the application level so every page could reach the database without redefining the connection string itself. The modern equivalent is a connection string stored in appsettings.json and read through the configuration system, then handed to each page model's constructor by the framework's dependency injection container, exactly as every page model in this course has already been built. The storage location changed from a classic ASP global file to a JSON configuration file read through IConfiguration, but the underlying idea, define the connection once and let every page reach it without repeating it, carried forward unchanged.
SqlConnection and SqlCommand directly, is the option this course has used throughout, and the one this lesson has just shown is the direct modern descendant of the Data Environment's own "run a command directly" philosophy.SqlCommand shows exactly what statement runs, exactly which parameters it carries, and exactly when the connection opens and closes. An ORM's generated SQL is often just as correct, and frequently more convenient to write, but it's generated, which means a learner working through the SQL lessons earlier in this course would be looking at C# code and trusting that the right statement eventually ran, rather than seeing the statement itself. That trade is worth making differently once the fundamentals are solid; it isn't the right trade for the lessons teaching the fundamentals in the first place.
asp-for and asp-page-handler wired up explicitly rather than generated by a scaffolding tool, and a layout chosen deliberately rather than inherited from a control's default rendering. A scaffolded CRUD page generated by a tool is this course's closest equivalent to a DTC: fast to produce, reasonable for a quick administrative screen, and the wrong choice whenever the page needs to look or behave like something other than a generic grid.
Is there a direct ASP.NET Core replacement for the Data Environment object itself?
Not as a single named object, no. Its two jobs split apart: the connection-string storage moved to appsettings.json plus dependency injection, and the named-command idea moved to whatever shape fits the code, a private helper method for a simple case, a small data-access class for a larger one.
Why didn't this lesson use Entity Framework Core, since it's the most direct tool for mapping a Data Environment's objects to C# classes?
Because the Data Environment's core feature was running a command directly, without an abstraction layer managing it. A direct SqlCommand call preserves that same directness; introducing an ORM here would replace one layer of abstraction (the DTC and the Data Environment) with another, rather than removing the abstraction the way this lesson's objective asks for.
What was the Scripting Object Model, and does anything need to be "enabled" today the way it did then?
The SOM was a classic ASP setting that exposed a page's available scripting objects, and had to be turned on before a page could use them. Nothing comparable needs to be switched on in ASP.NET Core; a page model's constructor simply declares what it needs, and dependency injection provides it automatically.
Did classic ADO have any gotchas around running commands directly, the way this module has flagged a few ADO.NET pitfalls?
Yes. Two are worth knowing even though neither applies to a stateless SqlCommand call. A stored procedure containing more than one SELECT or UPDATE statement could cause ADO to misreport the number of affected rows unless the procedure began with SET NOCOUNT ON, because every intermediate statement sent its own row-count signal back to the client. And calling a Data Environment command a second time while its previously auto-generated Recordset was still open raised error 3705, "Operation is not allowed when the object is open," requiring an explicit check and close first. A directly built SqlCommand has no equivalent state to manage between calls, so neither situation can occur.
Is a private helper method wrapping a SqlCommand the same thing as the Repository pattern?
No. The Repository pattern is a broader architectural choice involving interfaces and typically a full abstraction over an entire table or aggregate. A single private method wrapping one query is a much smaller step, useful specifically when one query is called from more than one place, and it doesn't commit the rest of the code to any larger pattern.
Does the custom-interface argument still hold up against modern scaffolding tools, not just old DTCs?
Yes, for the same reason. A scaffolded page generates a working interface quickly, in exchange for looking and behaving like whatever the scaffolding template produces. The trade is identical to the one the original lesson described for DTCs: speed for a generic result, versus hand-written code for a specific one.
What was Visual InterDev, and why does this course keep referencing a tool from a previous version of Visual Studio?
Visual InterDev was Microsoft's web development IDE for classic ASP, the predecessor environment to the ASP.NET tooling built into Visual Studio today, and it's where the Data Environment object and its DTCs originally lived. It's referenced here only because the legacy course content being modernized in this lesson was written against it; nothing about Visual InterDev itself carries forward into a Visual Studio 2026 project.
ExecuteNonQueryAsync pattern underneath them. Lesson 2 built an Update handler against a parameterized SqlCommand, replacing a Recordset's updateRecord method and its keyset-cursor requirement. Lesson 3 built an Insert handler, replacing the two-step addRecord/updateRecord sequence with a single statement and an OUTPUT INSERTED clause for the new identity value. Lesson 4 built a Delete handler, the simplest of the three statements, and closed with a side-by-side comparison of all three SQL shapes. Lesson 5 named the type-coercion concern that had been quietly sitting underneath every parameter in every one of those commands, replacing VBScript's CInt family with ASP.NET Core's model binding and TryParse methods.