Skip to content

Replace Sql Server with SQLite - #2

Merged
scegg merged 1 commit into
masterfrom
migrate/sqlite
Aug 15, 2026
Merged

Replace Sql Server with SQLite#2
scegg merged 1 commit into
masterfrom
migrate/sqlite

Conversation

@scegg

@scegg scegg commented Aug 15, 2026

Copy link
Copy Markdown
Member

A library is now a single SQLite file. No server, no instance, no connection string — New-ImageStoreDatabase library.db and you are working.

The cmdlets asked for

  • New-ImageStoreDatabase -Path creates the file, builds the schema and opens it, so it stands in for a following Open. -Force replaces an existing file along with its -wal/-shm/-journal companions.
  • Open-ImageStoreDatabase takes a path as its primary parameter set, with a -ConnectionString set kept for what a path cannot express (Mode=ReadOnly and similar). It refuses a path that does not exist — SQLite would otherwise create an empty database that looks fine until the first query.
  • Closing happens on Close-ImageStoreDatabase, on Remove-Module (IModuleAssemblyCleanup) and on host exit (AppDomain.ProcessExit). Two hooks because neither covers the other; both funnel into one idempotent, locked Close() that checkpoints WAL so nothing is left beside the file.
  • Everything else is unchanged at the call site.

What made this more than a find-and-replace

Reader casts — 97 of them. SQLite has five storage classes, so reader[i] returns long for every integer and bool, double for every real, and string/byte[] for a Guid. Every (Guid), (bool), (int) and (float) cast threw InvalidCastException. All now use typed getters, which convert correctly.

Guid binding. Stored as 16-byte BLOB to match uniqueidentifier. Binding a Guid directly makes the provider write 36-character TEXT — and the failure is silent: the write succeeds, and every subsequent lookup simply matches nothing. All binding goes through Parameters.AddGuid.

Case sensitivity. SQLite defaults to binary comparison, which would have been a behaviour change: the same file at a differently-cased path becomes two records, and UNIQUE indexes stop catching .JPG versus .jpg. Text columns are COLLATE NOCASE. Known limit: that folds ASCII only, so accented names compare exactly.

LIKE escaping, and a bug that predates this work. Sql Server's [%] bracket syntax means nothing to SQLite — the brackets would match literally while the % inside kept its wildcard meaning, turning an exact search into a wildcard one. Now an explicit ESCAPE. The rewrite also drops the quote-doubling, which was never a dialect difference but a plain bug: those values are bound as parameters and never parsed as SQL, so searching for Don't.jpg could not match. That was broken on Sql Server too.

Cascades are asymmetric on purpose. File, IgnoredDirectory and SameFile cascade; SimilarFile does not. Preserved exactly, because RemoveFolderCmdlet relies on the first and FileHelper compensates for the second.

Also: TOPLIMIT appended after the where and order by clauses (not a token swap — the assembly order had to be reworked), #tempCREATE TEMP TABLE, DBCC SHRINKDATABASEVACUUM.

Migration tool

ImageStore.Migrator, shipped as its own release asset:

ImageStore.Migrator --source "<sql server connection string>" --target library.db

It compiles SqliteSchema.cs by linked compile item rather than carrying its own copy, so a migrated database and a freshly created one cannot drift apart. It reads the old database without modifying it, so the Sql Server library remains as a fallback.

Removals

DataStore.mdf, DataStore_log.ldf and CreateDatabase.txt are gone, along with their solution entries and the ImageStore-Database-<tag>.zip release asset — there is no empty database to hand out when the cmdlet creates one. Microsoft.Data.SqlClient survives only in the migrator.

Packaging

Watch this one: SQLite ships its native engine for every platform it supports, which took the module from ~6 MB to 34 MB of Linux, macOS and wasm binaries a Windows-only module can never load. The workflow now drops every non-win* runtime directory before packaging (verified locally: 34M → 5.8M, all three Windows architectures kept). Verification asserts e_sqlite3.dll survives that trim, and that no Microsoft.Data.SqlClient.dll reaches the module.

Verification

Everything below was run, not assumed:

  • Solution builds — 0 warnings, 0 errors.
  • Provider behaviour measured directly before designing anything: (Guid)reader[0] throws; GetGuid round-trips a BLOB; COLLATE NOCASE matches ABC/abc but not CAFÉ/café; Sql Server's [%] matches nothing; ESCAPE '\' works.
  • End-to-end against the real schema file: 19 statements execute, Guid round-trips through a real query, NOCASE matches on Path and Extension, the UNIQUE index blocks a .JPG/.jpg duplicate, escaped % and apostrophe searches both match, LIMIT after order by, temp-table flow, cascade delete, VACUUM.
  • Trim + verification logic dry-run against actual publish output.

Not verified, and CI cannot cover it: Import-Module under a real pwsh, the WinForms cmdlets, clean shutdown leaving no -wal/-shm, and a migration against a real Sql Server library with row counts compared per table.

🤖 Generated with Claude Code

A library is now a single file. New-ImageStoreDatabase creates one and opens
it; Open-ImageStoreDatabase takes a path, with a connection-string parameter
set kept for options a path cannot express, and refuses a path that does not
exist rather than letting SQLite quietly create an empty database.

The database is closed on Remove-Module and on host exit, so the -wal and -shm
files are checkpointed away instead of left beside the file.

Guids are stored as 16-byte BLOBs, matching uniqueidentifier. Binding a Guid
directly would make the provider write 36-character TEXT and every lookup would
then silently miss, so all binding goes through Parameters.AddGuid.

Reader casts had to change everywhere: SQLite returns long for integers and
bools, double for reals, and string or byte[] for a Guid, so the 97 direct
casts all threw. They now use typed getters.

Text columns are COLLATE NOCASE, keeping path comparison case-insensitive as it
was under Sql Server and as the Windows file system and the in-memory
OrdinalIgnoreCase comparisons already assume.

LIKE escaping is rewritten. Sql Server's bracket syntax means nothing to SQLite
-- it would have turned exact searches into wildcard ones -- so this uses an
explicit ESCAPE clause. It also drops the quote-doubling, which was a plain bug
rather than a dialect difference: those values are bound as parameters and
never parsed as SQL, so searching for a name containing an apostrophe could not
match. That was broken on Sql Server too.

Also: TOP becomes LIMIT appended after the where and order by clauses, #temp
becomes CREATE TEMP TABLE, DBCC SHRINKDATABASE becomes VACUUM.

The empty database and schema script are gone; the schema lives in
SqliteSchema.cs, which ImageStore.Migrator compiles by link so a migrated
database and a new one cannot drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@scegg
scegg merged commit edff6d3 into master Aug 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant