Editing Data
Master the complete data-editing lifecycle — validated INSERT/UPDATE/DELETE, the CommandBuilder shortcut, SQL transactions for atomic multi-row operations, inline DataGridView editing, and a simple undo stack.
conn.BeginTransaction() → execute commands → tx.Commit(), or tx.Rollback() in the Catch block. The CommandBuilder auto-generates INSERT/UPDATE/DELETE commands from a SELECT DataAdapter in one line — ideal for simple tables without business rules.
31.1 Input Validation Before Saving
Never write unchecked user input to the database. Validate in the UI first — collect all errors into a list and show them together so the user can fix everything at once, rather than one error at a time.
' --- Validate inputs, return list of error messages --- Private Function ValidateStudent() As List(Of String) Dim errors As New List(Of String) ' Name: required, 2–80 chars, letters/spaces only If txtName.Text.Trim().Length < 2 Then errors.Add("Name must be at least 2 characters.") ElseIf txtName.Text.Trim().Length > 80 Then errors.Add("Name must be 80 characters or fewer.") End If ' Grade: numeric, 0–100 Dim grade As Double If Not Double.TryParse(txtGrade.Text, grade) Then errors.Add("Grade must be a number.") ElseIf grade < 0 Or grade > 100 Then errors.Add("Grade must be between 0 and 100.") End If ' Class: required selection If cboClass.SelectedIndex < 0 Then errors.Add("Please select a class.") End If Return errors End Function ' --- Use in Save button --- Private Sub btnSave_Click(...) Handles btnSave.Click Dim errors = ValidateStudent() If errors.Count > 0 Then MessageBox.Show(String.Join(Environment.NewLine, errors), "Validation Errors", MessageBoxButtons.OK, MessageBoxIcon.Warning) Return ' abort save End If SaveStudent() ' validation passed — write to database End Sub ' --- Live validation feedback (TextChanged) --- Private Sub txtGrade_TextChanged(...) Handles txtGrade.TextChanged Dim g As Double If Double.TryParse(txtGrade.Text, g) And g >= 0 And g <= 100 Then txtGrade.BackColor = Color.Honeydew ' green tint = valid lblGradeError.Text = "" Else txtGrade.BackColor = Color.MistyRose ' red tint = invalid lblGradeError.Text = "⚠ Must be 0–100" End If End Sub ' --- Duplicate name check (before INSERT) --- Function NameExists(name As String) As Boolean Using conn As New SqliteConnection(CONN_STR) conn.Open() Using cmd As New SqliteCommand( "SELECT COUNT(*) FROM Students WHERE Name = @Name", conn) cmd.Parameters.AddWithValue("@Name", name.Trim()) Return CInt(cmd.ExecuteScalar()) > 0 End Using End Using End Function
Try submitting with invalid data — empty name, out-of-range grade, missing class. Each field shows live feedback as you type. Clicking Save shows all collected errors at once.
31.2 The CommandBuilder — Auto-Generated SQL
The SqliteCommandBuilder (or OleDbCommandBuilder for Access) inspects your SELECT DataAdapter and automatically writes the matching INSERT, UPDATE, and DELETE commands. You then edit the in-memory DataTable and call da.Update(dt) — the adapter executes the right command for each changed row based on its RowState.
' --- CommandBuilder setup (in LoadData) --- Private _da As SqliteDataAdapter Private _dt As New DataTable() Private _cb As SqliteCommandBuilder Private Sub LoadData() Using conn As New SqliteConnection(CONN_STR) conn.Open() _da = New SqliteDataAdapter("SELECT * FROM Students ORDER BY Name", conn) _cb = New SqliteCommandBuilder(_da) ' auto-generates INSERT/UPDATE/DELETE _da.Fill(_dt) End Using dgv.DataSource = _dt End Sub ' --- Add a new row to the DataTable --- Private Sub AddRow() Dim row = _dt.NewRow() row("Name") = txtName.Text row("Grade") = CDbl(txtGrade.Text) row("Class") = cboClass.Text _dt.Rows.Add(row) ' RowState = DataRowState.Added End Sub ' --- Modify an existing row --- Private Sub EditRow(rowIndex As Integer) _dt.Rows(rowIndex)("Grade") = CDbl(txtGrade.Text) ' RowState changes to DataRowState.Modified automatically End Sub ' --- Mark a row for deletion --- Private Sub DeleteRow(rowIndex As Integer) _dt.Rows(rowIndex).Delete() ' RowState = DataRowState.Deleted ' Row not physically removed until da.Update() is called End Sub ' --- Commit ALL pending changes to the database --- Private Sub SaveAllChanges() Try Using conn As New SqliteConnection(CONN_STR) conn.Open() _da.SelectCommand.Connection = conn _da.Update(_dt) ' pushes Added/Modified/Deleted rows End Using _dt.AcceptChanges() ' reset all RowStates to Unchanged MessageBox.Show("All changes saved.") Catch ex As Exception MessageBox.Show(ex.Message, "Save Error") End Try End Sub ' --- Discard all pending changes --- Private Sub DiscardChanges() _dt.RejectChanges() ' rolls back all Added/Modified/Deleted rows End Sub ' --- Check if there are unsaved changes --- Dim hasChanges = _dt.GetChanges() IsNot Nothing
The CommandBuilder only works for single-table SELECT statements with a primary key. It cannot handle JOINs, computed columns, or stored procedures. For complex business logic (cascading deletes, audit trails, triggers), write your own parameterised INSERT/UPDATE/DELETE commands.
Add, edit, and mark rows for deletion — each shows a colour-coded RowState (green = Added, yellow = Modified, red = Deleted). Click Save All to simulate da.Update(dt) generating the right SQL. Discard calls RejectChanges().
31.3 SQL Transactions
A transaction ensures that a group of SQL statements all succeed or all fail together — the database never ends up in a half-changed state. Use conn.BeginTransaction(), assign the transaction to every command, call Commit() on success, and Rollback() in the Catch block.
' --- Transfer 10 marks from one student to another (atomic) --- Private Sub TransferMarks(fromId As Integer, toId As Integer, amount As Integer) Using conn As New SqliteConnection(CONN_STR) conn.Open() Dim tx = conn.BeginTransaction() ' START TRANSACTION Try ' Step 1 — deduct from source student Using cmd1 As New SqliteCommand( "UPDATE Students SET Grade = Grade - @Amt WHERE StudentID = @ID", conn, tx) cmd1.Parameters.AddWithValue("@Amt", amount) cmd1.Parameters.AddWithValue("@ID", fromId) cmd1.ExecuteNonQuery() End Using ' Step 2 — add to destination student Using cmd2 As New SqliteCommand( "UPDATE Students SET Grade = Grade + @Amt WHERE StudentID = @ID", conn, tx) cmd2.Parameters.AddWithValue("@Amt", amount) cmd2.Parameters.AddWithValue("@ID", toId) cmd2.ExecuteNonQuery() End Using ' Both steps succeeded — commit tx.Commit() MessageBox.Show("Transfer complete.") Catch ex As Exception tx.Rollback() ' undo BOTH steps if either failed MessageBox.Show($"Transfer failed — rolled back: {ex.Message}") End Try End Using End Sub ' --- Batch INSERT with transaction (much faster than individual inserts) --- Private Sub BulkInsert(students As List(Of Student)) Using conn As New SqliteConnection(CONN_STR) conn.Open() Dim tx = conn.BeginTransaction() Try Using cmd As New SqliteCommand( "INSERT INTO Students (Name, Grade, Class) VALUES (@N, @G, @C)", conn, tx) For Each s In students cmd.Parameters.Clear() cmd.Parameters.AddWithValue("@N", s.Name) cmd.Parameters.AddWithValue("@G", s.Grade) cmd.Parameters.AddWithValue("@C", s.Class) cmd.ExecuteNonQuery() ' reuse same command in loop Next End Using tx.Commit() MessageBox.Show($"Inserted {students.Count} records.") Catch ex As Exception tx.Rollback() End Try End Using End Sub
SQLite wraps every ExecuteNonQuery() without an explicit transaction in its own auto-transaction, which flushes to disk. Inserting 1 000 rows individually can take 10+ seconds. Wrapping the loop in a single BeginTransaction()/Commit() reduces that to under 100 ms — a 100× speedup.
Step through a multi-statement transaction manually, or trigger a deliberate failure to see the rollback. The transaction log shows each SQL statement and the commit/rollback outcome.
31.4 Inline DataGridView Editing
When dgv.ReadOnly = False, the user can double-click any cell to edit it directly. Handle the CellEndEdit event to validate the new value immediately and save (or reject) the change.
' --- Allow inline editing --- dgv.ReadOnly = False dgv.EditMode = DataGridViewEditMode.EditOnDoubleClick ' or EditOnKeystroke ' --- Validate immediately after cell edit --- Private Sub dgv_CellValidating(...) Handles dgv.CellValidating If dgv.Columns(e.ColumnIndex).Name = "Grade" Then Dim g As Double If Not Double.TryParse(e.FormattedValue.ToString(), g) _ Or g < 0 Or g > 100 Then e.Cancel = True ' keep cell in edit mode MessageBox.Show("Grade must be 0–100.") End If End If End Sub ' --- Save the cell edit to the database --- Private Sub dgv_CellEndEdit(...) Handles dgv.CellEndEdit Dim col = dgv.Columns(e.ColumnIndex).Name Dim val = dgv.Rows(e.RowIndex).Cells(e.ColumnIndex).Value Dim id = CInt(dgv.Rows(e.RowIndex).Cells("StudentID").Value) Using conn As New SqliteConnection(CONN_STR) conn.Open() Dim sql = $"UPDATE Students SET {col} = @Val WHERE StudentID = @ID" Using cmd As New SqliteCommand(sql, conn) cmd.Parameters.AddWithValue("@Val", val) cmd.Parameters.AddWithValue("@ID", id) cmd.ExecuteNonQuery() End Using End Using End Sub ' --- Prevent editing of primary-key column --- Private Sub dgv_CellBeginEdit(...) Handles dgv.CellBeginEdit If dgv.Columns(e.ColumnIndex).Name = "StudentID" Then e.Cancel = True ' block edit of primary key End If End Sub ' --- Undo last cell edit (Ctrl+Z) --- Private Sub Form_KeyDown(...) Handles MyBase.KeyDown If e.Control And e.KeyCode = Keys.Z Then UndoLastEdit() End Sub
Double-click any cell (except ID) to edit it in place. Grade cells validate immediately — out-of-range values are rejected and the cell stays in edit mode. Each accepted change shows the equivalent UPDATE SQL.
31.5 Simple Undo Stack
A lightweight undo system stores a snapshot of each record before it is changed. Push a copy onto a Stack before every INSERT/UPDATE/DELETE. On Ctrl+Z, pop the top action and reverse it.
' --- Undo action record --- Enum UndoType : Added : Edited : Deleted : End Enum Structure UndoAction Dim Kind As UndoType Dim StudentID As Integer Dim OldName As String Dim OldGrade As Double Dim OldClass As String End Structure Private _undo As New Stack(Of UndoAction)() ' --- Push BEFORE making any change --- Private Sub PushUndo(kind As UndoType, row As DataRow) _undo.Push(New UndoAction With { .Kind = kind, .StudentID = CInt(row("StudentID")), .OldName = row("Name").ToString(), .OldGrade = CDbl(row("Grade")), .OldClass = row("Class").ToString() }) btnUndo.Enabled = True End Sub ' --- Undo last action --- Private Sub UndoLastEdit() If _undo.Count = 0 Then Return Dim action = _undo.Pop() Using conn As New SqliteConnection(CONN_STR) conn.Open() Select Case action.Kind Case UndoType.Added ' Undo add = delete the inserted row Using cmd As New SqliteCommand( "DELETE FROM Students WHERE StudentID = @ID", conn) cmd.Parameters.AddWithValue("@ID", action.StudentID) cmd.ExecuteNonQuery() End Using Case UndoType.Edited ' Undo edit = restore old values Using cmd As New SqliteCommand( "UPDATE Students SET Name=@N, Grade=@G, Class=@C WHERE StudentID=@ID", conn) cmd.Parameters.AddWithValue("@N", action.OldName) cmd.Parameters.AddWithValue("@G", action.OldGrade) cmd.Parameters.AddWithValue("@C", action.OldClass) cmd.Parameters.AddWithValue("@ID", action.StudentID) cmd.ExecuteNonQuery() End Using Case UndoType.Deleted ' Undo delete = re-insert the row Using cmd As New SqliteCommand( "INSERT INTO Students (StudentID, Name, Grade, Class) VALUES (@ID, @N, @G, @C)", conn) cmd.Parameters.AddWithValue("@ID", action.StudentID) cmd.Parameters.AddWithValue("@N", action.OldName) cmd.Parameters.AddWithValue("@G", action.OldGrade) cmd.Parameters.AddWithValue("@C", action.OldClass) cmd.ExecuteNonQuery() End Using End Select End Using If _undo.Count = 0 Then btnUndo.Enabled = False LoadData() ' refresh DataGridView End Sub
Every Add, Edit, and Delete pushes a snapshot to the undo stack. Click Undo (or press Ctrl+Z) to reverse the last action. The stack display shows all pending undos — LIFO order.
31.6 GitHub Copilot — Validated CRUD with Undo
' Generate a complete validated CRUD form for an Inventory table (ProductID, Name, Category, Price, StockQty). Validate: Name required, Price > 0, StockQty >= 0, Category not empty. Use transactions for all writes. Support undo for the last 10 operations. Show a status bar with record count and "Unsaved changes" indicator.'' Validated inventory CRUD with undo — generated by Copilot. Imports Microsoft.Data.Sqlite Public Class frmInventory Private Const DB = "Data Source=inventory.db" Private _bs As New BindingSource() Private _dt As New DataTable() Private _undo As New Stack(Of UndoAction)() Private Const MAX_UNDO = 10 ' --- Validation --- Private Function Validate() As List(Of String) Dim e As New List(Of String) If txtName.Text.Trim() = "" Then e.Add("Name is required.") If cboCategory.SelectedIndex < 0 Then e.Add("Select a category.") Dim price As Decimal If Not Decimal.TryParse(txtPrice.Text, price) _ Or price <= 0 Then e.Add("Price must be > 0.") If nudStock.Value < 0 Then e.Add("StockQty cannot be negative.") Return e End Function ' --- Save with transaction --- Private Sub btnSave_Click(...) Handles btnSave.Click Dim errs = Validate() If errs.Count > 0 Then MessageBox.Show(String.Join(vbNewLine, errs), "Validation") Return End If Dim isNew = (_selectedId = -1) PushUndo(If(isNew, UndoType.Added, UndoType.Edited)) Using conn As New SqliteConnection(DB) conn.Open() Dim tx = conn.BeginTransaction() Try If isNew Then Using cmd As New SqliteCommand( "INSERT INTO Products(Name,Category,Price,StockQty) VALUES(@N,@C,@P,@S)", conn, tx) cmd.Parameters.AddWithValue("@N", txtName.Text.Trim()) cmd.Parameters.AddWithValue("@C", cboCategory.Text) cmd.Parameters.AddWithValue("@P", CDec(txtPrice.Text)) cmd.Parameters.AddWithValue("@S", CInt(nudStock.Value)) cmd.ExecuteNonQuery() End Using Else Using cmd As New SqliteCommand( "UPDATE Products SET Name=@N,Category=@C,Price=@P,StockQty=@S WHERE ProductID=@ID", conn, tx) cmd.Parameters.AddWithValue("@N", txtName.Text.Trim()) cmd.Parameters.AddWithValue("@C", cboCategory.Text) cmd.Parameters.AddWithValue("@P", CDec(txtPrice.Text)) cmd.Parameters.AddWithValue("@S", CInt(nudStock.Value)) cmd.Parameters.AddWithValue("@ID", _selectedId) cmd.ExecuteNonQuery() End Using End If tx.Commit() LoadProducts() UpdateStatus() Catch ex As Exception tx.Rollback() : _undo.TryPop(Nothing) MessageBox.Show(ex.Message, "Save Error") End Try End Using End Sub Private Sub UpdateStatus() lblStatus.Text = $"{_bs.Count} products | Undo: {_undo.Count}/{MAX_UNDO}" If _undo.Count > MAX_UNDO Then _undo.Clear() End Sub End Class
Try these in the Copilot Chat panel:
- "Generate an audit log table (AuditLog: LogID, TableName, Action, OldValues, NewValues, ChangedAt, ChangedBy) and a Sub that inserts a JSON-serialised audit record inside every transaction that modifies the Students table"
- "Write a Generic(Of T) helper function SaveWithUndo(T) that pushes to the undo stack, runs an Action, catches exceptions, and pops from the stack on failure"
- "Add DataGridView.UserDeletingRow handler: before deleting, push undo, ask for confirmation with MessageBox.YesNo, and e.Cancel = True if the user clicks No"
- "Implement a bulk-import feature: OpenFileDialog filters to .csv, parse each line into a Student, validate all rows first, then insert them all in a single transaction with a progress bar"
Lesson Summary
- Validate before writing. Collect all errors into a
List(Of String)and return them from aValidateStudent()function. Show them all at once withString.Join(Environment.NewLine, errors). Provide live feedback (green/red background) onTextChanged. - The CommandBuilder (
SqliteCommandBuilder(_da)) auto-generates INSERT/UPDATE/DELETE from your SELECT. EditDataRowobjects directly —RowStatetracks what changed — then callda.Update(dt)anddt.AcceptChanges(). Calldt.RejectChanges()to discard pending edits. - Transactions:
Dim tx = conn.BeginTransaction()→ passtxto every command →tx.Commit()on success →tx.Rollback()in Catch. Always in aUsingblock. Batch inserts inside a single transaction can be 100× faster than auto-commit mode. - Inline DataGridView editing: set
ReadOnly = False, handleCellValidating(sete.Cancel = Trueto reject), and save the change inCellEndEdit. Block primary-key editing inCellBeginEdit. - An undo stack (
Stack(Of UndoAction)) stores a before-snapshot before every change. On Ctrl+Z, pop and reverse: undo an INSERT with DELETE, undo an UPDATE by restoring old values, undo a DELETE with re-INSERT.
Exercises
Exercise 31.1 — Validated Student Entry
- Build a student entry form with live validation: Name (required, max 60 chars), Grade (0–100, numeric), Class (required ComboBox).
- Name field turns green/red as the user types. Save button disabled until all fields are valid.
- Add a duplicate-name check using
ExecuteScalar— warn but allow save after confirmation. - Wrap the INSERT in a transaction. On failure, show the full exception message in a label.
- Copilot challenge: "Add a RequiredField validator component that automatically highlights any TextBox bound to it when it loses focus while empty, using the Control.Validating event"
Exercise 31.2 — Batch Grade Update
- Add a "Apply Grade Bonus" feature: a NumericUpDown for the bonus value (1–10) and a ComboBox for the class to apply it to.
- Run
UPDATE Students SET Grade = MIN(Grade + @Bonus, 100) WHERE Class = @Classin a transaction. Show how many rows were affected. - Push an undo action that stores all affected rows before the update — undo restores each row individually in its own transaction.
- Show before/after statistics: average grade before and after the bonus using
ExecuteScalar. - Copilot challenge: "Log each batch update to an AuditLog table with columns: Action, AffectedRows, OldAvg, NewAvg, Timestamp"
Exercise 31.3 — Inline Editable Inventory Grid
- Products DataGridView with
ReadOnly = False. Product name and StockQty are editable; Price is read-only (edit via form below). CellValidating: StockQty must be ≥ 0 integer; Name must not be empty. Reject withe.Cancel = Trueand tooltip.CellEndEdit: save changed cell to database with parameterised UPDATE. Colour-code changed rows yellow until saved.- Add Ctrl+Z undo for the last 5 inline edits.
- Copilot challenge: "Add a DataGridView.UserDeletingRow handler: push to undo stack, confirm with MessageBox.YesNo, call DELETE with transaction, cancel the default deletion, and reload the grid"
Related Resources
← Lesson 30
Connecting DB — BindingSource, BindingNavigator.
Lesson 32 →
Console App 1 — Console.ReadLine, formatting.
CommandBuilder Docs
SqliteCommandBuilder full API reference.
SQLite Transactions
Official transaction isolation and performance docs.
Featured Books
Visual Basic 2022 Made Easy
Data editing chapters: validation patterns, CommandBuilder, and transactions with worked examples.
View on Amazon →
VB Programming With Code Examples
Full CRUD programs with error handling, validation, and undo functionality.
View on Amazon →