<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged orm</title>
<link>https://coderlegion.com/tag/orm</link>
<description>Powered by Question2Answer</description>
<item>
<title>Database Migrations in Rows ORM</title>
<link>https://coderlegion.com/18414/database-migrations-in-rows-orm</link>
<description>&lt;p&gt;Rows, the ORM built for Alusus language, takes a fundamentally different approach to database migrations compared to the linear, file-based systems found in tools like Sequelize, Flyway, or Rails ActiveRecord. Instead of maintaining an ordered sequence of migration files that must all be run in the right order, Rows ties migrations directly to your model definitions and tracks schema evolution on a per-table basis.&lt;/p&gt;
&lt;h3 id=&quot;the-core-idea-model-versions&quot;&gt;The Core Idea: Model Versions&lt;/h3&gt;
&lt;p&gt;Every model in Rows declares its current version as part of its &lt;code&gt;@model&lt;/code&gt; annotation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@model[&quot;vehicles&quot;, 2]
class Vehicle {
    define_model_essentials[];

    @notNull @primaryKey @Integer @column
    def id: int;

    @VarChar[&quot;50&quot;] @column
    def name: String;

    @Integer @column[&quot;year&quot;]
    def year: Int;

    @migration[1, 2]
    function migrate1To2(db: ref[Db]): SrdRef[Error] {
        return db.exec(&quot;alter table vehicles add column year integer&quot;).error;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The number &lt;code&gt;2&lt;/code&gt; in &lt;code&gt;@model[&quot;vehicles&quot;, 2]&lt;/code&gt; is the current version of this model's schema. The &lt;code&gt;@migration[1, 2]&lt;/code&gt; annotation on a method declares a function that upgrades the &lt;code&gt;vehicles&lt;/code&gt; table from version 1 to version 2.&lt;/p&gt;
&lt;p&gt;The library maintains a &lt;code&gt;table_versions&lt;/code&gt; table in the database that records the current version of every table. When you call &lt;code&gt;migrate()&lt;/code&gt;, the engine compares what versions the DB currently has against what versions the models declare, and figures out exactly which migration functions need to run.&lt;/p&gt;
&lt;h3 id=&quot;the-migration-lifecycle&quot;&gt;The Migration Lifecycle&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7601438939060940619&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;step-by-step&quot;&gt;Step by step&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ensure tracking table exists.&lt;/strong&gt; The engine creates the &lt;code&gt;table_versions&lt;/code&gt; table if it is not already there.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read current state.&lt;/strong&gt; It queries &lt;code&gt;table_versions&lt;/code&gt; to get the current version number for each table that has been migrated before.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pick needed migrations.&lt;/strong&gt; For each model, the engine walks the version chain starting from the current DB version up to the model's target version, collecting every migration function needed. If any step in the chain is missing a migration function, it returns an error immediately.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Populate dependencies.&lt;/strong&gt; Each migration function can declare that it depends on another table being at a certain version. The engine resolves these into direct migration-to-migration dependencies.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Topological sort.&lt;/strong&gt; The collected migrations are sorted using Kahn's algorithm so that every migration runs only after all of its dependencies have completed. If a dependency cycle is detected, an error is returned.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run and record.&lt;/strong&gt; Each migration is executed in sorted order. After each one succeeds, &lt;code&gt;table_versions&lt;/code&gt; is updated to record the new version for that table.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;fresh-databases-no-migrations-needed&quot;&gt;Fresh Databases: No Migrations Needed&lt;/h3&gt;
&lt;p&gt;When a table does not yet exist in the database (version 0), the engine does not require you to write a migration function. It automatically generates a &lt;code&gt;CREATE TABLE&lt;/code&gt; statement directly from the model's field definitions and annotations.&lt;/p&gt;
&lt;p&gt;This means for a brand-new development environment you just call &lt;code&gt;migrate()&lt;/code&gt; and all tables are created from scratch — no migration functions required at all. Migration functions are only needed when you need to transform an existing table from one version to another.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=8482834154403665021&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;multi-step-migrations&quot;&gt;Multi-Step Migrations&lt;/h3&gt;
&lt;p&gt;A model can hold multiple migration functions, each covering a different version step. The engine automatically chains them together:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@model[&quot;vehicles&quot;, 4]
class Vehicle {
    // ... current field definitions ...

    @migration[2, 3, { Price: 1 }]
    function migrate2To3(db: ref[Db]): SrdRef[Error] {
        return db.exec(&quot;insert into prices select id as price_id, the_price as price from vehicles&quot;).error;
    }

    @migration[3, 4]
    function migrate3To4(db: ref[Db]): SrdRef[Error] {
        return db.exec(&quot;alter table vehicles drop column the_price&quot;).error;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a database is currently at version 2, the engine will run &lt;code&gt;migrate2To3&lt;/code&gt; and then &lt;code&gt;migrate3To4&lt;/code&gt; in sequence, leaving the table at version 4. If it is already at version 3, only &lt;code&gt;migrate3To4&lt;/code&gt; runs. A jump from version 1 to version 4 would chain &lt;code&gt;migrate1To2&lt;/code&gt; (from the now-deleted or earlier version of the model) through each step.&lt;/p&gt;
&lt;h3 id=&quot;cross-table-dependencies&quot;&gt;Cross-Table Dependencies&lt;/h3&gt;
&lt;p&gt;The third argument to &lt;code&gt;@migration&lt;/code&gt; declares which other tables — and at which version — must be ready before this migration can run. The engine uses these declarations to determine a safe execution order across all tables.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@migration[2, 3, { Price: 1 }]
function migrate2To3(db: ref[Db]): SrdRef[Error] {
    // This migration copies data from vehicles into the prices table.
    // It must only run after the prices table exists at version 1.
    return db.exec(&quot;insert into prices select id as price_id, the_price as price from vehicles&quot;).error;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dependency &lt;code&gt;{ Price: 1 }&lt;/code&gt; tells the engine: &quot;before running this function, make sure the &lt;code&gt;prices&lt;/code&gt; table is at version 1.&quot; The engine resolves this into a direct ordering constraint between the two migrations and uses the topological sort to enforce it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=10099141435265855612&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;bypassing-auto-table-generation&quot;&gt;Bypassing Auto Table Generation&lt;/h3&gt;
&lt;p&gt;By default the engine creates a table automatically for any model whose table does not yet exist. If you need full control over the initial table creation for whatever reason you can define a migration from version 0 yourself. The presence of a &lt;code&gt;@migration[0, N]&lt;/code&gt; function signals to the engine to skip the auto-generation entirely and use your function instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@model[&quot;vehicles&quot;, 1]
class Vehicle {
    // ... field definitions ...

    @migration[0, 1]
    function createTable(db: ref[Db]): SrdRef[Error] {
        return db.exec(
            &quot;create table vehicles (id integer primary key, name varchar(50)) partition by range (id)&quot;
        ).error;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;arbitrary-migrations-using-fake-models&quot;&gt;Arbitrary Migrations using Fake Models&lt;/h3&gt;
&lt;p&gt;Sometimes you need to run a one-off data migration that does not correspond to a structural change in any particular table. You can use a &quot;fake&quot; model — a class with no column definitions — purely as a container for a migration function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@model[&quot;price_update&quot;, 1]
class PriceUpdate {
    @migration[0, 1, { Vehicle: 2 }]
    function migrate0To1(db: ref[Db]): SrdRef[Error] {
        return db.exec(&quot;update vehicles set the_price = the_price + 50&quot;).error;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because this model has a &lt;code&gt;@migration[0, 1]&lt;/code&gt; function, no table is created for it. The engine just runs the function and records &lt;code&gt;price_update&lt;/code&gt; at version 1 in &lt;code&gt;table_versions&lt;/code&gt;. The dependency &lt;code&gt;{ Vehicle: 2 }&lt;/code&gt; ensures the vehicles table is at the expected state before the data update runs.&lt;/p&gt;
&lt;h3 id=&quot;running-migrations&quot;&gt;Running Migrations&lt;/h3&gt;
&lt;p&gt;You pass a schema — a single model or a grouped set of models — to &lt;code&gt;schemaBuilder&lt;/code&gt; and call &lt;code&gt;migrate()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Single model
def schema: Vehicle;
db.schemaBuilder[schema].migrate();

// Multiple models
def schema: { Vehicle, Price, PriceUpdate };
db.schemaBuilder[schema].migrate();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The engine handles everything: determining what needs to run, ordering it correctly, and recording progress.&lt;/p&gt;
&lt;h3 id=&quot;cleaning-up-old-migrations&quot;&gt;Cleaning Up Old Migrations&lt;/h3&gt;
&lt;p&gt;Once a migration function has been deployed and run in production, it can be deleted from the source code. The engine only needs migration functions that bridge the gap between the current DB version and the target version. Any migration that covers a version range already passed in production is simply never consulted again.&lt;/p&gt;
&lt;p&gt;This is a significant ergonomic improvement: over time your model files stay lean rather than accumulating an ever-growing list of historical migration methods.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;comparison-with-linear-migration-systems&quot;&gt;Comparison with Linear Migration Systems&lt;/h3&gt;
&lt;p&gt;Tools like Sequelize, Flyway, and Rails ActiveRecord manage migrations as a linearly ordered sequence of numbered files. Every migration ever written must be present and run in order, even on a fresh database.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=4279846337408320298&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Concern &lt;/th&gt;&lt;th&gt;Linear system &lt;/th&gt;&lt;th&gt;Rows&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;New table&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Migration needed &lt;/td&gt;&lt;td&gt;Auto generated, no migration needed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Understanding current schema&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Must mentally replay all migrations &lt;/td&gt;&lt;td&gt;Read the model definition directly&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Fresh database&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Run every migration from the beginning &lt;/td&gt;&lt;td&gt;Auto-generate tables from models&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Branch conflicts&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Two people adding migrations get sequence number conflicts &lt;/td&gt;&lt;td&gt;Each model is independent; different models never conflict&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Cleaning up history&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Squashing migrations is risky and non-trivial &lt;/td&gt;&lt;td&gt;Delete functions once they are deployed; no risk&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Cross-table ordering&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Implicit via file numbering; easy to get wrong &lt;/td&gt;&lt;td&gt;Explicit dependency declarations; engine enforces correct order&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Arbitrary data migrations&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;A migration file like any other &lt;/td&gt;&lt;td&gt;Fake model with a 0→1 migration function&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Schema as documentation&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Migration files are authoritative, model may drift &lt;/td&gt;&lt;td&gt;Model is always authoritative and up to date&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;branch-conflicts&quot;&gt;Branch conflicts&lt;/h4&gt;
&lt;p&gt;In a linear system, two developers working on different features each create the next migration file. One calls it &lt;code&gt;0042_add_tags.sql&lt;/code&gt; and the other calls it &lt;code&gt;0042_add_ratings.sql&lt;/code&gt;. When the branches merge, one of them must be renumbered and every developer must re-run migrations. In Rows, because migrations belong to their model and are identified by version pairs rather than global sequence numbers, two developers working on &lt;code&gt;Article&lt;/code&gt; and &lt;code&gt;Comment&lt;/code&gt; respectively can never conflict with each other.&lt;/p&gt;
&lt;h4 id=&quot;schema-clarity&quot;&gt;Schema clarity&lt;/h4&gt;
&lt;p&gt;With a linear system, the only way to know the current shape of a table is to read every migration that has ever touched it. In Rows, the model class is always the authoritative description of the current schema. Columns, types, nullability, defaults, primary keys, and foreign keys are all declared there. The migration functions are only concerned with the delta between versions, not the full picture.&lt;/p&gt;
&lt;h4 id=&quot;history-hygiene&quot;&gt;History hygiene&lt;/h4&gt;
&lt;p&gt;Linear systems accumulate migrations indefinitely. Squashing them into a single &quot;initial&quot; migration is possible but fragile and infrequently done. In Rows, migration functions for old version ranges can simply be deleted once they have been run in production. The model definition already captures the end state; there is nothing to preserve.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18414/database-migrations-in-rows-orm</guid>
<pubDate>Wed, 20 May 2026 06:11:36 +0000</pubDate>
</item>
<item>
<title>Filtering, sorting, and pagination with the fluent query builder</title>
<link>https://coderlegion.com/15299/filtering-sorting-and-pagination-with-the-fluent-query-builder</link>
<description>&lt;p&gt;So far in this series we have mapped entities, validated them, and performed basic CRUD. But real applications rarely load &lt;em&gt;all&lt;/em&gt; records from a table. You need to search, filter, sort, and paginate.&lt;/p&gt;
&lt;p&gt;Trysil provides a fluent API for this: &lt;strong&gt;&lt;code&gt;TTFilterBuilder&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/strong&gt;. You chain method calls to build a filter, then pass it to &lt;code&gt;Select&amp;lt;T&amp;gt;&lt;/code&gt;. The builder generates parameterized SQL behind the scenes — no string concatenation, no injection risk.&lt;/p&gt;
&lt;h3 id=&quot;basic-filtering&quot;&gt;Basic filtering&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;uses
  Trysil.Filter;

var
  LBuilder: TTFilterBuilder&amp;lt;TTContact&amp;gt;;
  LFilter: TTFilter;
  LContacts: TTList&amp;lt;TTContact&amp;gt;;
begin
  LContacts := TTList&amp;lt;TTContact&amp;gt;.Create;
  try
    LBuilder := LContext.CreateFilterBuilder&amp;lt;TTContact&amp;gt;();
    try
      LFilter := LBuilder
        .Where('Lastname').Equal('Smith')
        .Build;

      LContext.Select&amp;lt;TTContact&amp;gt;(LContacts, LFilter);
    finally
      LBuilder.Free;
    end;

    for LContact in LContacts do
      WriteLn(Format('%s %s', [LContact.Firstname, LContact.Lastname]));
  finally
    LContacts.Free;
  end;
end;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The flow is always the same:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a builder with &lt;code&gt;LContext.CreateFilterBuilder&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Chain conditions with &lt;code&gt;.Where&lt;/code&gt;, &lt;code&gt;.AndWhere&lt;/code&gt;, &lt;code&gt;.OrWhere&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Apply an operator (&lt;code&gt;.Equal&lt;/code&gt;, &lt;code&gt;.Like&lt;/code&gt;, &lt;code&gt;.Greater&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;.Build&lt;/code&gt; to get a &lt;code&gt;TTFilter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Pass the filter to &lt;code&gt;LContext.Select&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;available-operators&quot;&gt;Available operators&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Method &lt;/th&gt;&lt;th&gt;SQL&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.Equal(value)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;= :param&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.NotEqual(value)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;&amp;gt; :param&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.Greater(value)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;gt; :param&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.GreaterOrEqual(value)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;gt;= :param&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.Less(value)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt; :param&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.LessOrEqual(value)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;= :param&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.Like(pattern)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;LIKE :param&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.NotLike(pattern)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;NOT LIKE :param&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.IsNull&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;IS NULL&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.IsNotNull&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;IS NOT NULL&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;combining-conditions&quot;&gt;Combining conditions&lt;/h3&gt;
&lt;p&gt;Use &lt;code&gt;.AndWhere&lt;/code&gt; and &lt;code&gt;.OrWhere&lt;/code&gt; to combine multiple conditions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;LFilter := LBuilder
  .Where('Lastname').Equal('Smith')
  .AndWhere('Email').IsNotNull
  .Build;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;LFilter := LBuilder
  .Where('City').Equal('Rome')
  .OrWhere('City').Equal('Milan')
  .Build;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;pattern-matching-with-like&quot;&gt;Pattern matching with LIKE&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;LFilter := LBuilder
  .Where('Lastname').Like('Sm%')
  .Build;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Standard SQL wildcards apply: &lt;code&gt;%&lt;/code&gt; matches any sequence of characters, &lt;code&gt;_&lt;/code&gt; matches a single character.&lt;/p&gt;
&lt;h3 id=&quot;sorting&quot;&gt;Sorting&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;LFilter := LBuilder
  .Where('Country').Equal('Italy')
  .OrderByAsc('Lastname')
  .Build;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can chain multiple sort clauses:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;LFilter := LBuilder
  .Where('Country').Equal('Italy')
  .OrderByAsc('Lastname')
  .OrderByAsc('Firstname')
  .Build;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For descending order, use &lt;code&gt;.OrderByDesc&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;LFilter := LBuilder
  .OrderByDesc('Price')
  .Build;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note: you can sort without filtering — just skip the &lt;code&gt;.Where&lt;/code&gt; call.&lt;/p&gt;
&lt;h3 id=&quot;pagination&quot;&gt;Pagination&lt;/h3&gt;
&lt;p&gt;For large datasets, load data in pages:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;const
  PageSize = 20;
var
  LPage: Integer;
begin
  LPage := 3; // zero-based page index

  LFilter := LBuilder
    .OrderByAsc('Lastname')
    .Limit(PageSize)
    .Offset(LPage * PageSize)
    .Build;

  LContext.Select&amp;lt;TTContact&amp;gt;(LContacts, LFilter);
end;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.Limit(n)&lt;/code&gt; sets the maximum number of rows to return. &lt;code&gt;.Offset(n)&lt;/code&gt; skips the first n rows. Combined with sorting, this gives you clean, predictable pagination.&lt;/p&gt;
&lt;h3 id=&quot;counting-records&quot;&gt;Counting records&lt;/h3&gt;
&lt;p&gt;Sometimes you need the total count (for example, to display &quot;Page 3 of 12&quot;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;var
  LCount: Integer;
begin
  LFilter := LBuilder
    .Where('Country').Equal('Italy')
    .Build;

  LCount := LContext.SelectCount&amp;lt;TTContact&amp;gt;(LFilter);
end;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SelectCount&amp;lt;T&amp;gt;&lt;/code&gt; returns the number of matching rows without loading the entities into memory.&lt;/p&gt;
&lt;h3 id=&quot;a-complete-example&quot;&gt;A complete example&lt;/h3&gt;
&lt;p&gt;Here is a realistic search function that combines filtering, sorting, pagination, and counting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;procedure TContactService.Search(
  const ASearchText: String;
  const APage: Integer;
  const APageSize: Integer;
  const AContacts: TTList&amp;lt;TTContact&amp;gt;;
  out ATotalCount: Integer);
var
  LBuilder: TTFilterBuilder&amp;lt;TTContact&amp;gt;;
  LFilter: TTFilter;
begin
  LBuilder := FContext.CreateFilterBuilder&amp;lt;TTContact&amp;gt;();
  try
    if not ASearchText.IsEmpty then
      LBuilder
        .Where('Lastname').Like(Format('%s%%', [ASearchText]))
        .OrWhere('Firstname').Like(Format('%s%%', [ASearchText]));

    LBuilder
      .OrderByAsc('Lastname')
      .OrderByAsc('Firstname');

    LFilter := LBuilder.Build;
    ATotalCount := FContext.SelectCount&amp;lt;TTContact&amp;gt;(LFilter);

    LBuilder
      .Limit(APageSize)
      .Offset(APage * APageSize);

    LFilter := LBuilder.Build;
    FContext.Select&amp;lt;TTContact&amp;gt;(AContacts, LFilter);
  finally
    LBuilder.Free;
  end;
end;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-is-next&quot;&gt;What is next&lt;/h3&gt;
&lt;p&gt;We can now search, sort, and paginate our data with a clean fluent API. In the next article we will tackle &lt;strong&gt;relations and lazy loading&lt;/strong&gt; — how to model parent-child relationships and load related entities on demand.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Trysil is open-source and available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/davidlastrucci/Trysil&quot;&gt;GitHub&lt;/a&gt;. Stars and feedback are always appreciated!&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15299/filtering-sorting-and-pagination-with-the-fluent-query-builder</guid>
<pubDate>Mon, 20 Apr 2026 12:51:41 +0000</pubDate>
</item>
<item>
<title>Meet Trysil: a lightweight ORM for Delphi</title>
<link>https://coderlegion.com/15298/meet-trysil-a-lightweight-orm-for-delphi</link>
<description>&lt;p&gt;If you have ever written a Delphi application that talks to a database, you know the routine: write SQL by hand, manage parameters, loop through datasets, and copy values into objects field by field. It works, but it is tedious and error-prone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Trysil&lt;/strong&gt; is an open-source ORM for Delphi that eliminates that boilerplate. You decorate your classes with attributes, and the framework handles the rest — mapping, querying, inserting, updating, deleting. It is lightweight, attribute-driven, and built on top of FireDAC, so it works with SQLite, PostgreSQL, SQL Server, and Firebird out of the box.&lt;/p&gt;
&lt;p&gt;In this first article we will go from zero to a working CRUD application with SQLite.&lt;/p&gt;
&lt;h3 id=&quot;installation&quot;&gt;Installation&lt;/h3&gt;
&lt;p&gt;You can install Trysil in three ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GetIt&lt;/strong&gt; — search for &quot;Trysil&quot; in the Embarcadero GetIt Package Manager&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Boss&lt;/strong&gt; — &lt;code&gt;boss install davidlastrucci/Trysil&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Manual&lt;/strong&gt; — clone the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/davidlastrucci/Trysil&quot;&gt;GitHub repo&lt;/a&gt;, open &lt;code&gt;Packages/&amp;lt;ver&amp;gt;/Trysil.groupproj&lt;/code&gt;, and build all&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After installation, point your project's Search Path to the compiled output directory.&lt;/p&gt;
&lt;h3 id=&quot;your-first-entity&quot;&gt;Your first entity&lt;/h3&gt;
&lt;p&gt;An entity in Trysil is a plain Delphi class with attributes. No base class to inherit, no interface to implement.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;unit Contact.Model;

{$WARN UNKNOWN_CUSTOM_ATTRIBUTE ERROR}

interface

uses
  Trysil.Types,
  Trysil.Attributes,
  Trysil.Validation.Attributes;

type
  [TTable('Contacts')]
  [TSequence('ContactsID')]
  TTContact = class
  strict private
    [TPrimaryKey]
    [TColumn('ID')]
    FID: TTPrimaryKey;

    [TRequired]
    [TMaxLength(50)]
    [TColumn('Firstname')]
    FFirstname: String;

    [TRequired]
    [TMaxLength(50)]
    [TColumn('Lastname')]
    FLastname: String;

    [TMaxLength(255)]
    [TEmail]
    [TColumn('Email')]
    FEmail: String;

    [TColumn('VersionID')]
    [TVersionColumn]
    FVersionID: TTVersion;
  public
    property ID: TTPrimaryKey read FID;
    property Firstname: String read FFirstname write FFirstname;
    property Lastname: String read FLastname write FLastname;
    property Email: String read FEmail write FEmail;
    property VersionID: TTVersion read FVersionID;
  end;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few things to note:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;[TTable('Contacts')]&lt;/code&gt;&lt;/strong&gt; maps the class to the &lt;code&gt;Contacts&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;[TSequence('ContactsID')]&lt;/code&gt;&lt;/strong&gt; tells Trysil how the primary key is generated.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;[TPrimaryKey]&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;[TColumn('...')]&lt;/code&gt;&lt;/strong&gt; mark the primary key and map each field to a column.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;[TVersionColumn]&lt;/code&gt;&lt;/strong&gt; enables optimistic locking — Trysil will check this value on every update to prevent lost writes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;[TRequired]&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;[TMaxLength]&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;[TEmail]&lt;/code&gt;&lt;/strong&gt; are validation attributes — we will cover them in depth in a later article.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;{$WARN UNKNOWN_CUSTOM_ATTRIBUTE ERROR}&lt;/code&gt;&lt;/strong&gt; turns typos in attribute names into compile-time errors. Always add this.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Fields are &lt;code&gt;strict private&lt;/code&gt; with public properties. The ORM accesses them through RTTI.&lt;/p&gt;
&lt;h3 id=&quot;connecting-to-sqlite&quot;&gt;Connecting to SQLite&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;uses
  Trysil.Data,
  Trysil.Data.FireDAC.SQLite,
  Trysil.Data.FireDAC.ConnectionPool,
  Trysil.Context;

var
  LConnection: TTConnection;
  LContext: TTContext;
begin
  // Disable connection pooling (not needed for desktop apps)
  TTFireDACConnectionPool.Instance.Config.Enabled := False;

  // Register and create the connection
  TTSQLiteConnection.RegisterConnection('MyApp', 'contacts.db');
  LConnection := TTSQLiteConnection.Create('MyApp');
  try
    LContext := TTContext.Create(LConnection);
    try
      // ... use the context
    finally
      LContext.Free;
    end;
  finally
    LConnection.Free;
  end;
end;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;TTContext&lt;/code&gt; is the single entry point for all ORM operations. You create it with a connection, and it gives you methods for reading, writing, and querying entities.&lt;/p&gt;
&lt;h3 id=&quot;crud-operations&quot;&gt;CRUD operations&lt;/h3&gt;
&lt;h4 id=&quot;create&quot;&gt;Create&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;var
  LContact: TTContact;
begin
  LContact := LContext.CreateEntity&amp;lt;TTContact&amp;gt;();
  LContact.Firstname := 'Ada';
  LContact.Lastname := 'Lovelace';
  LContact.Email := '*Emails are not allowed*';
  LContext.Insert&amp;lt;TTContact&amp;gt;(LContact);
end;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Always use &lt;code&gt;CreateEntity&amp;lt;T&amp;gt;&lt;/code&gt; to create new entities — it initializes internal state that the context needs to track the object.&lt;/p&gt;
&lt;h4 id=&quot;read&quot;&gt;Read&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;var
  LContacts: TTList&amp;lt;TTContact&amp;gt;;
begin
  LContacts := TTList&amp;lt;TTContact&amp;gt;.Create;
  try
    // Load all contacts
    LContext.SelectAll&amp;lt;TTContact&amp;gt;(LContacts);

    for LContact in LContacts do
      WriteLn(Format('%s %s — %s', [
        LContact.Firstname,
        LContact.Lastname,
        LContact.Email]));
  finally
    LContacts.Free;
  end;
end;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To fetch a single entity by ID:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;var
  LContact: TTContact;
begin
  LContact := LContext.Get&amp;lt;TTContact&amp;gt;(42);
  // raises an exception if not found
end;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;update&quot;&gt;Update&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;LContact.Email := '*Emails are not allowed*';
LContext.Update&amp;lt;TTContact&amp;gt;(LContact);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Behind the scenes, Trysil generates an UPDATE statement that includes the &lt;code&gt;VersionID&lt;/code&gt; in the WHERE clause. If another user has modified the same record in the meantime, the update fails with a concurrency error rather than silently overwriting their changes.&lt;/p&gt;
&lt;h4 id=&quot;delete&quot;&gt;Delete&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-delphi&quot;&gt;LContext.Delete&amp;lt;TTContact&amp;gt;(LContact);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-sql-table&quot;&gt;The SQL table&lt;/h3&gt;
&lt;p&gt;For completeness, here is the SQLite schema that matches our entity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE Contacts (
  ID INTEGER PRIMARY KEY,
  Firstname TEXT NOT NULL,
  Lastname TEXT NOT NULL,
  Email TEXT,
  VersionID INTEGER NOT NULL DEFAULT 1
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-is-next-1&quot;&gt;What is next&lt;/h3&gt;
&lt;p&gt;In this article we defined an entity, connected to SQLite, and performed full CRUD — all without writing a single line of SQL.&lt;/p&gt;
&lt;p&gt;In the next article we will look deeper into &lt;strong&gt;entity mapping&lt;/strong&gt;: nullable fields, custom types, and how Trysil handles the relationship between your Delphi classes and your database schema.&lt;/p&gt;
&lt;p&gt;The full source code is available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/davidlastrucci/Trysil&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Trysil is an open-source project. If you find it useful, consider giving it a star on GitHub!&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15298/meet-trysil-a-lightweight-orm-for-delphi</guid>
<pubDate>Mon, 20 Apr 2026 12:51:09 +0000</pubDate>
</item>
<item>
<title>Why learning to build your own ORM is worth it (even if you use EF Core or Dapper)</title>
<link>https://coderlegion.com/8633/why-learning-to-build-your-own-orm-is-worth-it-even-if-you-use-ef-core-or-dapper</link>
<description>&lt;h3 id=&quot;why-learning-to-build-your-own-orm-is-worth-it-even-if-you-use-ef-core-or-dapper-&quot;&gt;Why learning to build your own ORM is worth it (even if you use EF Core or Dapper)&lt;/h3&gt;
&lt;p&gt;Most developers meet ORMs the same way they meet electricity, you flip a switch and things work. That’s great, until something gets slow, a query behaves oddly, or you hit a transaction or concurrency edge case and you suddenly need to know what’s happening under the hood.&lt;/p&gt;
&lt;p&gt;Building a small ORM, even a deliberately limited one, is one of the fastest ways to close the gap between “I can use EF Core or Dapper” and “I understand data access.”&lt;/p&gt;
&lt;p&gt;To keep it grounded, I’ll anchor this around my tiny demo ORM (MicroOrm), a .NET 8 sample using SQLite, simple POCOs, a lightweight &lt;code&gt;Include&lt;/code&gt; for eager loading, and CancellationToken propagation end to end. (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/stevsharp/MicroOrm&quot; title=&quot;GitHub - stevsharp/MicroOrm&quot;&gt;GitHub&lt;/a&gt;)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-you-stop-treating-the-database-like-a-black-box&quot;&gt;1) You stop treating the database like a black box&lt;/h3&gt;
&lt;p&gt;When you write mapping yourself, you’re forced to answer questions full ORMs often hide:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What SQL is actually executed?&lt;/li&gt;
&lt;li&gt;How are parameters created and bound?&lt;/li&gt;
&lt;li&gt;How do nulls map into value types vs nullable types?&lt;/li&gt;
&lt;li&gt;What happens when schema changes, column names drift, or types don’t match?&lt;/li&gt;
&lt;li&gt;How do you retrieve identities safely after inserts?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is where “I know EF” turns into “I can debug a data issue under pressure.”&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-reflection-becomes-a-tool-not-a-mystery&quot;&gt;2) Reflection becomes a tool, not a mystery&lt;/h3&gt;
&lt;p&gt;A tiny ORM almost always starts with reflection.&lt;/p&gt;
&lt;p&gt;You inspect a type, find its attributes, discover the key property, build a column list, and map rows back into objects. In MicroOrm’s demo, entities like &lt;code&gt;Blog&lt;/code&gt; and &lt;code&gt;Post&lt;/code&gt; use familiar attributes like &lt;code&gt;[Table]&lt;/code&gt;, &lt;code&gt;[Key]&lt;/code&gt;, &lt;code&gt;[ForeignKey]&lt;/code&gt;, and &lt;code&gt;[InverseProperty]&lt;/code&gt;, which is exactly the kind of metadata you typically read via reflection. (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/stevsharp/MicroOrm&quot; title=&quot;GitHub - stevsharp/MicroOrm&quot;&gt;GitHub&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;The payoff is immediate because you hit two truths fast:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reflection is powerful, it removes a ton of repetitive glue code.&lt;/li&gt;
&lt;li&gt;Reflection is not free, so you learn to cache metadata and keep reflection out of hot paths.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those instincts carry straight into EF Core too.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-generics-give-you-type-safety-and-a-clean-api-surface&quot;&gt;3) Generics give you type safety and a clean API surface&lt;/h3&gt;
&lt;p&gt;Generics are the other superpower of a micro ORM.&lt;/p&gt;
&lt;p&gt;They let you write APIs that feel natural in C# and stay strongly typed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GetById&amp;lt;T&amp;gt;(id)&lt;/code&gt; returns a &lt;code&gt;T&lt;/code&gt;, not &lt;code&gt;object&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GetAll&amp;lt;T&amp;gt;()&lt;/code&gt; returns &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt;, not dictionaries&lt;/li&gt;
&lt;li&gt;One set of operations can work across your entities without repetition&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In practice, building a micro ORM becomes a “generics + reflection” exercise done right, compile-time safety on the outside, runtime discovery on the inside.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-you-finally-learn-ado-net-for-real-sqlconnection-sqlcommand-and-friends-&quot;&gt;4) You finally learn ADO.NET for real (SqlConnection, SqlCommand, and friends)&lt;/h3&gt;
&lt;p&gt;EF Core and Dapper sit on top of ADO.NET. When you build your own ORM, you stop skipping the fundamentals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Connections (&lt;code&gt;SqlConnection&lt;/code&gt;, &lt;code&gt;SqliteConnection&lt;/code&gt;, or &lt;code&gt;DbConnection&lt;/code&gt;)&lt;/strong&gt;&lt;br&gt;
You learn what “open late, close early” actually looks like, why &lt;code&gt;using&lt;/code&gt; or &lt;code&gt;await using&lt;/code&gt; matters, and how connection pooling changes the way you should think about lifetime.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Commands (&lt;code&gt;SqlCommand&lt;/code&gt;, &lt;code&gt;DbCommand&lt;/code&gt;)&lt;/strong&gt;&lt;br&gt;
You get comfortable with parameterized queries (and why they’re non negotiable), command timeouts, and how provider differences show up.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Readers (&lt;code&gt;DbDataReader&lt;/code&gt;)&lt;/strong&gt;&lt;br&gt;
You learn what streaming results means, how mapping happens row by row, and where conversions and null handling can bite you.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Transactions (&lt;code&gt;DbTransaction&lt;/code&gt;)&lt;/strong&gt;&lt;br&gt;
You start treating transaction boundaries as a deliberate design choice, not an incidental side effect of the ORM.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MicroOrm’s demo explicitly highlights cancellation support across opening connections and executing commands, which is exactly the sort of detail you only appreciate once you’re directly using these classes. (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/stevsharp/MicroOrm&quot; title=&quot;GitHub - stevsharp/MicroOrm&quot;&gt;GitHub&lt;/a&gt;)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-you-learn-the-real-price-of-convenience-and-why-loading-strategy-matters-&quot;&gt;5) You learn the real price of convenience (and why loading strategy matters)&lt;/h3&gt;
&lt;p&gt;MicroOrm’s &lt;code&gt;Include&lt;/code&gt; supports both references and collections, implemented as extra queries per entity. It’s intentionally simple, and extremely educational because you can feel the tradeoff immediately: clearer code and predictable behavior, plus a very real risk of N+1 query explosions if you’re not careful. (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/stevsharp/MicroOrm&quot; title=&quot;GitHub - stevsharp/MicroOrm&quot;&gt;GitHub&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;After you build even a basic Include, you start spotting inefficient loading patterns everywhere, including in mature ORMs.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-you-learn-portability-the-honest-way&quot;&gt;6) You learn portability the honest way&lt;/h3&gt;
&lt;p&gt;“Database agnostic” always has seams.&lt;/p&gt;
&lt;p&gt;The sample calls it out plainly: switching from SQLite to SQL Server means swapping the provider and adjusting identity retrieval in &lt;code&gt;SqlBuilder.Insert&lt;/code&gt; to use &lt;code&gt;SELECT SCOPE_IDENTITY();&lt;/code&gt;. That single detail teaches a big lesson, database behavior differences are real and they show up fast. (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/stevsharp/MicroOrm&quot; title=&quot;GitHub - stevsharp/MicroOrm&quot;&gt;GitHub&lt;/a&gt;)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-takeaway&quot;&gt;The takeaway&lt;/h3&gt;
&lt;p&gt;Writing a tiny ORM is not about shipping a competitor to EF Core.&lt;/p&gt;
&lt;p&gt;It’s about gaining X-ray vision for what’s underneath, reflection, generics, ADO.NET primitives like &lt;code&gt;SqlConnection&lt;/code&gt; and &lt;code&gt;SqlCommand&lt;/code&gt;, query cost, round trips, and transactions. Then, when you choose EF Core or Dapper, you’re not just using them, you’re using them with intent.&lt;/p&gt;
</description>
<category>C#</category>
<guid isPermaLink="true">https://coderlegion.com/8633/why-learning-to-build-your-own-orm-is-worth-it-even-if-you-use-ef-core-or-dapper</guid>
<pubDate>Fri, 19 Dec 2025 13:53:30 +0000</pubDate>
</item>
</channel>
</rss>