Forget.Oracle
2.4.3
dotnet add package Forget.Oracle --version 2.4.3
NuGet\Install-Package Forget.Oracle -Version 2.4.3
<PackageReference Include="Forget.Oracle" Version="2.4.3" />
<PackageVersion Include="Forget.Oracle" Version="2.4.3" />
<PackageReference Include="Forget.Oracle" />
paket add Forget.Oracle --version 2.4.3
#r "nuget: Forget.Oracle, 2.4.3"
#:package Forget.Oracle@2.4.3
#addin nuget:?package=Forget.Oracle&version=2.4.3
#tool nuget:?package=Forget.Oracle&version=2.4.3
Forget
A thin, provider-aware extension layer on top of Dapper: type-safe CRUD (single-row or multi-row), filtering, sorting, and paging against one mapped table at a time — with SQL generated correctly for the engine you're actually running against, not a lowest-common-denominator translation. Supports MySQL, Oracle, PostgreSQL, and SQL Server.
It sits between raw Dapper and a full ORM: you still map plain classes to tables with a handful of attributes, but
you can forget hand-writing (and hand-maintaining, across 4 dialects) the same SELECT/UPDATE/INSERT/MERGE
boilerplate for every entity.
Why
| Dapper | Forget | EF Core | |
|---|---|---|---|
| Single-row CRUD | You write the SQL | Generated, one line per call | Generated |
| Multi-row update/insert/delete/upsert | You write the SQL | ✅ Generated as multi-row statements, one per batch of a size you choose, all in one transaction | Generated, but inconsistently: one statement per row or one multi-row statement, depending on provider and operation |
| Filter built from a compile-time LINQ expression | You write the SQL | ✅ Expression<Func<T, bool>> |
✅ |
Filter built dynamically at runtime (e.g. from a query string, with no Expression in sight) |
You write the SQL | ✅ FilterDescriptor/FilterGroup, by property name |
Needs Expression.Lambda plumbing or a package like System.Linq.Dynamic.Core |
| Upsert on a natural (non-identity) key | You write the SQL | ✅ [UpsertKey], one call across all 4 providers |
Needs manual logic or a third-party package |
| Multi-table joins / result-set mapping to several types | ✅ splitOn |
❌ (see What this isn't) | ✅ |
| Change tracking, migrations, lazy loading | ❌ | ❌ | ✅ |
If you need joins across unrelated entities, keep using Dapper directly for that one query — Forget doesn't replace it, it removes the repetitive single-table code around it.
Install
Pick the package for your database; each one pulls in Forget.Core and the underlying ADO.NET driver
automatically.
| Database | Package | Connection type |
|---|---|---|
| MySQL | Forget.MySql |
MySqlConnection |
| Oracle | Forget.Oracle |
OracleConnection |
| PostgreSQL | Forget.PostgreSql |
NpgsqlConnection |
| SQL Server | Forget.SqlServer |
SqlConnection |
dotnet add package Forget.SqlServer
Quickstart
Map an entity with plain data-annotation attributes:
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
// UpsertKeyAttribute, and later FilterDescriptor/FilterGroup/ComparisonOperator/SortDescriptor
using Forget.Core.Models;
[Table("Products")]
public class Product
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[UpsertKey]
public string Sku { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public decimal Price { get; set; }
public bool IsActive { get; set; }
}
Then use the connection extension methods — every method below exists identically (same name, same overloads) on
all 4 providers, and each has both a synchronous and an asynchronous overload (GetAll/GetAllAsync,
Insert/InsertAsync, and so on); only the using and the connection type change.
[UpsertKey] (on Sku above) makes Upsert/UpsertAsync match rows by that column instead of the identity
column — see Upsert on a natural key
below for what actually runs:
using Forget.SqlServer.Extensions;
using Microsoft.Data.SqlClient;
await using SqlConnection connection = new(connectionString);
IReadOnlyList<Product> all = await connection.GetAllAsync<Product>();
// Every method also has a synchronous overload with the same name minus "Async" - the rest of this example
// sticks to async, but this one works identically: IReadOnlyList<Product> all = connection.GetAll<Product>();
Product? product = await connection.GetByIdAsync<Product>(id: 42);
await connection.InsertAsync(new Product { Sku = "SKU-1", Name = "Widget", Category = "Tools", Price = 9.99m, IsActive = true });
await connection.UpdateAsync(product!);
await connection.UpsertAsync(new Product { Sku = "SKU-1", Name = "Widget", Category = "Tools", Price = 12.50m, IsActive = true });
await connection.DeleteAsync<Product>(id: 42);
Filtering, two ways
A predicate you can write at compile time works exactly like you'd expect:
IReadOnlyList<Product> cheapTools = await connection.GetAllAsync<Product>(
p => p.Category == "Tools" && p.Price < 20 && p.IsActive);
But a search endpoint rarely knows its filters at compile time — they come from a query string, a search form,
an API request body.
Raw Dapper gives you nothing here beyond string concatenation, and EF Core needs you to build Expression trees
by hand (or add System.Linq.Dynamic.Core).
Forget's FilterDescriptor/FilterGroup build the exact same kind of filter from plain data — a property
name, not a property selector — so it composes cleanly from untyped input:
// PropertyName and Value could come straight from Request.Query, validated against Product's real properties.
FilterGroup<Product> filter = new(
[
new FilterDescriptor<Product>("Category", "Tools"),
new FilterDescriptor<Product>("Price", 20m, ComparisonOperator.LessThan),
new FilterDescriptor<Product>("Name", "wid", ComparisonOperator.Contains, ignoreCase: true)
]);
IReadOnlyList<Product> results = await connection.GetAllAsync(filter);
GetAll, GetFirst, GetSingle, GetPage, Update, Delete, Exists, Count, Avg, Sum, Min, and Max
(and their respective asynchronous overloads) all accept either shape — pick whichever fits the call site, they
translate to the same SQL.
What a predicate can contain
A predicate is translated to SQL, not run, so it can only contain what has a SQL equivalent. Anything else throws a
NotSupportedException that names the expression, before any query is sent.
| Supported | |
|---|---|
| Comparisons | ==, !=, <, <=, >, >= between a column and a value (on either side, 5 < p.Id) or between two columns, combined with &&, \|\| and ! |
| The value | a literal, a variable, a field, a property or a member of one of them (request.Filter.Category), a constant, a static property such as DateTime.UtcNow; of any type the driver can bind (Guid, DateTime, enum, ...) |
null |
p.Note == null, p.Note != null, and a variable that is null |
| Nullable columns | p.Deleted.HasValue, !p.Deleted.HasValue, p.Deleted.HasValue == false, p.Deleted.Value > since |
| Booleans | p.IsActive, !p.IsActive, p.IsActive == flag |
| Text | ==, Equals (also with StringComparison.OrdinalIgnoreCase), string.Compare(a, b) < 0, Contains, StartsWith and EndsWith (also with a StringComparison), ToLower(), ToUpper(), ToString(); the argument of Contains, StartsWith and EndsWith must be a value, not a column |
| Collections | ids.Contains(p.Id) and !ids.Contains(p.Id) on an array, a List<T>, a HashSet<T> or any IEnumerable<T> (translated to IN and NOT IN, or to = ANY on PostgreSQL) |
What is not translated: arithmetic (p.Price * 2, x + 1, -x), other members of a string (Length, Trim(),
Substring(), string.IsNullOrEmpty, concatenation with +), Math, parts of a date (p.Created.Year), enum flags
(HasFlag, &), GetValueOrDefault(), Equals on anything but a string, LINQ (Any), and new, ?:, ??, is,
as, a method call or an indexer used as a value. Compute the value in a variable first
(DateTime since = DateTime.UtcNow.AddDays(-1);) and compare with that. The negation of a variable in a comparison,
p.IsActive == !flag, is sent as IsActive = NOT @p_0, which only PostgreSQL accepts: write bool wanted = !flag; first.
A cast is not applied: p.Id == (int)value sends value with its own type, because Forget never converts one type
into another.
Sorting and paging
IReadOnlyList<Product> page = await connection.GetPageAsync<Product>(
predicate: p => p.IsActive,
sortDescriptors: [new SortDescriptor<Product>(p => p.Price, SortDirection.Descending)],
skip: 20,
take: 10);
Correctness details worth knowing about
Every provider is designed individually at the query-generation level — the same C# call can, and usually does, produce a genuinely different statement depending on which engine it runs against. What follows is a handful of concrete examples of that, not an exhaustive list of every place the 4 providers diverge.
A few smaller, mostly mechanical ones:
- MySQL escapes
LIKEpatterns (forContains/StartsWith/EndsWith) accounting for MySQL's own C-style backslash escaping inside string literals — different from the other three engines, and easy to get wrong with one sharedESCAPEclause. - PostgreSQL translates
Enumerable.Contains/ComparisonOperator.Into= ANY(@p_0_)when the collection is homogeneously typed (Npgsql needs a genuinely typed array for that). A list of mixed types is written out asIN (@p_0_0, @p_0_1, ...), one parameter per element, as Dapper expands a list on the other three providers; on Npgsql Dapper does not, because it binds a list as one array.
And a few bigger ones:
Upsert on a natural key: one call, a different correct statement per engine
await connection.UpsertAsync(new Product { Sku = "SKU-1", Name = "Widget", Category = "Tools", Price = 12.50m, IsActive = true });
What actually runs is deliberately not the same statement re-parameterized four times:
- MySQL:
INSERT INTO ... VALUES (...) AS new ON DUPLICATE KEY UPDATE col = new.col, ... - Oracle:
MERGE INTO ... USING (SELECT ... FROM DUAL) SOURCE ON (...) WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT ...(aMERGEis not atomic against a concurrent insert of the same key: if two sessions upsert a key that doesn't exist yet at the same moment, one of them can fail withORA-00001. Forget doesn't retry — handle that error and call again; the row exists by then, and it is updated. It never leaves a duplicate row.) - PostgreSQL:
INSERT INTO ... VALUES (...) ON CONFLICT (...) DO UPDATE SET col = EXCLUDED.col, ... - SQL Server: not
MERGE— a documented, well-known source of concurrency correctness bugs. Instead:UPDATE ... WITH (UPDLOCK, HOLDLOCK) SET ... WHERE ...followed by a conditionalINSERTguarded byIF @@ROWCOUNT = 0, wrapped in a transaction Forget manages for you if you don't supply one.
Each engine gets the statement that is right for it, rather than one statement that parses everywhere but is slow or unsafe under concurrency on at least one of them.
Upserts from many connections at once, on the same new keys, are covered by tests on all 4 providers. Single-row upserts succeeded for every caller on MySQL, PostgreSQL and SQL Server, and so did multi-row upserts on MySQL and PostgreSQL. Two cases can fail, and in both Forget doesn't retry — handle the error and call again:
- Oracle:
ORA-00001, as described above, and a multi-row upsert can also be chosen as the victim of a deadlock (ORA-00060). - SQL Server: a multi-row upsert of overlapping keys can be chosen as the victim of a deadlock (error 1205).
No key ever ends up with two rows.
Avg, on every provider: read as text, converted to decimal in C#
Database numeric types can carry more precision than .NET's decimal can hold: MySQL's DECIMAL goes up to 65
digits, Oracle's NUMBER up to 38 significant digits, PostgreSQL's NUMERIC is effectively unbounded, and SQL
Server's AVG on a decimal(p,s) column returns decimal(38,s) — while System.Decimal only holds about
28-29.
Averaging routinely produces exactly this kind of long, non-terminating result, and reading it as a native
decimal scalar risks the ADO.NET driver itself failing before Forget ever gets a say.
So on all 4 providers, the database returns the average as text instead of a raw decimal, and Avg/AvgAsync
then converts that text into a decimal in C# with its own parser: precision beyond what decimal can represent
is rounded off, and only a magnitude that genuinely doesn't fit throws OverflowException.
Sum/SumAsync doesn't go through any of this — summing a column doesn't introduce precision beyond what it
already had, so it reads the native decimal scalar directly.
The exact SQL each provider uses to produce that text is visible via AvgCommand, if you want to see it.
Oracle: multi-row writes that work on every release
Multi-row VALUES (...), (...) (the table value constructor) only arrived with Oracle 23ai, and many production
databases still run older releases. So Forget uses the backward-compatible form that Oracle has always supported:
INSERT INTO "Products" (
"Sku",
"Name",
"Category",
"Price",
"IsActive"
)
SELECT :Sku_0 AS "Sku", :Name_0 AS "Name", :Category_0 AS "Category", :Price_0 AS "Price", :IsActive_0 AS "IsActive" FROM DUAL
UNION ALL
SELECT :Sku_1 AS "Sku", :Name_1 AS "Name", :Category_1 AS "Category", :Price_1 AS "Price", :IsActive_1 AS "IsActive" FROM DUAL
Written like this, though, it runs into ORA-01790 ("expression must have same datatype as corresponding
expression") the moment two rows disagree on the inferred type of a bound parameter in the same SELECT position —
which happens constantly, since a NULL or a numeric literal in one row and a string in another are common in real
data.
This isn't only an InsertRange/InsertRangeAsync problem: UpdateRange/UpdateRangeAsync and
UpsertRange/UpsertRangeAsync build on the exact same UNION ALL/DUAL block as their source rowset
(UpdateRange/UpdateRangeAsync feeds it into a MERGE with only a WHEN MATCHED branch;
UpsertRange/UpsertRangeAsync into a full MERGE with both branches) — so all three needed solving, not just
one.
Forget generates this shape automatically, but with every value cast to its column's real type first:
INSERT INTO "Products" (
"Sku",
"Name",
"Category",
"Price",
"IsActive"
)
SELECT CAST(:Sku_0 AS VARCHAR2(50)) AS "Sku", CAST(:Name_0 AS VARCHAR2(100)) AS "Name", CAST(:Category_0 AS VARCHAR2(50)) AS "Category", CAST(:Price_0 AS NUMBER(18,2)) AS "Price", CAST(:IsActive_0 AS NUMBER(1,0)) AS "IsActive" FROM DUAL
UNION ALL
SELECT CAST(:Sku_1 AS VARCHAR2(50)) AS "Sku", CAST(:Name_1 AS VARCHAR2(100)) AS "Name", CAST(:Category_1 AS VARCHAR2(50)) AS "Category", CAST(:Price_1 AS NUMBER(18,2)) AS "Price", CAST(:IsActive_1 AS NUMBER(1,0)) AS "IsActive" FROM DUAL
The cast expression for each column isn't hard-coded — on the first call for a given entity and connection,
Forget queries Oracle's data dictionary (ALL_TAB_COLUMNS) for that table, and for each column probes,
via EXECUTE IMMEDIATE against a set of candidate type strings (precision+scale, precision-only, length-only,
bare type name, and a TO_<type>(...) conversion-function fallback), which cast expression the server actually
accepts for that column right now — then caches the result.
This means it stays correct across Oracle versions and unusual column type definitions without the library
hard-coding assumptions about either.
await connection.InsertRangeAsync(products, batchSize: 500);
The Product above has one more problem on Oracle before 23ai: those releases have no boolean type in SQL, and
ODP.NET binds a C# bool as one, so writing IsActive fails with ORA-00932 ("NUMBER expected, got BOOLEAN").
Forget passes every value to Dapper as it is, so a Dapper type handler, registered once at startup, fixes it for every
statement Forget sends:
using System.Data;
using Dapper;
public sealed class BooleanAsNumberHandler : SqlMapper.TypeHandler<bool>
{
public override void SetValue(IDbDataParameter parameter, bool value)
{
parameter.DbType = DbType.Int16;
parameter.Value = value ? 1 : 0;
}
public override bool Parse(object value) => Convert.ToInt32(value) != 0;
}
// Dapper maps bool itself, and only asks a handler about types it doesn't map
SqlMapper.RemoveTypeMap(typeof(bool));
SqlMapper.AddTypeHandler(new BooleanAsNumberHandler());
The column stays a NUMBER(1). Inserts, updates, upserts and filters such as p.IsActive == flag now bind 1 or 0, and
reads turn the number back into a bool. The handler applies to every Dapper call in the process, so register it only
where the application talks to such an Oracle; on 23ai and later it isn't needed.
SQL Server: safe aggregation, and one place we didn't intervene
Summing an int/smallint/tinyint column over enough rows can overflow that type's range, so Sum/Avg cast
it to BIGINT first — the same value, just in a container wide enough not to overflow.
That cast doesn't (and can't, without inventing an arbitrary precision) give Avg fractional precision, though:
T-SQL's own AVG returns the same exact numeric type as its input, so averaging an integer-typed column still
returns a truncated whole number — AVG of 1, 2, 4 is 2, not 2.33, identical to what a hand-written
AVG(column) query returns.
We considered "fixing" this and decided not to. A translation bug, like an extra escape character that silently breaks
a search, has one correct answer. Here there is none: any fractional precision would be an arbitrary choice, and it
isn't Forget's to make.
Because it fails silently, with a plausible number and no exception, this behavior is documented on every
Avg/AvgAsync/AvgCommand overload and covered by a regression test.
Entity mapping attributes
| Attribute | Effect |
|---|---|
[Key] |
Marks the identifier property. Only one property can have it. If omitted, a property named Id is used. |
[UpsertKey] |
Marks one or more properties as the natural key used to match rows on upsert. Multiple properties form a composite key. Without it, upsert matches on the [Key]/Id property. |
[Table("...", Schema = "...")] |
Sets the table name and, optionally, its schema (without the attribute: the class name, no schema; see below). |
[Column("...")] |
Overrides the SQL column name for a property. |
[NotMapped] |
Excludes a property entirely. |
[DatabaseGenerated(...)] |
A property with Identity or Computed is never written by the library, only read back. |
What the entity maps, and what Forget leaves alone
- The entity maps the columns it declares. The table can have more columns than the entity (audit or legacy columns, say): those are never selected and never written, so a database default applies on insert. A mapped property whose column does not exist in the table fails with a message naming the property and the column.
- Names are used exactly as written. Forget quotes every table, schema and column name (
`...`on MySQL,"..."on Oracle and PostgreSQL,[...]on SQL Server) and doubles the closing quote character inside a name. So any name the database accepts works, spaces, accented letters,#,$and hyphens included, and on Oracle and PostgreSQL the case has to match the table's (a table created asproductsis not"Products"). Two kinds of name are refused the first time the entity is used, because they would be rewritten before the database reads the SQL: one with a curly brace, which Forget's SQL templates and Dapper use as markers, and one containing what Dapper takes for a parameter, such as@p_1_or?Name?. A table created without quotes, such asCREATE TABLE products (name ...), is stored in uppercase on Oracle and in lowercase on PostgreSQL, so map it with the names as stored:[Table("PRODUCTS")] public class Product { ... [Column("NAME")] public string Name { get; set; } = string.Empty; ... } - Without a schema, the database finds the table. An entity with a schema, such as
[Table("Products", Schema = "sales")], is always qualified with that schema. Without one, the table is left unqualified, so each connection gets the table its own session resolves, as with hand-written SQL: the connection's database on MySQL, the current schema on Oracle (the user's, or the one set withALTER SESSION SET CURRENT_SCHEMA), thesearch_pathon PostgreSQL, the user's default schema (thendbo) on SQL Server. One entity works with a database or a schema per tenant. - Types are Dapper's. Forget does not map or convert types. Any type Dapper can bind, or that you register a
SqlMapper.TypeHandlerfor, works the same way through Forget (the Oracle section above registers one that bindsboolas a number). - Values are checked, not converted. An id or a filter value (a comparison), and a value you set with
Update/UpdateAsyncand an object such asnew { Price = 10 }(a write), reach Dapper as you passed them. Forget rejects one with anArgumentExceptionwhen its type could lose precision or change meaning for the property, or when the drivers cannot bind it. The table below says what is accepted. The one exception to "not converted" is a list of bytes, or of enum values whose underlying type is byte (as ids, in anInfilter or inContains): it is bound as a list ofshort, because every driver binds abyte[]as one binary value. Ashortholds every byte, so the values compared are the same. - Column metadata is read once. A few operations need the table's column types — the multi-row writes on Oracle,
UpdateRange/UpdateRangeAsyncon PostgreSQL, andSum/SumAsyncandAvg/AvgAsyncon SQL Server. Forget reads them the first time it needs them, or when you callLoadDbCache/LoadDbCacheAsyncyourself, and keeps them until the process ends, per entity and per database. A name without a schema can mean another table for another user on Oracle, anothersearch_pathon PostgreSQL or another user on SQL Server, so each of those gets its own copy too. Nothing refreshes them: after altering a column, restart the process.
| Value type | Comparison (id, filter) | Write (Update) |
|---|---|---|
| The property's own type | accepted | accepted |
byte, short, int, long for an integer property |
accepted, in any width (GetById(1L) on an int key) |
only into a wider property (int for a long; a long for an int is rejected, cast it yourself) |
byte, short, int, long for a decimal |
accepted | accepted |
byte, short, int for a double; byte, short for a float |
accepted | accepted |
| An enum for its underlying type, and the other way round | accepted | accepted |
string for a number, int or long for a float, long for a double, decimal or double for an integer, a float for a double (1.1f is not 1.1) |
rejected | rejected |
sbyte, ushort, uint, ulong for a property of another type |
rejected (Oracle, PostgreSQL and SQL Server cannot bind them) | rejected |
A list of bytes, or of enum values whose underlying type is byte (as ids, in an In filter or in Contains) |
accepted, bound as a list of short (every driver binds a byte[] as one binary value) |
not applicable |
A single byte[] as the value of an In filter on a byte[] property |
rejected: it is one value, use Equal or a list of byte arrays |
not applicable |
The Write column is for Update/UpdateAsync with a values object such as new { Price = 10 }; Update with an
entity is typed by the entity, so there is nothing to check.
A comparison never writes anything, so a value too large for the column simply finds no row. A write with a value
that does not fit is an error at best: MySQL without strict mode clips it, Oracle can store it and then fail to read
the row back into an int, and PostgreSQL and SQL Server reject it. Hence the stricter rule.
What this isn't
Forget is deliberately single-table: there is no multi-mapping, no splitOn, no join-building across
unrelated entities.
Every generated statement targets exactly one table.
For a query that spans several tables, drop down to Dapper directly on the same connection — Forget doesn't
try to replace it, it exists so that most of your single-table data access code doesn't need to be Dapper's raw
SQL either.
It also doesn't do change tracking, migrations, or lazy loading — if you need those, you likely want EF Core.
Performance notes
- Per-entity reflection (property/attribute scanning) happens once per
TEntity, on first use, guarded by the CLR's own thread-safe static-constructor initialization — not on every call. - Property access uses compiled expression-tree getters, not
PropertyInfo.GetValuereflection. - Generated SQL is cached as a template per
(entity type, provider)pair and rendered by substituting only the parts that vary per call (the filter, the sort, the parameter values) — not rebuilt from scratch every time.
See benchmarks/README.md for the full numbers against hand-written Dapper and EF Core, and The Batch Size Bug for the story behind some of them.
Inspecting generated SQL without running it
Every execution method (GetAll/GetAllAsync, Upsert/UpsertAsync, InsertRange/InsertRangeAsync, ...) has
a matching *Command/*Commands method that builds and returns the SQL text and parameters without executing
them — useful for logging, testing, or executing manually:
DbCommandInfo command = connection.UpsertCommand(product);
// command.Sql, command.Parameters
Versioning
This project follows Semantic Versioning: breaking changes bump the major version, backwards-compatible additions bump the minor version, and fixes bump the patch version. See CHANGELOG.md for release history.
License
Apache-2.0 — see LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Forget.Core (>= 2.4.3)
- Oracle.ManagedDataAccess.Core (>= 23.26.400)
- System.Security.Cryptography.Pkcs (>= 8.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2.4.3 | 0 | 10/24/2026 |
| 2.4.2 | 34 | 10/23/2026 |
| 2.4.1 | 36 | 10/23/2026 |
| 2.4.0 | 37 | 10/23/2026 |
| 2.3.5 | 65 | 10/21/2026 |
| 2.3.4 | 70 | 10/21/2026 |
| 2.3.3 | 88 | 10/19/2026 |
| 2.3.2 | 84 | 10/10/2026 |
| 2.3.1 | 86 | 10/7/2026 |
| 2.3.0 | 100 | 9/27/2026 |
| 2.2.0 | 97 | 9/21/2026 |
| 2.1.1 | 103 | 9/21/2026 |
| 2.1.0 | 102 | 9/20/2026 |
| 2.0.0 | 108 | 9/20/2026 |
| 1.0.3 | 110 | 9/19/2026 |
| 1.0.2 | 100 | 9/18/2026 |
| 1.0.1 | 98 | 9/18/2026 |
| 1.0.0 | 94 | 9/18/2026 |