Data Access with ADO.NET and EF Core
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example that talks to a
database and prints results is a complete program that was built with warnings treated as errors (Option Strict On, root namespace
ShelfApp) and run in the .NET 10 SDK container on Linux. Databases that need a server (SQL Server, MongoDB, Redis) cannot run in that
container, so the SQL Server code is labelled built, not run; the parts of the SQL Server provider that do not need a server (connection
string defaults, the error for an unreachable server) were run. SQLite, which is an in-process engine, stands in for the server wherever a
real round trip is needed: Microsoft.Data.Sqlite for ADO.NET and the Microsoft.EntityFrameworkCore.Sqlite provider for EF Core. Package versions
used: Microsoft.Data.Sqlite 10.0.13, Microsoft.Data.SqlClient 7.1.1, Microsoft.EntityFrameworkCore.Sqlite 10.0.13, Dapper 2.1.89.
Data access is where Visual Basic differs from C# in several small ways that all come from the same source: the language has no nullable
reference types and no Await Using, and the tooling around it (the EF Core command line, the
designers) was built for C# first. This page shows what works as in C#, what needs a different spelling, and what does not work at all. The reference
scenario is the shelf catalogue; the entities are Book, Author and the others from Shelf.Core.
SQL itself, indexes and schema design are not repeated here: see the SQL reference and the Databases section. EF Core concepts are explained for C# on Data Access with EF Core in the ASP.NET Core section, and the older EF6 on Data Access with EF6; this page covers the Visual Basic spelling and the differences.
Which data-access API?
| API | Choose it when | Visual Basic notes |
|---|---|---|
ADO.NET provider ( |
You write SQL, need every provider feature (bulk copy, table-valued parameters, output parameters), or you are building the layer that other tools sit on. |
Everything works. Use |
Dapper |
You write SQL but do not want to write the reader loop. It is a small library that adds extension methods to |
Works; anonymous types are the usual way to pass parameters. A community project, not a Microsoft one. |
EF Core |
You want a model, change tracking, LINQ and migrations. |
Works. Migrations must be scaffolded from a C# project; query syntax such as |
EF6 and LINQ to SQL |
You maintain an existing .NET Framework application. |
Legacy. Not for new development; see the last sections. |
Stored procedures |
The logic lives in the database. |
Call them through ADO.NET ( |
Project setup
Every ADO.NET and EF Core example in this page is a console project of this shape (the Dapper, caching and configuration examples add one package each). The Directory.Build.props
turns Option Strict On and warnings-as-errors on for the whole solution, as the other pages do; NuGetAudit is off only so that the build output
of these scratch projects is deterministic (see the note on advisories below).
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OptionExplicit>On</OptionExplicit>
<OptionStrict>On</OptionStrict>
<OptionInfer>On</OptionInfer>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>ShelfApp</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.13" />
<PackageReference Include="Dapper" Version="2.1.89" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.13" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.13" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.13" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.13" />
<ProjectReference Include="..\Shelf.Core\Shelf.Core.vbproj" />
</ItemGroup>
</Project>
The programs in the next sections are Module`s in one project, each with its own `Sub Main; the project property StartupObject (here set on the build command line) chooses which one runs.
In your own project there is one Sub Main.
A note on package advisories. With Microsoft.EntityFrameworkCore.Sqlite 10.0.0 the restore reported NU1903 (a known high-severity advisory) for the native SQLite package that it pulls
in (SQLitePCLRaw.lib.e_sqlite3 2.1.11). The examples here use 10.0.13 and switch auditing off only to keep their output reproducible; in a real project leave NuGetAudit on, treat NU1903 as
a prompt to update the package, and combine it with TreatWarningsAsErrors deliberately.
ADO.NET in one page
ADO.NET is a set of abstract classes in System.Data.Common that every provider implements:
| Abstract type | SQL Server (Microsoft.Data.SqlClient) |
SQLite (Microsoft.Data.Sqlite) |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
none (the assembly exports no type that derives from |
|
|
|
|
|
|
The provider you pick is a NuGet package. For SQL Server use Microsoft.Data.SqlClient, which replaces System.Data.SqlClient (the old package is the one that
older books and answers show; the replacement keeps the names, so Imports System.Data.SqlClient becomes Imports Microsoft.Data.SqlClient, and the new provider
encrypts by default; see below). System.Data.OracleClient no longer exists; Oracle’s own managed provider is Oracle.ManagedDataAccess.Core. Npgsql (PostgreSQL) and
MySqlConnector (MySQL, MariaDB) are community providers with the same shape. The abstract types let a repository be written once against DbConnection
and DbCommand, and DbProviderFactory creates the concrete ones when the provider is chosen at run time.
Source: ADO.NET overview and DbConnection
Connections, pooling and connection strings
A connection is expensive to open and cheap to keep in a pool. The rule that follows is the same in every language: open late, close early, and let the pool do the sharing. In Visual Basic that means a
Using block around every SqlConnection or SqliteConnection; the Dispose at End Using returns the physical connection to the pool, it does not close the socket.
Pooling defaults, and a failed connect, without a server
Two things about SQL Server connections can be checked without a server. The first is what the connection-string builder assumes when you say nothing; the second is what a failed connection looks like. This program was run (a loopback port with nothing listening gives an immediate refusal, so no network is needed):
Option Strict On
Imports Microsoft.Data.SqlClient
Module Offline
Sub Main()
Dim builder As New SqlConnectionStringBuilder()
Console.WriteLine("Defaults of SqlConnectionStringBuilder:")
Console.WriteLine(" Encrypt = " & builder.Encrypt.ToString())
Console.WriteLine(" TrustServerCertificate = " & builder.TrustServerCertificate)
Console.WriteLine(" Pooling = " & builder.Pooling)
Console.WriteLine(" Min / Max Pool Size = " & builder.MinPoolSize & " / " & builder.MaxPoolSize)
Console.WriteLine(" Connect Timeout = " & builder.ConnectTimeout)
Console.WriteLine(" MultipleActiveResultSets = " & builder.MultipleActiveResultSets)
builder.DataSource = "db.example.test"
builder.InitialCatalog = "Shelf"
builder.IntegratedSecurity = True
builder.ApplicationName = "Shelf.Cli"
builder.ConnectTimeout = 1
Console.WriteLine("Built: " & builder.ConnectionString)
' Nothing listens on this loopback port, so the failure is immediate and needs no network.
builder.DataSource = "127.0.0.1,1"
Using connection As New SqlConnection(builder.ConnectionString)
Try
connection.Open()
Catch ex As SqlException
Console.WriteLine("SqlException number " & ex.Number & ", class " & ex.Class)
Console.WriteLine(" message starts: " & ex.Message.Substring(0, ex.Message.IndexOf(" The server")))
End Try
End Using
End Sub
End Module
Defaults of SqlConnectionStringBuilder:
Encrypt = True
TrustServerCertificate = False
Pooling = True
Min / Max Pool Size = 0 / 100
Connect Timeout = 15
MultipleActiveResultSets = False
Built: Data Source=db.example.test;Initial Catalog=Shelf;Integrated Security=True;Connect Timeout=1;Application Name=Shelf.Cli
SqlException number 0, class 20
message starts: A network-related or instance-specific error occurred while establishing a connection to SQL Server.
Read the defaults: pooling is on with a maximum of 100 connections per distinct connection string, the connect timeout is 15 seconds, and, since version 4 of Microsoft.Data.SqlClient, Encrypt
is True and TrustServerCertificate is False. A program that used to connect to a development server with a self-signed certificate through System.Data.SqlClient therefore fails with a certificate
error after the switch. The fix for a real server is a valid certificate. For a throw-away local server, TrustServerCertificate=True makes the error go away and removes the protection, so keep it out of
production configuration. A failed connect raises SqlException, whose Number is 0 for transport failures and a server error number once the server answered; the text of the transport message varies by operating system, which is why the
program prints only its first sentence.
Pooling rules that matter (SQL Server):
-
A pool belongs to one connection string (character for character), one process and, with integrated security, one identity. Ten strings that differ only by
Application Namemake ten pools. -
A connection that is not disposed is not returned: the classic symptom is
InvalidOperationException("Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool"). Visual Basic’sUsingmakes the correct code the short code; the leak appears when the connection is stored in a field and nobody owns its disposal. -
SqlConnection.ClearAllPools()andClearPool(connection)exist for tests and for failover; do not call them in normal code. -
Microsoft.Data.Sqlitepools too (thePoolingkeyword;New SqliteConnectionStringBuilder().Poolingis True, see the check below) and its in-memory databases live only as long as one connection stays open, which is why the EF examples below keep a connection open for the duration.
Source: Connection pooling and SQLite connection strings
Connection strings come from configuration
A connection string holds a server name and often credentials. It does not belong in source code, in the repository, or in a compiled resource. Read it from configuration at start-up, and keep the secret part in a
store that is not in the repository: environment variables, dotnet user-secrets for development, a cloud key vault or the platform’s secret manager in production, or integrated authentication so that there is no secret at all.
Microsoft.Extensions.Configuration layers sources, and later sources override earlier ones. The program writes a small appsettings.json, then overrides the value with an environment variable (the double underscore stands for the : separator):
Option Strict On
Imports System.IO
Imports Microsoft.Data.Sqlite
Imports Microsoft.Extensions.Configuration
Module ConfigDemo
Sub Main()
Dim folder = Path.Combine(Path.GetTempPath(), "shelf-config-demo")
Directory.CreateDirectory(folder)
File.WriteAllText(Path.Combine(folder, "appsettings.json"),
"{ ""ConnectionStrings"": { ""Shelf"": ""Data Source=shelf-dev.db;Cache=Shared"" } }")
Dim configuration = New ConfigurationBuilder().
SetBasePath(folder).
AddJsonFile("appsettings.json", optional:=False).
AddEnvironmentVariables(prefix:="SHELF_").
Build()
Dim fromFile = configuration.GetConnectionString("Shelf")
Console.WriteLine("From appsettings.json: " & fromFile)
Environment.SetEnvironmentVariable("SHELF_ConnectionStrings__Shelf", "Data Source=:memory:")
Dim withEnvironment = New ConfigurationBuilder().
SetBasePath(folder).
AddJsonFile("appsettings.json", optional:=False).
AddEnvironmentVariables(prefix:="SHELF_").
Build()
Dim effective = withEnvironment.GetConnectionString("Shelf")
Console.WriteLine("With SHELF_ConnectionStrings__Shelf set: " & effective)
Dim builder As New SqliteConnectionStringBuilder(effective)
builder.Mode = SqliteOpenMode.ReadWriteCreate
builder.Pooling = False
Console.WriteLine("Normalised: " & builder.ConnectionString)
Using connection As New SqliteConnection(builder.ConnectionString)
connection.Open()
Console.WriteLine("Opened, server version " & connection.ServerVersion.Split("."c)(0) & ".x")
End Using
File.Delete(Path.Combine(folder, "appsettings.json"))
End Sub
End Module
From appsettings.json: Data Source=shelf-dev.db;Cache=Shared
With SHELF_ConnectionStrings__Shelf set: Data Source=:memory:
Normalised: Data Source=:memory:;Mode=ReadWriteCreate;Pooling=False
Opened, server version 3.x
Source: Configuration in .NET and Configuration providers
Visual Basic specifics seen in this program: named arguments use := (optional:=False); the extension methods AddJsonFile and AddEnvironmentVariables need their packages and Imports Microsoft.Extensions.Configuration; and a local variable named
directory would hide System.IO.Directory because names are not case-sensitive (the first version of this program failed with BC30456, 'CreateDirectory' is not a member of 'String', for exactly that reason). Use SqlConnectionStringBuilder (or
SqliteConnectionStringBuilder) instead of concatenating strings: it validates keywords, escapes values and shows the normalised result, as the Normalised: line above does.
Commands, parameters and readers
The first program uses everything in the basic cycle: create a table, insert rows with parameters, read a scalar, and read rows with a reader. It was built and run:
Option Strict On
Imports Microsoft.Data.Sqlite
Module Basics
Private Structure BookSeed
Public Title As String
Public Year As Integer
Public Isbn As String
Public Subtitle As String
End Structure
Sub Main()
Dim seeds = {
New BookSeed With {.Title = "Dune", .Year = 1965, .Isbn = "9780441172719"},
New BookSeed With {.Title = "Neuromancer", .Year = 1984, .Isbn = "9780441569595", .Subtitle = "A novel"},
New BookSeed With {.Title = "Hyperion", .Year = 1989, .Isbn = "9780553283686"}}
Using connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
Using create = connection.CreateCommand()
create.CommandText =
"CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, Year INTEGER NOT NULL, Isbn TEXT NOT NULL UNIQUE, Subtitle TEXT NULL)"
create.ExecuteNonQuery()
End Using
Using insert = connection.CreateCommand()
insert.CommandText = "INSERT INTO Books (Title, Year, Isbn, Subtitle) VALUES ($title, $year, $isbn, $subtitle)"
insert.Parameters.Add("$title", SqliteType.Text)
insert.Parameters.Add("$year", SqliteType.Integer)
insert.Parameters.Add("$isbn", SqliteType.Text)
insert.Parameters.Add("$subtitle", SqliteType.Text)
For Each seed In seeds
insert.Parameters("$title").Value = seed.Title
insert.Parameters("$year").Value = seed.Year
insert.Parameters("$isbn").Value = seed.Isbn
insert.Parameters("$subtitle").Value = If(seed.Subtitle Is Nothing, DBNull.Value, CObj(seed.Subtitle))
Console.WriteLine($"Inserted {insert.ExecuteNonQuery()} row: {seed.Title}")
Next
End Using
Using count = connection.CreateCommand()
count.CommandText = "SELECT COUNT(*) FROM Books WHERE Year > $year"
count.Parameters.Add("$year", SqliteType.Integer).Value = 1970
Dim total = CLng(count.ExecuteScalar())
Console.WriteLine($"Books after 1970: {total}")
End Using
Using query = connection.CreateCommand()
query.CommandText = "SELECT Id, Title, Year, Subtitle FROM Books ORDER BY Year"
Using reader = query.ExecuteReader()
Dim title = reader.GetOrdinal("Title")
Dim subtitle = reader.GetOrdinal("Subtitle")
While reader.Read()
Dim text = If(reader.IsDBNull(subtitle), "(none)", reader.GetString(subtitle))
Console.WriteLine($"{reader.GetInt32(0)}: {reader.GetString(title)} [{reader.GetInt32(2)}] subtitle={text}")
End While
End Using
End Using
End Using
End Sub
End Module
Inserted 1 row: Dune
Inserted 1 row: Neuromancer
Inserted 1 row: Hyperion
Books after 1970: 2
1: Dune [1965] subtitle=(none)
2: Neuromancer [1984] subtitle=A novel
3: Hyperion [1989] subtitle=(none)
Source: SQLite parameters and DbDataReader
Points to take from it:
-
Parameters, always. Every value that comes from outside the program goes in a parameter. The SQL text stays constant, the provider sends the values separately and the database never parses them as SQL. With SQL Server the idiom is
command.Parameters.Add("@isbn", SqlDbType.NChar, 13).Value = isbn: the type and size are explicit, so the plan cache sees one statement.Parameters.AddWithValue("@isbn", isbn)is shorter and lets the provider infer the type from the .NET value, which produces a different parameter size for each string length and, forStringagainst avarcharcolumn, an implicit conversion that can stop an index from being used. PreferAdd(name, type[, size]).Value.Microsoft.Data.Sqliteaccepts both and is not affected by the plan-cache issue; every example on this page still uses typedAdd, so the habit carries over to SQL Server unchanged. -
Parameters.Add(...)returns the new parameter, so.Value = xcan be chained onto it, as incommand.Parameters.Add("@isbn", SqlDbType.NChar, 13).Value = isbnin the SQL Server listing below. The first program declares the parameters once and setsValuethrough the collection by name. -
Reuse one command in a loop. Declare the parameters once, change
Valuefor each row, callExecuteNonQuery. The program above inserts three rows with one prepared command shape. -
Readers are forward-only and hold the connection. Read by ordinal (
GetOrdinalonce, outside the loop) with the typed getters (GetInt32,GetString), not byreader("Title")in a loop, which does a name lookup for every row and returnsObjectthatOption Strict Onmakes you convert. -
ExecuteScalarreturnsObject: convert it (CLng(...); SQLite’sCOUNT(*)is a 64-bit integer, SQL Server’s is a 32-bit one, andCInton a boxedLongis a conversion, not an unboxing).ExecuteNonQueryreturns the number of rows affected.
Nothing is not NULL
The most Visual Basic-specific mistake in data access is to pass Nothing for a missing value. In .NET, a database NULL is the singleton DBNull.Value; Nothing is a null reference, which the provider
treats as "no value was supplied". This program shows what the provider does with each, and the other classic ADO.NET failures: a closed connection, a constraint violation, SQL built by concatenation, and reading NULL with a typed getter:
Option Strict On
Imports Microsoft.Data.Sqlite
Module Pitfalls
Sub Main()
Using connection As New SqliteConnection("Data Source=:memory:")
' 1. A command on a closed connection
Using command = connection.CreateCommand()
command.CommandText = "SELECT 1"
Try
command.ExecuteScalar()
Catch ex As InvalidOperationException
Console.WriteLine("Closed connection: " & ex.Message)
End Try
End Using
connection.Open()
Using create = connection.CreateCommand()
create.CommandText = "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, Isbn TEXT NOT NULL UNIQUE, Subtitle TEXT NULL)"
create.ExecuteNonQuery()
End Using
' 2. Nothing is not NULL
Using insert = connection.CreateCommand()
insert.CommandText = "INSERT INTO Books (Title, Isbn, Subtitle) VALUES ($title, $isbn, $subtitle)"
insert.Parameters.Add("$title", SqliteType.Text).Value = "Dune"
insert.Parameters.Add("$isbn", SqliteType.Text).Value = "9780441172719"
insert.Parameters.Add("$subtitle", SqliteType.Text).Value = Nothing
Try
insert.ExecuteNonQuery()
Catch ex As InvalidOperationException
Console.WriteLine("Value Nothing: " & ex.Message)
End Try
insert.Parameters("$subtitle").Value = DBNull.Value
Console.WriteLine("Value DBNull.Value: inserted " & insert.ExecuteNonQuery())
End Using
' 3. A constraint violation
Using duplicate = connection.CreateCommand()
duplicate.CommandText = "INSERT INTO Books (Title, Isbn) VALUES ('Dune again', '9780441172719')"
Try
duplicate.ExecuteNonQuery()
Catch ex As SqliteException
Console.WriteLine($"SqliteException {ex.SqliteErrorCode}/{ex.SqliteExtendedErrorCode}: {ex.Message}")
End Try
End Using
' 4. Concatenated text versus a parameter
Dim hostile = "x' OR '1'='1"
Using unsafeCommand = connection.CreateCommand()
unsafeCommand.CommandText = "SELECT COUNT(*) FROM Books WHERE Title = '" & hostile & "'"
Console.WriteLine("Concatenated: " & unsafeCommand.CommandText)
Console.WriteLine(" rows matched: " & CLng(unsafeCommand.ExecuteScalar()))
End Using
Using safeCommand = connection.CreateCommand()
safeCommand.CommandText = "SELECT COUNT(*) FROM Books WHERE Title = $title"
safeCommand.Parameters.Add("$title", SqliteType.Text).Value = hostile
Console.WriteLine("Parameterised: " & safeCommand.CommandText)
Console.WriteLine(" rows matched: " & CLng(safeCommand.ExecuteScalar()))
End Using
' 5. Reading a NULL column without checking
Using query = connection.CreateCommand()
query.CommandText = "SELECT Subtitle FROM Books"
Using reader = query.ExecuteReader()
reader.Read()
Try
Console.WriteLine(reader.GetString(0))
Catch ex As InvalidOperationException
Console.WriteLine("GetString on NULL: " & ex.Message)
End Try
Console.WriteLine("GetValue on NULL is DBNull: " & (reader.GetValue(0) Is DBNull.Value))
End Using
End Using
End Using
End Sub
End Module
Closed connection: ExecuteScalar can only be called when the connection is open.
Value Nothing: Value must be set.
Value DBNull.Value: inserted 1
SqliteException 19/2067: SQLite Error 19: 'UNIQUE constraint failed: Books.Isbn'.
Concatenated: SELECT COUNT(*) FROM Books WHERE Title = 'x' OR '1'='1'
rows matched: 1
Parameterised: SELECT COUNT(*) FROM Books WHERE Title = $title
rows matched: 0
GetString on NULL: The data is NULL at ordinal 0. This method can't be called on NULL values. Check using IsDBNull before calling.
GetValue on NULL is DBNull: True
Source: SQLite errors and SQL injection
The fifth block is worth understanding because it is the SQL injection demonstration in miniature. The concatenated text x' OR '1'='1 closes the string literal and adds a condition that is always true, so the query matches a row that does not have that title; the same text sent as a parameter is just an unusual title and
matches nothing. With a database that supports stacked statements the same technique deletes tables. SQL injection is the only item on this page that can end a company, and the defence is mechanical: parameters for values, an allow-list for identifiers (a column name cannot be a parameter),
least-privilege database accounts, and no string building of SQL. See Security and Cryptography for the rest of the security picture.
NULL handling in Visual Basic, in one table:
| Task | Visual Basic spelling |
|---|---|
Send a NULL |
|
Test a column |
|
Map to a nullable value |
|
Compare in the SQL |
|
Transactions
A transaction makes a group of commands atomic. BeginTransaction returns an object that must be disposed; if neither Commit nor Rollback ran, disposing rolls back. With Microsoft.Data.Sqlite (as with SqlClient when you use explicit transactions) each command must be told which
transaction it belongs to. This program moves stock between two shelves; the second transfer violates a CHECK constraint and rolls back:
Option Strict On
Imports Microsoft.Data.Sqlite
Module Transactions
Sub Main()
Using connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
Execute(connection, "CREATE TABLE Accounts (Name TEXT PRIMARY KEY, Balance INTEGER NOT NULL CHECK (Balance >= 0))")
Execute(connection, "INSERT INTO Accounts VALUES ('Reading room', 100), ('Archive', 50)")
Transfer(connection, "Reading room", "Archive", 30)
Transfer(connection, "Reading room", "Archive", 500)
PrintBalances(connection)
End Using
End Sub
Private Sub Transfer(connection As SqliteConnection, source As String, target As String, amount As Integer)
Using transaction = connection.BeginTransaction()
Try
Move(connection, transaction, source, -amount)
Move(connection, transaction, target, amount)
transaction.Commit()
Console.WriteLine($"Committed: {amount} from {source} to {target}")
Catch ex As SqliteException
transaction.Rollback()
Console.WriteLine($"Rolled back ({ex.SqliteErrorCode}): {ex.Message}")
End Try
End Using
End Sub
Private Sub Move(connection As SqliteConnection, transaction As SqliteTransaction, name As String, delta As Integer)
Using command = connection.CreateCommand()
command.Transaction = transaction
command.CommandText = "UPDATE Accounts SET Balance = Balance + $delta WHERE Name = $name"
command.Parameters.Add("$delta", SqliteType.Integer).Value = delta
command.Parameters.Add("$name", SqliteType.Text).Value = name
command.ExecuteNonQuery()
End Using
End Sub
Private Sub Execute(connection As SqliteConnection, sql As String)
Using command = connection.CreateCommand()
command.CommandText = sql
command.ExecuteNonQuery()
End Using
End Sub
Private Sub PrintBalances(connection As SqliteConnection)
Using command = connection.CreateCommand()
command.CommandText = "SELECT Name, Balance FROM Accounts ORDER BY Name"
Using reader = command.ExecuteReader()
While reader.Read()
Console.WriteLine($"{reader.GetString(0)}: {reader.GetInt32(1)}")
End While
End Using
End Using
End Sub
End Module
Committed: 30 from Reading room to Archive
Rolled back (19): SQLite Error 19: 'CHECK constraint failed: Balance >= 0'.
Archive: 80
Reading room: 70
Source: SQLite transactions
Rules of thumb: keep transactions short, open them as late as possible and commit as early as possible; never wait for a user inside one; catch the specific exception you can handle (SqlException with a known Number, such as 1205 for a deadlock victim in SQL Server, after which the whole unit of work may be retried) and roll back
in every other case. System.Transactions.TransactionScope is the ambient alternative and also works with the providers; it enlists connections that open inside the scope, which can silently escalate to a distributed transaction when two connection strings are used.
Prefer the explicit transaction unless the code you call is outside your control.
Async ADO.NET
Every I/O method has an async twin (OpenAsync, ExecuteReaderAsync, ReadAsync, ExecuteNonQueryAsync) that takes a CancellationToken. Visual Basic has no Await Using, so the code uses a plain Using for synchronous disposal
or, when the asynchronous disposal matters, a Try/Finally with an explicit Dispose. Awaiting inside Finally is not allowed:
Advanced.vb(47,38): error BC36943: 'Await' cannot be used inside a 'Catch' statement, a 'Finally' statement, or a 'SyncLock' statement.
This program was built and run; it also shows cancellation (a token that is already cancelled stops the call before it starts):
Option Strict On
Imports System.Threading
Imports Microsoft.Data.Sqlite
Module AsyncAdo
Sub Main()
RunAsync().GetAwaiter().GetResult()
End Sub
Private Async Function RunAsync() As Task
Dim connection As New SqliteConnection("Data Source=:memory:")
Try
Await connection.OpenAsync()
Using create = connection.CreateCommand()
create.CommandText = "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL)"
Await create.ExecuteNonQueryAsync()
End Using
Using insert = connection.CreateCommand()
insert.CommandText = "INSERT INTO Books (Title) VALUES ('Dune'), ('Hyperion'), ('Neuromancer')"
Console.WriteLine("Inserted: " & Await insert.ExecuteNonQueryAsync())
End Using
Using query = connection.CreateCommand()
query.CommandText = "SELECT Title FROM Books ORDER BY Title"
Using reader = Await query.ExecuteReaderAsync()
While Await reader.ReadAsync()
Console.WriteLine("Read: " & reader.GetString(0))
End While
End Using
End Using
Using cancelled As New CancellationTokenSource()
cancelled.Cancel()
Using query = connection.CreateCommand()
query.CommandText = "SELECT COUNT(*) FROM Books"
Try
Await query.ExecuteScalarAsync(cancelled.Token)
Catch ex As OperationCanceledException
Console.WriteLine("Cancelled before it started: " & ex.GetType().Name)
End Try
End Using
End Using
Finally
connection.Dispose()
End Try
End Function
End Module
Inserted: 3
Read: Dune
Read: Hyperion
Read: Neuromancer
Cancelled before it started: TaskCanceledException
Source: SQLite async limitations and ADO.NET overview
Two details of this output. The exception type is TaskCanceledException, a subclass of OperationCanceledException; catch the base class. And Microsoft.Data.Sqlite is synchronous underneath (SQLite has no asynchronous I/O API), so its async methods only honour cancellation before the call; the
SQL Server provider is genuinely asynchronous and cancels an in-flight command. The general rules for Async/Await, including the Async Sub restriction and why an Async Function Main is not allowed in Visual Basic, are on
Async and Await.
SQL Server with Microsoft.Data.SqlClient
The code below is the SQL Server counterpart of the examples above: a typed lookup, a stored procedure with an output parameter, an explicit transaction with a deadlock-aware Catch ... When, an async reader with a cancellation token, a DataAdapter with generated update commands and a bulk copy. It
was built, not run: nothing here can execute without a server. The connection string is read from an environment variable with a safe development default, and the class that owns it is the only place that knows about it.
Option Strict On
Imports System.Data
Imports System.Threading
Imports Microsoft.Data.SqlClient
' Built, not run: every method here needs a SQL Server that the build machine does not have.
Public Class SqlServerCatalog
Private ReadOnly _connectionString As String
' The connection string comes from configuration or an environment variable, never from source code.
Public Sub New()
_connectionString = If(Environment.GetEnvironmentVariable("SHELF_SQL"), "Server=localhost;Database=Shelf;Integrated Security=True")
End Sub
' Typed parameter, explicit size: one cached plan for every title length.
Public Function FindIdByIsbn(isbn As String) As Integer?
Using connection As New SqlConnection(_connectionString)
connection.Open()
Using command As New SqlCommand("SELECT Id FROM dbo.Books WHERE Isbn = @isbn", connection)
command.Parameters.Add("@isbn", SqlDbType.NChar, 13).Value = isbn
Dim result = command.ExecuteScalar()
Return If(result Is Nothing OrElse result Is DBNull.Value, CType(Nothing, Integer?), CInt(result))
End Using
End Using
End Function
' Stored procedure with an output parameter and a return value.
Public Function AddBook(title As String, year As Integer, isbn As String) As Integer
Using connection As New SqlConnection(_connectionString)
connection.Open()
Using command As New SqlCommand("dbo.AddBook", connection)
command.CommandType = CommandType.StoredProcedure
command.Parameters.Add("@title", SqlDbType.NVarChar, 200).Value = title
command.Parameters.Add("@year", SqlDbType.Int).Value = year
command.Parameters.Add("@isbn", SqlDbType.NChar, 13).Value = isbn
Dim newId = command.Parameters.Add("@id", SqlDbType.Int)
newId.Direction = ParameterDirection.Output
command.ExecuteNonQuery()
Return CInt(newId.Value)
End Using
End Using
End Function
' Transaction: both commands enlist explicitly.
Public Sub Rename(id As Integer, title As String)
Using connection As New SqlConnection(_connectionString)
connection.Open()
Using transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
Try
Using command As New SqlCommand("UPDATE dbo.Books SET Title = @title WHERE Id = @id", connection, transaction)
command.Parameters.Add("@title", SqlDbType.NVarChar, 200).Value = title
command.Parameters.Add("@id", SqlDbType.Int).Value = id
command.ExecuteNonQuery()
End Using
transaction.Commit()
Catch ex As SqlException When ex.Number = 1205
transaction.Rollback()
Throw New InvalidOperationException("Chosen as a deadlock victim; retry the whole unit of work.", ex)
Catch
transaction.Rollback()
Throw
End Try
End Using
End Using
End Sub
' Async with a cancellation token. The Using block (not Await Using) disposes synchronously.
Public Async Function GetTitlesAsync(minimumYear As Integer, token As CancellationToken) As Task(Of List(Of String))
Dim titles As New List(Of String)
Using connection As New SqlConnection(_connectionString)
Await connection.OpenAsync(token)
Using command As New SqlCommand("SELECT Title FROM dbo.Books WHERE Year >= @year ORDER BY Title", connection)
command.Parameters.Add("@year", SqlDbType.Int).Value = minimumYear
Using reader = Await command.ExecuteReaderAsync(token)
While Await reader.ReadAsync(token)
titles.Add(reader.GetString(0))
End While
End Using
End Using
End Using
Return titles
End Function
' DataAdapter with generated update commands; the SELECT must return the primary key.
Public Sub SaveChanges(table As DataTable)
Using connection As New SqlConnection(_connectionString)
Using adapter As New SqlDataAdapter("SELECT Id, Title, Year, Isbn FROM dbo.Books", connection)
Using builder As New SqlCommandBuilder(adapter)
Dim updated = adapter.Update(table)
Console.WriteLine("Rows written: " & updated)
End Using
End Using
End Using
End Sub
' Bulk load.
Public Sub Import(rows As DataTable)
Using connection As New SqlConnection(_connectionString)
connection.Open()
Using copy As New SqlBulkCopy(connection) With {.DestinationTableName = "dbo.Books", .BatchSize = 1000}
copy.WriteToServer(rows)
End Using
End Using
End Sub
End Class
Visual Basic notes on this listing:
-
Catch ex As SqlException When ex.Number = 1205is Visual Basic’s exception filter (C# added the same idea ascatch ... whenin C# 6). The filter runs before the stack unwinds, so an exception that noCatchmatches is not caught at all. -
Return If(result Is Nothing OrElse result Is DBNull.Value, CType(Nothing, Integer?), CInt(result))is how a function returnsInteger?: theCType(Nothing, Integer?)types the first operand, andCIntconverts toInteger, which widens toInteger?. -
A stored procedure needs
CommandType.StoredProcedure, the procedure name as the command text, andParameterDirection.Outputfor output parameters, whoseValueis available afterExecuteNonQuery(and, for a reader, only after the reader is closed). -
SqlCommandBuildergenerates theINSERT,UPDATEandDELETEfor an adapter from itsSELECT, which must return the primary key. It is convenient for tools and small tables; for a real data layer, write the commands or use EF Core. -
SqlBulkCopyis the fast way to load many rows; it has no equivalent in EF Core (EF’s own bulk helpers areExecuteUpdateandExecuteDelete). -
The
Usingblocks dispose synchronously even insideAsyncmethods. Provider objects implementIAsyncDisposable, but withoutAwait UsingVisual Basic code cannot callDisposeAsyncin aFinally; in practice the synchronousDisposeof a pooled connection is cheap.
DataSet, DataTable and DataView
A DataTable is an in-memory table with rows that track their own state (Added, Modified, Deleted, Unchanged); a DataSet is a group of tables with DataRelation`s between them; a `DataView is a sorted and filtered window onto a table. They predate LINQ and EF, are
still supported on .NET 10, and remain useful for ad hoc reports, for binding to a grid and for code ported from .NET Framework. Microsoft.Data.Sqlite has no DbDataAdapter, so this program fills the tables from a reader with DataTable.Load. The check that supports the claim about the missing adapter:
Option Strict On
Imports System.Data.Common
Imports System.Reflection
Module AdapterCheck
Sub Main()
Dim assembly As Assembly = GetType(Microsoft.Data.Sqlite.SqliteConnection).Assembly
Dim adapters = assembly.GetExportedTypes().Where(Function(t) GetType(DbDataAdapter).IsAssignableFrom(t)).Select(Function(t) t.Name).ToList()
Dim sqlClientAdapter = GetType(Microsoft.Data.Sqlite.SqliteConnection).Assembly.GetName().Name
Console.WriteLine(sqlClientAdapter & " exports " & adapters.Count & " DbDataAdapter type(s)")
Console.WriteLine("Pooling default: " & New Microsoft.Data.Sqlite.SqliteConnectionStringBuilder().Pooling)
Console.WriteLine("Public types in the assembly: " & String.Join(", ", assembly.GetExportedTypes().Select(Function(t) t.Name).Take(8)) & ", ...")
End Sub
End Module
Microsoft.Data.Sqlite exports 0 DbDataAdapter type(s)
Pooling default: True
Public types in the assembly: SqliteBlob, SqliteCacheMode, SqliteCommand, SqliteConnection, SqliteConnectionStringBuilder, SqliteDataReader, SqliteException, SqliteFactory, ...
And the program that builds a DataSet, relates the tables, filters a view and watches row states:
Option Strict On
Imports System.Data
Imports Microsoft.Data.Sqlite
Module DataSets
Sub Main()
Using connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
Run(connection, "CREATE TABLE Authors (Id INTEGER PRIMARY KEY, Name TEXT NOT NULL)")
Run(connection, "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, Year INTEGER NOT NULL, AuthorId INTEGER NOT NULL)")
Run(connection, "INSERT INTO Authors VALUES (1, 'Frank Herbert'), (2, 'William Gibson')")
Run(connection, "INSERT INTO Books VALUES (1, 'Dune', 1965, 1), (2, 'Dune Messiah', 1969, 1), (3, 'Neuromancer', 1984, 2)")
Dim data As New DataSet("Shelf")
For Each name In {"Authors", "Books"}
Using command = connection.CreateCommand()
command.CommandText = "SELECT * FROM " & name
Using reader = command.ExecuteReader()
Dim table = data.Tables.Add(name)
table.Load(reader)
End Using
End Using
Next
data.Relations.Add("AuthorBooks", data.Tables("Authors").Columns("Id"), data.Tables("Books").Columns("AuthorId"))
For Each author As DataRow In data.Tables("Authors").Rows
Dim titles = author.GetChildRows("AuthorBooks").Select(Function(r) r.Field(Of String)("Title"))
Console.WriteLine($"{author.Field(Of String)("Name")}: {String.Join(", ", titles)}")
Next
Dim books = data.Tables("Books")
Dim view As New DataView(books) With {.RowFilter = "Year > 1966", .Sort = "Year DESC"}
Console.WriteLine("View rows: " & String.Join(", ", view.Cast(Of DataRowView)().Select(Function(v) CStr(v("Title")))))
books.AcceptChanges()
books.Rows(0)("Year") = 1966
books.Rows.Add(4, "Count Zero", 1986, 2)
books.Rows(1).Delete()
For Each row As DataRow In books.Rows
Console.WriteLine($"{row.RowState}: {If(row.RowState = DataRowState.Deleted, CStr(row("Title", DataRowVersion.Original)), CStr(row("Title")))}")
Next
Dim changes = books.GetChanges()
Console.WriteLine("Changed rows: " & changes.Rows.Count)
End Using
End Sub
Private Sub Run(connection As SqliteConnection, sql As String)
Using command = connection.CreateCommand()
command.CommandText = sql
command.ExecuteNonQuery()
End Using
End Sub
End Module
Frank Herbert: Dune, Dune Messiah
William Gibson: Neuromancer
View rows: Neuromancer, Dune Messiah
Modified: Dune
Deleted: Dune Messiah
Unchanged: Neuromancer
Added: Count Zero
Changed rows: 3
Source: DataSets, DataTables and DataViews and DataTable
Notes: row.Field(Of String)("Title") (from System.Data.DataSetExtensions, which is part of the shared framework) returns a typed value and handles DBNull; row("Title") returns Object. The row filter and sort are strings in a small expression language, not Visual Basic, and DataView is not
type-checked by the compiler. A deleted row cannot be read normally; ask for DataRowVersion.Original. With SqlDataAdapter (see the SQL Server listing) adapter.Update(table) sends exactly the Added, Modified and Deleted rows to the database.
A repository over ADO.NET
The Shelf.Core project defines ICatalogStore; any store that implements it can be plugged into Catalog. The class below implements it over SQLite with plain ADO.NET, mapping the Book/Magazine hierarchy to a single table with a Kind discriminator. It
is the classic data-access layer: SQL is in one class, parameters are always used, and the rest of the program sees only the interface.
Option Strict On
Imports Microsoft.Data.Sqlite
Imports Shelf.Core
' ICatalogStore from Shelf.Core, implemented over ADO.NET.
Public Class SqliteCatalogStore
Implements ICatalogStore
Private ReadOnly _connection As SqliteConnection
Public Sub New(connection As SqliteConnection)
_connection = connection
Using command = _connection.CreateCommand()
command.CommandText =
"CREATE TABLE IF NOT EXISTS Items (Id INTEGER PRIMARY KEY, Kind TEXT NOT NULL, Title TEXT NOT NULL, Year INTEGER NOT NULL, Isbn TEXT NULL, Issue INTEGER NULL, Authors TEXT NULL)"
command.ExecuteNonQuery()
End Using
End Sub
Public Sub Add(item As CatalogItem) Implements ICatalogStore.Add
Using command = _connection.CreateCommand()
command.CommandText = "INSERT INTO Items (Kind, Title, Year, Isbn, Issue, Authors) VALUES ($kind, $title, $year, $isbn, $issue, $authors); SELECT last_insert_rowid()"
command.Parameters.Add("$kind", SqliteType.Text).Value = item.Kind
command.Parameters.Add("$title", SqliteType.Text).Value = item.Title
command.Parameters.Add("$year", SqliteType.Integer).Value = item.Year
Dim book = TryCast(item, Book)
Dim magazine = TryCast(item, Magazine)
command.Parameters.Add("$isbn", SqliteType.Text).Value = If(book Is Nothing, CObj(DBNull.Value), CObj(book.Isbn.Value))
command.Parameters.Add("$issue", SqliteType.Integer).Value = If(magazine Is Nothing, CObj(DBNull.Value), CObj(magazine.IssueNumber))
command.Parameters.Add("$authors", SqliteType.Text).Value = If(book Is Nothing, CObj(DBNull.Value), CObj(String.Join("; ", book.Authors)))
item.Id = CInt(CLng(command.ExecuteScalar()))
End Using
End Sub
Public Function GetAll() As IReadOnlyList(Of CatalogItem) Implements ICatalogStore.GetAll
Dim result As New List(Of CatalogItem)
Using command = _connection.CreateCommand()
command.CommandText = "SELECT Id, Kind, Title, Year, Isbn, Issue, Authors FROM Items ORDER BY Id"
Using reader = command.ExecuteReader()
While reader.Read()
result.Add(Materialize(reader))
End While
End Using
End Using
Return result
End Function
Public Function Find(id As Integer) As CatalogItem Implements ICatalogStore.Find
Using command = _connection.CreateCommand()
command.CommandText = "SELECT Id, Kind, Title, Year, Isbn, Issue, Authors FROM Items WHERE Id = $id"
command.Parameters.Add("$id", SqliteType.Integer).Value = id
Using reader = command.ExecuteReader()
Return If(reader.Read(), Materialize(reader), Nothing)
End Using
End Using
End Function
Private Shared Function Materialize(reader As SqliteDataReader) As CatalogItem
Dim item As CatalogItem
If reader.GetString(1) = "Book" Then
Dim authors = reader.GetString(6).Split({"; "}, StringSplitOptions.RemoveEmptyEntries).Select(Function(name) New Author(name)).ToArray()
item = New Book(reader.GetString(2), reader.GetInt32(3), Isbn.Parse(reader.GetString(4)), authors)
Else
item = New Magazine(reader.GetString(2), reader.GetInt32(3), reader.GetInt32(5))
End If
item.Id = reader.GetInt32(0)
Return item
End Function
End Class
Module Repository
Sub Main()
Using connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
Dim catalog As New Catalog(New SqliteCatalogStore(connection))
catalog.Add(New Book("Dune", 1965, Isbn.Parse("9780441172719"), New Author("Frank Herbert")))
catalog.Add(New Magazine("Byte", 1984, 12))
catalog.Add(New Book("Neuromancer", 1984, Isbn.Parse("9780441569595"), New Author("William Gibson")))
For Each item In catalog.Search("e")
Console.WriteLine($"{item.Id}: {item.Describe()}")
Next
End Using
End Sub
End Module
2: Magazine: Byte (1984)
1: Book: Dune (1965) by Frank Herbert
3: Book: Neuromancer (1984) by William Gibson
Source: Microsoft.Data.Sqlite overview
Design notes: the store takes an open connection from its owner, so one unit of work can share a transaction between stores; Materialize is the only place that knows the column order; and the Authors of a Book are stored in one text column for brevity, which is a deliberate simplification (a real schema has an
Authors table and a link table, which is what the EF Core model below uses).
Caching in front of the store
A cache in front of a repository is the simplest performance step and the easiest to get wrong. The in-memory cache from Microsoft.Extensions.Caching.Memory replaces System.Runtime.Caching.MemoryCache of .NET Framework; the program counts database queries to show the effect:
Option Strict On
Imports Microsoft.Data.Sqlite
Imports Microsoft.Extensions.Caching.Memory
Module CacheDemo
Private _queries As Integer
Sub Main()
Using connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
Using setup = connection.CreateCommand()
setup.CommandText = "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT); INSERT INTO Books (Title) VALUES ('Dune')"
setup.ExecuteNonQuery()
End Using
Using cache As New MemoryCache(New MemoryCacheOptions())
For i = 1 To 3
Dim title = cache.GetOrCreate("book:1",
Function(entry)
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
Return LoadTitle(connection, 1)
End Function)
Console.WriteLine($"Request {i}: {title} (database queries so far: {_queries})")
Next
cache.Remove("book:1")
Console.WriteLine("After Remove: " & cache.GetOrCreate("book:1", Function(entry) LoadTitle(connection, 1)) & " (queries: " & _queries & ")")
End Using
End Using
End Sub
Private Function LoadTitle(connection As SqliteConnection, id As Integer) As String
_queries += 1
Using command = connection.CreateCommand()
command.CommandText = "SELECT Title FROM Books WHERE Id = $id"
command.Parameters.Add("$id", SqliteType.Integer).Value = id
Return CStr(command.ExecuteScalar())
End Using
End Function
End Module
Request 1: Dune (database queries so far: 1)
Request 2: Dune (database queries so far: 1)
Request 3: Dune (database queries so far: 1)
After Remove: Dune (queries: 2)
Source: Caching in .NET
GetOrCreate takes a Func(Of ICacheEntry, TItem); in Visual Basic a multi-line Function(entry) ... End Function lambda is required when the body has a statement, as with the expiration setting here. Cache keys are strings, so build them in one place; set an expiration; and invalidate (Remove) when you write. Remember that a cache is state: do not
cache anything that depends on the current user unless the user is in the key, and do not cache entities that are tracked by an EF context.
Dapper
Dapper is a micro-ORM: extension methods on IDbConnection that run SQL and map rows to objects by column name. It is a community library (not from Microsoft) and is widely used from Visual Basic as well as C#. It adds nothing beyond what ADO.NET provides except the mapping code, which is what makes it fast and what keeps it out of the way. The program was built and run with Dapper 2.1.89:
Option Strict On
Imports Dapper
Imports Microsoft.Data.Sqlite
Public Class BookRow
Public Property Id As Integer
Public Property Title As String = ""
Public Property Year As Integer
Public Property Isbn As String = ""
End Class
Module DapperDemo
Sub Main()
Using connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
connection.Execute("CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, Year INTEGER NOT NULL, Isbn TEXT NOT NULL)")
Dim inserted = connection.Execute(
"INSERT INTO Books (Title, Year, Isbn) VALUES (@Title, @Year, @Isbn)",
{New BookRow With {.Title = "Dune", .Year = 1965, .Isbn = "9780441172719"},
New BookRow With {.Title = "Neuromancer", .Year = 1984, .Isbn = "9780441569595"}})
Console.WriteLine("Inserted: " & inserted)
Dim newer = connection.Query(Of BookRow)("SELECT * FROM Books WHERE Year > @minimum ORDER BY Year", New With {.minimum = 1970}).ToList()
For Each book In newer
Console.WriteLine($"{book.Id}: {book.Title} ({book.Year})")
Next
Dim count = connection.ExecuteScalar(Of Long)("SELECT COUNT(*) FROM Books")
Console.WriteLine("Count: " & count)
Dim first = connection.QuerySingleOrDefault(Of BookRow)("SELECT * FROM Books WHERE Isbn = @isbn", New With {.isbn = "0000000000000"})
Console.WriteLine("Unknown ISBN gives Nothing: " & (first Is Nothing))
End Using
End Sub
End Module
Inserted: 2
2: Neuromancer (1984)
Count: 2
Unknown ISBN gives Nothing: True
Visual Basic notes: parameters are passed as an anonymous type (New With {.minimum = 1970}), whose property names become the @minimum parameter; the usual mapping target is a class with a parameterless constructor and settable properties named as the columns (name matching is case-insensitive, which suits Visual Basic); QuerySingleOrDefault(Of T) returns Nothing, not an
exception, for no row (but throws when more than one row matches); and the generic argument is (Of BookRow), which is where C# writes <BookRow>. Dapper makes SQL injection as easy as plain ADO.NET if you concatenate: the parameter syntax is the protection.
Entity Framework Core from Visual Basic
EF Core 10 is the version that ships with .NET 10. Everything in the runtime works from Visual Basic: the model, LINQ translation (both syntaxes), change tracking, SaveChanges, concurrency, raw SQL and seeding. What C# has and Visual Basic lacks are, again, a language feature (nullable reference types, so EF Core cannot infer which columns are required) and tooling (the design-time code generators for migrations and scaffolding emit C#; discussed below).
The model and the context
The entities are plain classes. A navigation collection is initialised with As New List(Of BookEntity). The context configures the model in OnModelCreating with the fluent API, using lambdas (Function(b) b.Title, and Sub(book) ... for the entity configuration block). The ISBN is the Isbn structure from Shelf.Core, stored as text through a value converter. The context takes its options through
the constructor, so the same class can be used with SQLite in tests and another provider in production.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>ShelfApp</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
<ProjectReference Include="..\Shelf.Core\Shelf.Core.vbproj" />
</ItemGroup>
</Project>
Option Strict On
Imports Microsoft.Data.Sqlite
Imports Microsoft.EntityFrameworkCore
Imports Microsoft.EntityFrameworkCore.Storage.ValueConversion
Imports Shelf.Core
Public Class AuthorEntity
Public Property Id As Integer
Public Property Name As String = ""
Public Property Books As New List(Of BookEntity)
End Class
Public Class BookEntity
Public Property Id As Integer
Public Property Title As String = ""
Public Property Year As Integer
Public Property Isbn As Isbn
Public Property Version As Integer
Public Property AuthorId As Integer
Public Property Author As AuthorEntity
End Class
Public Class ShelfContext
Inherits DbContext
Public Sub New(options As DbContextOptions(Of ShelfContext))
MyBase.New(options)
End Sub
Public Property Authors As DbSet(Of AuthorEntity)
Public Property Books As DbSet(Of BookEntity)
Protected Overrides Sub OnModelCreating(modelBuilder As ModelBuilder)
modelBuilder.Entity(Of AuthorEntity)(
Sub(author)
author.Property(Function(a) a.Name).IsRequired().HasMaxLength(100)
author.HasMany(Function(a) a.Books).WithOne(Function(b) b.Author).HasForeignKey(Function(b) b.AuthorId)
End Sub)
modelBuilder.Entity(Of BookEntity)(
Sub(book)
book.Property(Function(b) b.Title).IsRequired().HasMaxLength(200)
book.Property(Function(b) b.Version).IsConcurrencyToken()
book.Property(Function(b) b.Isbn).HasConversion(New IsbnConverter()).HasMaxLength(13)
book.HasIndex(Function(b) b.Isbn).IsUnique()
End Sub)
modelBuilder.Entity(Of AuthorEntity)().HasData(
New AuthorEntity With {.Id = 1, .Name = "Frank Herbert"},
New AuthorEntity With {.Id = 2, .Name = "William Gibson"})
End Sub
End Class
Public Class IsbnConverter
Inherits ValueConverter(Of Isbn, String)
Public Sub New()
MyBase.New(Function(i) i.Value, Function(s) Isbn.Parse(s))
End Sub
End Class
Public Module Db
Public Function CreateOptions(connection As SqliteConnection) As DbContextOptions(Of ShelfContext)
Return New DbContextOptionsBuilder(Of ShelfContext)().UseSqlite(connection).Options
End Function
' One open in-memory connection keeps the database alive for the whole run.
Public Function OpenDatabase() As SqliteConnection
Dim connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
Using context As New ShelfContext(CreateOptions(connection))
context.Database.EnsureCreated()
End Using
Return connection
End Function
End Module
Source: DbContext configuration, Value conversions and Data seeding
Four Visual Basic details in this file:
-
Required columns must be declared. Visual Basic has no
String?versusString, so EF Core sees everyStringproperty as nullable. A value-type property (Integer,Date) is required; aNullable(Of T)one (Integer?) is optional. This is the DDL that EF Core generated for a model with no configuration (run):Option Strict On Imports Microsoft.EntityFrameworkCore Public Class Loose Public Property Id As Integer Public Property Title As String Public Property Subtitle As String Public Property Pages As Integer Public Property Rating As Integer? Public Property Published As Date Public Property Reviewed As Date? End Class Public Class LooseContext Inherits DbContext Public Property Items As DbSet(Of Loose) Protected Overrides Sub OnConfiguring(optionsBuilder As DbContextOptionsBuilder) optionsBuilder.UseSqlite("Data Source=:memory:") End Sub End Class Module Nullability Sub Main() Using context As New LooseContext() Console.WriteLine(context.Database.GenerateCreateScript()) Console.WriteLine(context.Items.Where(Function(i) i.Subtitle Is Nothing).ToQueryString()) End Using End Sub End ModuleCREATE TABLE "Items" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_Items" PRIMARY KEY AUTOINCREMENT, "Title" TEXT NULL, "Subtitle" TEXT NULL, "Pages" INTEGER NOT NULL, "Rating" INTEGER NULL, "Published" TEXT NOT NULL, "Reviewed" TEXT NULL ); SELECT "i"."Id", "i"."Pages", "i"."Published", "i"."Rating", "i"."Reviewed", "i"."Subtitle", "i"."Title" FROM "Items" AS "i" WHERE "i"."Subtitle" IS NULLTitleandSubtitleare bothTEXT NULL; the model above callsIsRequired()for the properties that must not be NULL. The last statement of the program shows that a comparison withIs Nothingin a query is translated toIS NULL. -
Case-insensitive names and the DbSet properties.
Public Property Books As DbSet(Of BookEntity)is initialised by EF Core when the context is created. A property namedBooksin the context and an entity class namedBookcan coexist, but a local variable namedbookshides the property in the same method (names are not case-sensitive); the entity classes here are suffixed withEntityso that they do not collide with theBookofShelf.Core. -
Nothingis an acceptable value for a reference navigation and for strings; giveStringproperties a default of""(Public Property Title As String = "") when you want to avoidNullReferenceExceptionin code that reads an unset property. -
Lambda expressions that EF Core translates must be expression-tree compatible. A single-expression lambda such as
Function(b) b.Year > 1960converts to an expression tree like a C# one. A lambda with statements does not, and the compiler says so:Option Strict On Imports Microsoft.EntityFrameworkCore Module Fail Sub Main() Using connection = OpenDatabase() Using context As New ShelfContext(CreateOptions(connection)) Dim query = context.Books.Where(Function(b) Dim limit = 1960 Return b.Year > limit End Function) Console.WriteLine(query.Count()) End Using End Using End Sub End ModuleFail.vb(9,49): error BC36675: Statement lambdas cannot be converted to expression trees.Constructs that Visual Basic compiles to runtime-library calls instead of plain operators are not translatable either (the next two sections).
Create, read, update, delete and change tracking
The context tracks every entity it loads or you attach. Entry(entity).State shows what SaveChanges will do. This program was run:
Option Strict On
Imports Microsoft.EntityFrameworkCore
Imports Shelf.Core
Module Crud
Sub Main()
Using connection = OpenDatabase()
Using context As New ShelfContext(CreateOptions(connection))
Dim herbert = context.Authors.Single(Function(a) a.Name = "Frank Herbert")
Dim dune As New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .Author = herbert}
Console.WriteLine("Before Add: " & context.Entry(dune).State.ToString())
context.Books.Add(dune)
Console.WriteLine("After Add: " & context.Entry(dune).State.ToString())
context.SaveChanges()
Console.WriteLine("After Save: " & context.Entry(dune).State.ToString() & ", Id=" & dune.Id)
dune.Year = 1966
Console.WriteLine("After edit: " & context.Entry(dune).State.ToString())
context.ChangeTracker.DetectChanges()
Console.WriteLine(context.ChangeTracker.DebugView.ShortView)
context.SaveChanges()
End Using
Using context As New ShelfContext(CreateOptions(connection))
Dim loaded = context.Books.AsNoTracking().Single()
Console.WriteLine($"Reloaded: {loaded.Title} {loaded.Year} {loaded.Isbn}")
Dim changed = context.Books.Where(Function(b) b.Year < 2000).
ExecuteUpdate(Function(setters) setters.SetProperty(Function(b) b.Year, Function(b) b.Year + 1))
Console.WriteLine("ExecuteUpdate rows: " & changed)
Console.WriteLine("Year now: " & context.Books.Select(Function(b) b.Year).Single())
Dim deleted = context.Books.Where(Function(b) b.Title = "Dune").ExecuteDelete()
Console.WriteLine("ExecuteDelete rows: " & deleted)
End Using
End Using
End Sub
End Module
Before Add: Detached
After Add: Added
After Save: Unchanged, Id=1
After edit: Modified
AuthorEntity {Id: 1} Unchanged
BookEntity {Id: 1} Modified FK {AuthorId: 1}
Reloaded: Dune 1966 9780441172719
ExecuteUpdate rows: 1
Year now: 1967
ExecuteDelete rows: 1
ExecuteUpdate and ExecuteDelete run one SQL statement without loading entities (bulk operations). In Visual Basic, ExecuteUpdate(Function(setters) setters.SetProperty(Function(b) b.Year, Function(b) b.Year + 1)) compiled and ran; the SetProperty lambdas are ordinary expression lambdas, so the code reads like C#'s.
Lifetime rules are the same as in C#: one DbContext per unit of work, short-lived, not thread-safe, never a static or a field of a long-lived object. AsNoTracking() is the right default for read-only queries.
Queries: query syntax, method syntax and what does not translate
Both LINQ syntaxes work, including the Visual Basic query operators Group By ... Into, Aggregate and Take/Skip. The following program prints the SQL that EF Core generated for a Group By written in query syntax and then runs several queries, including two that use a Like:
Option Strict On
Imports Microsoft.EntityFrameworkCore
Imports Shelf.Core
Module Queries
Sub Main()
Using connection = OpenDatabase()
Using context As New ShelfContext(CreateOptions(connection))
Dim herbert = context.Authors.Single(Function(a) a.Id = 1)
Dim gibson = context.Authors.Single(Function(a) a.Id = 2)
context.Books.AddRange(
New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .Author = herbert},
New BookEntity With {.Title = "Dune Messiah", .Year = 1969, .Isbn = Isbn.Parse("9780593098233"), .Author = herbert},
New BookEntity With {.Title = "Neuromancer", .Year = 1984, .Isbn = Isbn.Parse("9780441569595"), .Author = gibson},
New BookEntity With {.Title = "Count Zero", .Year = 1986, .Isbn = Isbn.Parse("9780441117734"), .Author = gibson})
context.SaveChanges()
End Using
Using context As New ShelfContext(CreateOptions(connection))
Console.WriteLine("--- query syntax with Group By")
Dim perAuthor = From b In context.Books
Group By Name = b.Author.Name Into Count(), Latest = Max(b.Year)
Order By Name
Console.WriteLine(perAuthor.ToQueryString())
For Each row In perAuthor
Console.WriteLine($"{row.Name}: {row.Count} book(s), latest {row.Latest}")
Next
Console.WriteLine("--- Aggregate")
Dim total = Aggregate b In context.Books Into Sum(b.Year)
Console.WriteLine("Sum of years: " & total)
Console.WriteLine("--- method syntax with paging")
Dim page = context.Books.OrderBy(Function(b) b.Title).Skip(1).Take(2).Select(Function(b) b.Title).ToList()
Console.WriteLine(String.Join(", ", page))
Console.WriteLine("--- Include")
Dim authors = context.Authors.Include(Function(a) a.Books).OrderBy(Function(a) a.Name).ToList()
For Each a In authors
Console.WriteLine(a.Name & ": " & String.Join(", ", a.Books.Select(Function(b) b.Title)))
Next
Console.WriteLine("--- EF.Functions.Like")
Dim likeResult = context.Books.Where(Function(b) EF.Functions.Like(b.Title, "Dune%")).Count()
Console.WriteLine("Titles like Dune%: " & likeResult)
Console.WriteLine("--- the Visual Basic Like operator")
Try
Dim vbLike = context.Books.Where(Function(b) b.Title Like "Dune*").Count()
Console.WriteLine(vbLike)
Catch ex As InvalidOperationException
Dim start = ex.Message.IndexOf("Translation of")
Dim finish = ex.Message.IndexOf(" If this method", start)
Console.WriteLine(ex.GetType().Name & ": " & ex.Message.Substring(start, finish - start))
End Try
End Using
End Using
End Sub
End Module
--- query syntax with Group By
SELECT "a"."Name", COUNT(*) AS "Count", MAX("b"."Year") AS "Latest"
FROM "Books" AS "b"
INNER JOIN "Authors" AS "a" ON "b"."AuthorId" = "a"."Id"
GROUP BY "a"."Name"
ORDER BY "a"."Name"
Frank Herbert: 2 book(s), latest 1969
William Gibson: 2 book(s), latest 1986
--- Aggregate
Sum of years: 7904
--- method syntax with paging
Dune, Dune Messiah
--- Include
Frank Herbert: Dune, Dune Messiah
William Gibson: Neuromancer, Count Zero
--- EF.Functions.Like
Titles like Dune%: 2
--- the Visual Basic Like operator
InvalidOperationException: Translation of method 'Microsoft.VisualBasic.CompilerServices.LikeOperator.LikeString' failed.
Source: Eager loading and Complex query operators
The last block is the most important Visual Basic fact in this section. The Visual Basic Like operator is not a method call that EF Core recognises; it compiles to LikeOperator.LikeString, and the translator reports InvalidOperationException ("The LINQ expression … could not be translated"). Use EF.Functions.Like(b.Title, "Dune%"), whose pattern is SQL’s (% and _), not Visual Basic’s (*, ?, #).
Option Compare Text breaks translation
String comparison with = in Visual Basic obeys the file’s Option Compare setting. With the default, Option Compare Binary, b.Title = "Dune" becomes an ordinary equality that EF Core translates. With Option Compare Text, the compiler emits a call to a runtime comparison method instead, and the translator fails:
Option Compare Text
Option Strict On
Imports Microsoft.EntityFrameworkCore
Imports Shelf.Core
Module CompareText
Sub Main()
Using connection = OpenDatabase()
Using context As New ShelfContext(CreateOptions(connection))
context.Books.Add(New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .AuthorId = 1})
context.SaveChanges()
Console.WriteLine("In memory, Option Compare Text: " & ("dune" = "DUNE"))
Try
Dim count = context.Books.Where(Function(b) b.Title = "dune").Count()
Console.WriteLine("Rows: " & count)
Catch ex As InvalidOperationException
Dim start = ex.Message.IndexOf("Translation of")
Dim finish = ex.Message.IndexOf(" If this method", start)
Console.WriteLine(ex.Message.Substring(start, finish - start))
End Try
Console.WriteLine("Rows with EF.Functions.Like: " & context.Books.Where(Function(b) EF.Functions.Like(b.Title, "dune")).Count())
End Using
End Using
End Sub
End Module
In memory, Option Compare Text: True
Translation of method 'string.Compare' failed.
Rows with EF.Functions.Like: 1
Source: Collations and case sensitivity
The in-memory comparison "dune" = "DUNE" is True under Option Compare Text, and the identical-looking expression inside the query tree is a different one. Two lessons: keep Option Compare Binary (the SDK default) in files that contain EF queries, and decide case sensitivity in the database (collation) or with EF.Functions.Like; see
Option Statements. String.Equals(a, b, StringComparison.OrdinalIgnoreCase) is not translated by EF Core either (the first version of this program tried it and got an InvalidOperationException).
Async, transactions, concurrency and raw SQL
This program was run. It saves and queries asynchronously, rolls back an explicit transaction, provokes an optimistic-concurrency conflict with two contexts, and uses interpolated raw SQL:
Option Strict On
Imports Microsoft.EntityFrameworkCore
Imports Microsoft.EntityFrameworkCore.ChangeTracking
Imports Shelf.Core
Module Advanced
Sub Main()
RunAsync().GetAwaiter().GetResult()
End Sub
Private Async Function RunAsync() As Task
Using connection = OpenDatabase()
Using context As New ShelfContext(CreateOptions(connection))
Dim options = CreateOptions(connection)
context.Books.Add(New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .AuthorId = 1})
Console.WriteLine("Saved: " & Await context.SaveChangesAsync())
Dim titles = Await context.Books.Select(Function(b) b.Title).ToListAsync()
Console.WriteLine("Titles: " & String.Join(", ", titles))
Console.WriteLine("--- explicit transaction")
Dim transaction = Await context.Database.BeginTransactionAsync()
Try
context.Books.Add(New BookEntity With {.Title = "Temporary", .Year = 2000, .Isbn = Isbn.Parse("9780441569595"), .AuthorId = 2})
Await context.SaveChangesAsync()
Console.WriteLine("Inside transaction: " & Await context.Books.CountAsync())
Await transaction.RollbackAsync()
Finally
transaction.Dispose()
End Try
context.ChangeTracker.Clear()
Console.WriteLine("After rollback: " & Await context.Books.CountAsync())
Console.WriteLine("--- optimistic concurrency")
Using first As New ShelfContext(options), second As New ShelfContext(options)
Dim a = Await first.Books.FirstAsync()
Dim b = Await second.Books.FirstAsync()
a.Title = "Dune (edited by first)"
a.Version += 1
Await first.SaveChangesAsync()
b.Title = "Dune (edited by second)"
b.Version += 1
Dim conflict As EntityEntry = Nothing
Try
Await second.SaveChangesAsync()
Catch ex As DbUpdateConcurrencyException
Console.WriteLine("DbUpdateConcurrencyException, entries: " & ex.Entries.Count)
conflict = ex.Entries(0)
End Try
If conflict IsNot Nothing Then
Dim stored = Await conflict.GetDatabaseValuesAsync()
Console.WriteLine("Database has: " & CStr(stored(NameOf(BookEntity.Title))))
End If
End Using
Console.WriteLine("--- raw SQL")
Dim year = 1960
Dim raw = Await context.Books.FromSql($"SELECT * FROM Books WHERE Year > {year}").ToListAsync()
Console.WriteLine("FromSql rows: " & raw.Count)
Dim affected = Await context.Database.ExecuteSqlAsync($"UPDATE Books SET Year = Year + 1 WHERE Id = {raw(0).Id}")
Console.WriteLine("ExecuteSql affected: " & affected)
End Using
End Using
End Function
End Module
Saved: 1
Titles: Dune
--- explicit transaction
Inside transaction: 2
After rollback: 1
--- optimistic concurrency
DbUpdateConcurrencyException, entries: 1
Database has: Dune (edited by first)
--- raw SQL
FromSql rows: 1
ExecuteSql affected: 1
Source: Transactions, Concurrency conflicts and SQL queries
Visual Basic points in this listing:
-
The conflict handler stores the entry in a variable and awaits after the
Tryblock;Awaitinside theCatchis the BC36943 error shown earlier. -
The concurrency token here is an integer
Versionthat the code increments, configured withIsConcurrencyToken(). SQL Server’srowversioncolumn (IsRowVersion()) updates itself; SQLite has no such type. -
FromSql($"... {year}")takes a formattable string. Visual Basic converts an interpolated string literal toFormattableStringwhen the parameter has that type, so each{...}hole becomes a database parameter, not text. It is safe because of that conversion;FromSqlRaw("... " & year)with a concatenated string is the unsafe form. Do not assign an interpolated string to aStringvariable first and pass that: the holes are formatted into the text and the protection is gone. -
Using first As New ShelfContext(options), second As New ShelfContext(options)declares two resources in oneUsing.
Seeding
Two kinds. HasData in OnModelCreating (used in the model above) seeds rows with fixed keys and is part of the model, so it is included in migrations. UseSeeding (EF Core 9 and later) runs code when the database is created or migrated, which suits data that is computed or that needs the full context. This program ran:
Option Strict On
Imports Microsoft.Data.Sqlite
Imports Microsoft.EntityFrameworkCore
Imports Shelf.Core
Module Seeding
Sub Main()
Using connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
Dim builder As New DbContextOptionsBuilder(Of ShelfContext)()
builder.UseSqlite(connection)
builder.UseSeeding(
Sub(context, storeCreated)
Dim set1 = context.Set(Of BookEntity)()
If Not set1.Any(Function(b) b.Title = "Dune") Then
set1.Add(New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .AuthorId = 1})
context.SaveChanges()
Console.WriteLine("UseSeeding added Dune (store created by this call: " & storeCreated & ")")
End If
End Sub)
Using context As New ShelfContext(builder.Options)
context.Database.EnsureCreated()
Console.WriteLine("Books: " & context.Books.Count())
End Using
End Using
End Sub
End Module
UseSeeding added Dune (store created by this call: True)
Books: 1
Source: Data seeding
The seeding delegate is a Sub(context, storeCreated) lambda; the second parameter says whether the call created the store. UseAsyncSeeding is the asynchronous counterpart, and both should be written so that running twice does not duplicate rows (the Any check above).
Migrations and Visual Basic
Migrations are where Visual Basic stops working like C#. dotnet ef migrations add generates the migration as source code in the project’s language, and EF Core ships a code generator for C# only. Run against a Visual Basic project, the tool says so:
$ dotnet ef migrations add Initial
Build started...
Build succeeded.
The project language 'VB' isn't supported by the built-in IMigrationsCodeGenerator service. You can try looking for an additional NuGet package which supports this language; moving your DbContext type to a C# class library referenced by this project; or manually implementing and registering the design-time service for the programming language.
(The message is the tool’s own; it names the three ways out.) The options, from simplest to most flexible:
| Option | Notes |
|---|---|
Keep the migrations in a small C# project |
The supported route, and the one this page tested. The model ( |
|
A package that adds a Visual Basic code generator to |
Hand-written migrations |
A migration is a class that derives from |
|
Creates the schema from the model without migrations. Right for tests and prototypes; it cannot evolve a schema and cannot be mixed with migrations on the same database. |
SQL scripts |
|
The C# migrations project, tested
The layout is three projects: MigData (Visual Basic class library, the model from above), Shelf.Migrations (C# class library that references MigData; migrations go here) and MigApp (the Visual Basic executable that references both, contains the design-time factory and carries the
Microsoft.EntityFrameworkCore.Design package). The C# project:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MigData\MigData.vbproj" />
</ItemGroup>
</Project>
The start-up project registers the migrations assembly and provides the design-time factory (the tool uses it because the executable has no generic host):
Option Strict On
Imports Microsoft.Data.Sqlite
Imports Microsoft.EntityFrameworkCore
Imports Microsoft.EntityFrameworkCore.Design
Public Class ShelfContextFactory
Implements IDesignTimeDbContextFactory(Of ShelfContext)
Public Function CreateDbContext(args As String()) As ShelfContext Implements IDesignTimeDbContextFactory(Of ShelfContext).CreateDbContext
Return New ShelfContext(CreateOptions("Data Source=shelf.db"))
End Function
Public Shared Function CreateOptions(connectionString As String) As DbContextOptions(Of ShelfContext)
Return New DbContextOptionsBuilder(Of ShelfContext)().
UseSqlite(connectionString, Sub(o) o.MigrationsAssembly("Shelf.Migrations")).Options
End Function
End Class
Module Program
Sub Main()
Using connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
Dim options = New DbContextOptionsBuilder(Of ShelfContext)().
UseSqlite(connection, Sub(o) o.MigrationsAssembly("Shelf.Migrations")).Options
Using context As New ShelfContext(options)
Console.WriteLine("Pending before: " & String.Join(", ", context.Database.GetPendingMigrations().Select(Function(m) m.Substring(m.IndexOf("_"c) + 1))))
context.Database.Migrate()
Console.WriteLine("Applied after: " & String.Join(", ", context.Database.GetAppliedMigrations().Select(Function(m) m.Substring(m.IndexOf("_"c) + 1))))
Console.WriteLine("Authors seeded: " & context.Authors.Count())
End Using
End Using
End Sub
End Module
The tool is installed once, then the command names the migrations project and the start-up project:
$ dotnet ef migrations add Initial --project MigCs --startup-project MigApp
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
$ ls MigCs/Migrations
20261019215308_Initial.Designer.cs
20261019215308_Initial.cs
ShelfContextModelSnapshot.cs
The generated migration is C#, in the C# project, and is not edited by hand (excerpt):
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Shelf.Migrations.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Authors",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Authors", x => x.Id);
});
migrationBuilder.CreateTable(
The Visual Basic program above then ran Migrate() against an in-memory SQLite database. The output includes the two seed rows from HasData, which came through the migration:
Pending before: Initial
Applied after: Initial
Authors seeded: 2
dotnet ef migrations script --startup-project ../MigApp printed the SQL (first lines):
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
BEGIN TRANSACTION;
CREATE TABLE "Authors" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Authors" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL
);
CREATE TABLE "Books" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
"Title" TEXT NOT NULL,
"Year" INTEGER NOT NULL,
"Isbn" TEXT NOT NULL,
"Version" INTEGER NOT NULL,
"AuthorId" INTEGER NOT NULL,
CONSTRAINT "FK_Books_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id") ON DELETE CASCADE
);
INSERT INTO "Authors" ("Id", "Name")
VALUES (1, 'Frank Herbert');
SELECT changes();
INSERT INTO "Authors" ("Id", "Name")
VALUES (2, 'William Gibson');
SELECT changes();
Source: Using a separate migrations project, EF Core tools reference (.NET CLI) and Applying migrations
The same documentation lists the other ways to apply migrations (a script, a bundle, Migrate() at start-up); Migrate() at start-up is fine for development and a single instance, while a deployment step is better for anything with more than one instance.
EFCore.VisualBasic, tested
EFCore.VisualBasic is a community project, published by an EF team member on the project’s own repository, that registers a Visual Basic migrations generator through IDesignTimeServices. The test installed EntityFrameworkCore.VisualBasic 8.0.0 next to EF Core 10.0.13. Restore warns first:
warning NU1608: Detected package version outside of dependency constraint: EntityFrameworkCore.VisualBasic 8.0.0 requires Microsoft.EntityFrameworkCore.Design (>= 8.0.2 && < 9.0.0) but version Microsoft.EntityFrameworkCore.Design 10.0.13 was resolved.
warning NU1608: Detected package version outside of dependency constraint: Microsoft.CodeAnalysis.VisualBasic 4.5.0 requires Microsoft.CodeAnalysis.Common (= 4.5.0) but version Microsoft.CodeAnalysis.Common 5.0.0 was resolved.
With warnings as errors off, the project builds and the services class (EFCoreVisualBasicServices.ConfigureDesignTimeServices) compiles, but running the tool fails inside Roslyn:
$ dotnet ef migrations add Initial --no-build
Method 'GetForEachSymbols' in type 'Microsoft.CodeAnalysis.VisualBasic.Utilities.SpeculationAnalyzer' from assembly 'Microsoft.CodeAnalysis.VisualBasic.Workspaces, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35' does not have an implementation.
Could not load type 'Microsoft.CodeAnalysis.LanguageService.AbstractBlockFacts' from assembly 'Microsoft.CodeAnalysis.Workspaces, Version=5.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35'.
So with EF Core 10 the C# migrations project is the working route. If the project publishes a version for the EF Core release you use, the package would remove the need for the second project; re-check its NuGet page before relying on either statement.
Source: Reverse engineering (scaffolding) describes the plug-in model that community code generators use, and the package page (community) lists its versions.
Reverse engineering (dotnet ef dbcontext scaffold) has the same limit: it writes C# entity classes. Scaffold into a C# project and reference it, or write the entities by hand.
Binding query results to Windows Forms and WPF
For a grid in Windows Forms the shape is: load into a list, wrap it in a BindingList(Of T) or a BindingSource, set DataGridView.DataSource. EF Core can also provide a ready local view (context.Books.Local.ToBindingList()), which stays in step with the change tracker. The listing below was built, not run (Windows Forms needs Windows); the
Async Sub override is the correct place to start asynchronous loading because it runs on the UI thread and resumes there.
Option Strict On
Imports System.ComponentModel
Imports System.Windows.Forms
Imports Microsoft.EntityFrameworkCore
Public Class BookRow
Public Property Id As Integer
Public Property Title As String = ""
Public Property Year As Integer
End Class
Public Class RowsContext
Inherits DbContext
Public Property Books As DbSet(Of BookRow)
Protected Overrides Sub OnConfiguring(optionsBuilder As DbContextOptionsBuilder)
optionsBuilder.UseSqlite("Data Source=shelf.db")
End Sub
End Class
Public Class BookGridForm
Inherits Form
Private ReadOnly _grid As New DataGridView() With {.Dock = DockStyle.Fill, .AutoGenerateColumns = True}
Private ReadOnly _source As New BindingSource()
Private ReadOnly _rows As New BindingList(Of BookRow)
Public Sub New()
Text = "Shelf"
_source.DataSource = _rows
_grid.DataSource = _source
Controls.Add(_grid)
End Sub
Public Shared Function TrackedRows(context As RowsContext) As BindingList(Of BookRow)
' A binding list that stays in step with the change tracker.
Return context.Books.Local.ToBindingList()
End Function
Protected Overrides Async Sub OnShown(e As EventArgs)
MyBase.OnShown(e)
Using context As New RowsContext()
' AsNoTracking: the grid edits copies; save them explicitly with a separate unit of work.
Dim loaded = Await context.Books.AsNoTracking().OrderBy(Function(b) b.Title).ToListAsync()
_rows.RaiseListChangedEvents = False
For Each row In loaded
_rows.Add(row)
Next
_rows.RaiseListChangedEvents = True
_rows.ResetBindings()
End Using
End Sub
End Class
Public Module Program
<STAThread>
Public Sub Main()
Application.EnableVisualStyles()
Application.Run(New BookGridForm())
End Sub
End Module
Source: Windows Forms for .NET
Notes: the entity here is a row class for the grid, with settable properties (a grid cannot edit read-only ones); the context loads with AsNoTracking because the grid edits copies and the program saves them explicitly; RaiseListChangedEvents = False while adding many rows avoids one grid refresh per row; do not keep the
context alive for the lifetime of the form. For WPF the equivalent is an ObservableCollection(Of BookViewModel) that the view model fills after Await returns (see
WPF Applications), and a DataTable.DefaultView binds to a DataGrid in both frameworks. The Windows Forms details (BindingSource, DataGridView columns) are on
Windows Forms Applications.
Other stores: MongoDB and Redis
Document and key-value stores have their own drivers, which are .NET libraries and therefore work from Visual Basic. The listing was built, not run: it needs servers on localhost. It uses MongoDB.Driver 3.12.0 and StackExchange.Redis 3.4.5 with expression lambdas in the
queries (the Mongo Find takes an expression tree, as LINQ does):
Option Strict On
Imports MongoDB.Bson
Imports MongoDB.Driver
Imports StackExchange.Redis
' Built, not run: both stores need a running server.
Public Class BookDocument
Public Property Id As ObjectId
Public Property Title As String = ""
Public Property Year As Integer
End Class
Module Program
Sub Main()
RunAsync().GetAwaiter().GetResult()
End Sub
Private Async Function RunAsync() As Task
Dim client As New MongoClient("mongodb://localhost:27017")
Dim books = client.GetDatabase("shelf").GetCollection(Of BookDocument)("books")
Await books.InsertOneAsync(New BookDocument With {.Title = "Dune", .Year = 1965})
Dim newer = Await books.Find(Function(b) b.Year > 1960).SortBy(Function(b) b.Title).ToListAsync()
Console.WriteLine("Mongo documents: " & newer.Count)
Dim redis = Await ConnectionMultiplexer.ConnectAsync("localhost:6379")
Dim db = redis.GetDatabase()
Await db.StringSetAsync("book:1:title", "Dune", TimeSpan.FromMinutes(5))
Dim cached As RedisValue = Await db.StringGetAsync("book:1:title")
Console.WriteLine("Redis value: " & cached.ToString())
End Function
End Module
The data models, indexing and operations of these stores are explained with their native tools in MongoDB drivers and tooling and Redis clients and client-side caching; Choosing the right database helps to decide which store a feature needs.
Legacy: EF6, LINQ to SQL and typed DataSets
-
EF6 (
EntityFramework6.x, theSystem.Data.Entitynamespace) is in maintenance. The 6.5 line targets .NET and .NET Framework: a project test on .NET 10 restoredEntityFramework6.5.1, compiled aDbContext-derived class, and loaded the assembly at run time (EF6 type loaded: EntityFramework 6.0.0.0,Runtime: .NET 10.0.13). The test did not open a database; the EDMX designer and the Package Manager Console commands of EF6 (Enable-Migrations,Update-Database) were not exercised. For an application that already uses EF6, keeping it on .NET Framework is legitimate; moving to .NET 10 is a port to EF Core in practice, covered for C# on Data Access with EF6. The official guide for the move is Port from EF6 to EF Core. -
LINQ to SQL (
System.Data.Linq,.dbmldesigner files) is documented in the .NET Framework data documentation (the link below sits under/dotnet/framework/), not in the .NET documentation. Plan to port a program that uses it to EF Core or Dapper as part of a move to .NET 10. -
Typed DataSets generated by older Visual Studio wizards are plain code on top of
System.Dataand port easily, but they are not a model to start from. -
System.Data.OracleClientandSystem.Data.SqlClientare the old provider names; see the opening table.
Visual Basic and C# in data access
| Topic | Visual Basic | C# |
|---|---|---|
Dispose a connection |
|
|
Async disposal |
No |
|
Await in |
Not allowed (BC36943) |
Allowed since C# 6 |
Optional column |
|
|
Required string properties in EF |
|
Non-nullable |
Parameters |
|
|
Anonymous parameter object (Dapper) |
|
|
LINQ in EF |
Query syntax with |
Query syntax and method syntax |
Interpolated raw SQL |
|
|
Migrations scaffolding |
Not for VB; use a C# migrations project |
Built in |
Design-time code generation (migrations, scaffolding) |
Not for Visual Basic (tested for migrations) |
Built in |
Case-insensitive names |
|
Case-sensitive: |
Where older material is outdated
| Older advice | Today on .NET 10 |
|---|---|
|
Package |
|
Removed; use |
LINQ to SQL and EF6 ( |
EF Core with |
|
|
Connection strings in |
|
Concatenated SQL in teaching examples |
Never; parameters, always. |
|
The |
Versions, compiler messages and run-time failures
When the features on this page arrived:
| Feature | Introduced in |
|---|---|
Interpolated strings, including conversion to |
Visual Basic 14 (Visual Studio 2015) |
|
Visual Basic from its first release |
|
Never supported in Visual Basic (BC36943); C# has it since C# 6 |
|
EF Core 2.1 |
|
EF Core 7 |
|
EF Core 9 |
Encrypt-by-default connection strings |
Microsoft.Data.SqlClient 4.0 |
EF Core 10, the release used here |
Ships with .NET 10 (package versions 10.0.x) |
Compiler messages seen while writing the examples:
| Code | Message (as the .NET 10 SDK printed it) | Cause |
|---|---|---|
BC36943 |
|
Awaiting inside an exception handler. |
BC36675 |
|
A multi-line lambda passed where EF Core needs an expression ( |
BC30456 |
|
A local variable |
BC36663 and BC30456 |
|
Two classes of the same name, one in your namespace and one imported (shown on the WPF page). |
Run-time failures seen, with the exception type and the message the provider gave:
| Situation | Exception | Message (shortened) |
|---|---|---|
Command on a closed connection |
|
|
Parameter value left as |
|
|
Unique constraint violated |
|
|
Typed getter on a NULL column |
|
|
|
|
|
Query EF Core cannot translate |
|
|
Two contexts update the same row |
|
Raised by |
SQL Server unreachable |
|
|
Cancelled token |
|
Derives from |
Pitfalls
| Pitfall | Remedy |
|---|---|
|
|
String-concatenated SQL |
Parameters; allow-list identifiers. |
|
|
|
Capture, leave the block, then await (BC36943). |
Visual Basic |
|
|
|
|
|
Local variable named like a type or a |
Rename; names are case-insensitive. |
Trying |
Use a C# migrations project. |
One shared |
One per unit of work. |
Leaked connection |
|
|
Fix the certificate; use the flag only for throw-away local servers. |
Connection string in source control |
Configuration, user secrets, a key vault or integrated security. |
Unbounded query bound to a grid |
Page in the query ( |
References
-
Microsoft.Data.Sqlite, Connection strings, Parameters, Transactions, Async, Database errors and In-memory databases
-
Microsoft.Data.SqlClient, Introduction to Microsoft.Data.SqlClient, SQL Server connection pooling and SQL injection
-
ADO.NET, ADO.NET overview, Connection pooling and DataSets, DataTables and DataViews
-
DbConnection, DbDataAdapter, DataSet, SqlConnection and SqliteConnection
-
Entity Framework Core, What’s new in EF Core 10, SQLite provider and SQLite limitations
-
Migrations, Separate migrations project, Scaffolding and .NET CLI tools
-
Testing EF Core applications, Nullable reference types and Efficient querying
-
Community: Dapper and EFCore.VisualBasic