diff --git a/README.md b/README.md index 98addc9..9e7cb94 100644 --- a/README.md +++ b/README.md @@ -1 +1,278 @@ -# Squealify \ No newline at end of file +# Squealify + +Squealify is a lightweight, compile-time micro-ORM for .NET that generates database query methods based on schema classes. It leverages C# source generators to create strongly-typed, efficient, and maintainable database access code. + +## Features + +- **Compile-Time Query Generation**: Automatically generates database query methods (e.g., `Create`, `Insert`, `Upsert`, `Update`, `Delete`, `Find`) based on your schema class. +- **Schema-Driven**: Define your database schema using attributes like `[Table]`, `[PrimaryKey]`, and `[ColumnUnique]`. +- **Nullable support**: Supports nullable properties and types, allowing for flexible schema definitions. Define properties as nullable in your schema and Squealify will generate nullable columns. +- **Type Safety**: Strongly-typed query methods ensure compile-time safety. +- **Custom Conversions**: Supports custom type conversions for complex types. Automatically detects complex types and forces you to implement the conversion on compilation. +- **Minimal Boilerplate**: Focus on your schema, and let Squealify handle the rest. + +## Installation + +Add Squealify to your project via NuGet: + +`dotnet add package Squealify` + +## Getting Started + +### Step 1: Define Your Schema Class + +Define a class that represents your database table. Use attributes to specify table and column metadata. + +```csharp +[Table("accounts")] +[TableUnique(nameof(Name), nameof(Id))] +public sealed class AccountDBO +{ + [PrimaryKey] + public required AccountIdentifier Id { get; init; } + + [ForeignKey("secondaryTable", "id")] + public required Identifier Secondary { get; init; } + + [ColumnUnique] + public required string Name { get; init; } + + [ColumnName("password")] + public string? PasswordHash { get; set; } + public string? Email { get; set; } + public DateTimeOffset? EmailVerifiedTime { get; set; } + + public AccountType? State { get; set; } + + public DateTimeOffset? CreatedTime { get; set; } + public DateTimeOffset? LastLoginTime { get; set; } +} +``` + +### Step 2: Squealify generates your db context +```csharp +public abstract class AccountDBOTableContextBase +{ + public AccountDBOTableContextBase(DbConnection connection) + { + this.Connection = connection; + } + + protected DbConnection Connection { get; } + protected abstract Func AccountIdentifierToStringConverter { get; } + protected abstract Func StringToAccountIdentifierConverter { get; } + protected abstract Func IdentifierToStringConverter { get; } + protected abstract Func StringToIdentifierConverter { get; } + + public async ValueTask CreateTable(CancellationToken cancellationToken) + { + using var command = this.Connection.CreateCommand(); + command.CommandText = @" + CREATE TABLE accounts ( + Id TEXT PRIMARY KEY, + Secondary TEXT NOT NULL REFERENCES secondaryTable(id), + Name TEXT UNIQUE NOT NULL, + password TEXT, + Email TEXT, + EmailVerifiedTime TIMESTAMP, + State INTEGER, + CreatedTime TIMESTAMP, + LastLoginTime TIMESTAMP, + UNIQUE (Name, Id)); +"; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async ValueTask CreateTableIfNotExists(CancellationToken cancellationToken) + { + using var command = this.Connection.CreateCommand(); + command.CommandText = @" + CREATE TABLE IF NOT EXISTS accounts ( + Id TEXT PRIMARY KEY, + Secondary TEXT NOT NULL REFERENCES secondaryTable(id), + Name TEXT UNIQUE NOT NULL, + password TEXT, + Email TEXT, + EmailVerifiedTime TIMESTAMP, + State INTEGER, + CreatedTime TIMESTAMP, + LastLoginTime TIMESTAMP, + UNIQUE (Name, Id)); +"; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async ValueTask Insert(AccountDBO dbo, CancellationToken cancellationToken) + { + using var command = this.Connection.CreateCommand(); + command.CommandText = @" + INSERT INTO accounts + (Id, Secondary, Name, password, Email, EmailVerifiedTime, State, CreatedTime, LastLoginTime) + VALUES + (@Id, @Secondary, @Name, @password, @Email, @EmailVerifiedTime, @State, @CreatedTime, @LastLoginTime) +"; + command.Parameters.Add(this.CreateParameter(command, "@Id", this.AccountIdentifierToStringConverter(dbo.Id))); + command.Parameters.Add(this.CreateParameter(command, "@Secondary", this.IdentifierToStringConverter(dbo.Secondary))); + command.Parameters.Add(this.CreateParameter(command, "@Name", dbo.Name)); + command.Parameters.Add(this.CreateParameter(command, "@password", dbo.PasswordHash)); + command.Parameters.Add(this.CreateParameter(command, "@Email", dbo.Email)); + command.Parameters.Add(this.CreateParameter(command, "@EmailVerifiedTime", dbo.EmailVerifiedTime)); + command.Parameters.Add(this.CreateParameter(command, "@State", dbo.State)); + command.Parameters.Add(this.CreateParameter(command, "@CreatedTime", dbo.CreatedTime)); + command.Parameters.Add(this.CreateParameter(command, "@LastLoginTime", dbo.LastLoginTime)); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async ValueTask Upsert(AccountDBO dbo, CancellationToken cancellationToken) + { + using var command = this.Connection.CreateCommand(); + command.CommandText = @" + INSERT INTO accounts + (Id, Secondary, Name, password, Email, EmailVerifiedTime, State, CreatedTime, LastLoginTime) + VALUES + (@Id, @Secondary, @Name, @password, @Email, @EmailVerifiedTime, @State, @CreatedTime, @LastLoginTime) + ON CONFLICT(Id) DO UPDATE SET + Secondary = excluded.Secondary, + Name = excluded.Name, + password = excluded.password, + Email = excluded.Email, + EmailVerifiedTime = excluded.EmailVerifiedTime, + State = excluded.State, + CreatedTime = excluded.CreatedTime, + LastLoginTime = excluded.LastLoginTime; +"; + command.Parameters.Add(this.CreateParameter(command, "@Id", this.AccountIdentifierToStringConverter(dbo.Id))); + command.Parameters.Add(this.CreateParameter(command, "@Secondary", this.IdentifierToStringConverter(dbo.Secondary))); + command.Parameters.Add(this.CreateParameter(command, "@Name", dbo.Name)); + command.Parameters.Add(this.CreateParameter(command, "@password", dbo.PasswordHash)); + command.Parameters.Add(this.CreateParameter(command, "@Email", dbo.Email)); + command.Parameters.Add(this.CreateParameter(command, "@EmailVerifiedTime", dbo.EmailVerifiedTime)); + command.Parameters.Add(this.CreateParameter(command, "@State", dbo.State)); + command.Parameters.Add(this.CreateParameter(command, "@CreatedTime", dbo.CreatedTime)); + command.Parameters.Add(this.CreateParameter(command, "@LastLoginTime", dbo.LastLoginTime)); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async ValueTask Update(AccountDBO dbo, CancellationToken cancellationToken) + { + using var command = this.Connection.CreateCommand(); + command.CommandText = @" + UPDATE accounts + SET + Secondary = @Secondary, + Name = @Name, + password = @password, + Email = @Email, + EmailVerifiedTime = @EmailVerifiedTime, + State = @State, + CreatedTime = @CreatedTime, + LastLoginTime = @LastLoginTime + WHERE + Id = @Id; +"; + command.Parameters.Add(this.CreateParameter(command, "@Id", this.AccountIdentifierToStringConverter(dbo.Id))); + command.Parameters.Add(this.CreateParameter(command, "@Secondary", this.IdentifierToStringConverter(dbo.Secondary))); + command.Parameters.Add(this.CreateParameter(command, "@Name", dbo.Name)); + command.Parameters.Add(this.CreateParameter(command, "@password", dbo.PasswordHash)); + command.Parameters.Add(this.CreateParameter(command, "@Email", dbo.Email)); + command.Parameters.Add(this.CreateParameter(command, "@EmailVerifiedTime", dbo.EmailVerifiedTime)); + command.Parameters.Add(this.CreateParameter(command, "@State", dbo.State)); + command.Parameters.Add(this.CreateParameter(command, "@CreatedTime", dbo.CreatedTime)); + command.Parameters.Add(this.CreateParameter(command, "@LastLoginTime", dbo.LastLoginTime)); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async ValueTask Delete(AccountIdentifier primaryKey, CancellationToken cancellationToken) + { + using var command = this.Connection.CreateCommand(); + command.CommandText = @" + DELETE FROM accounts + WHERE Id = @Id; +"; + command.Parameters.Add(this.CreateParameter(command, "@Id", this.AccountIdentifierToStringConverter(primaryKey))); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async ValueTask Find(AccountIdentifier primaryKey, CancellationToken cancellationToken) + { + using var command = this.Connection.CreateCommand(); + command.CommandText = @" + SELECT * FROM accounts + WHERE Id = @Id; +"; + command.Parameters.Add(this.CreateParameter(command, "@Id", this.AccountIdentifierToStringConverter(primaryKey))); + using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + return default; + return new AccountDBO + { + Id = this.StringToAccountIdentifierConverter(reader.GetString(0)) , + Secondary = this.StringToIdentifierConverter(reader.GetString(1)) , + Name = reader.GetString(2) , + PasswordHash = await reader.IsDBNullAsync(3, cancellationToken) ? default : reader.GetString(3) , + Email = await reader.IsDBNullAsync(4, cancellationToken) ? default : reader.GetString(4) , + EmailVerifiedTime = await reader.IsDBNullAsync(5, cancellationToken) ? default : (DateTimeOffset? )reader.GetDateTime(5) , + State = await reader.IsDBNullAsync(6, cancellationToken) ? default : (AccountType? )reader.GetInt32(6) , + CreatedTime = await reader.IsDBNullAsync(7, cancellationToken) ? default : (DateTimeOffset? )reader.GetDateTime(7) , + LastLoginTime = await reader.IsDBNullAsync(8, cancellationToken) ? default : (DateTimeOffset? )reader.GetDateTime(8) } ; + } + + protected DbParameter CreateParameter(DbCommand command, string name, object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + return parameter; + } +} +``` + + +### Step 3: Implement the Context + +Create an implementation of the generated context and provide any required custom type converters. +Squealify generates basic queries and statements based on your schema. + +```csharp +public sealed class AccountsContext(DbConnection connection) : AccountDBOTableContextBase(connection) +{ + protected override Func AccountIdentifierToStringConverter { get; } = (identifier) => identifier.ToString(); + protected override Func StringToAccountIdentifierConverter { get; } = Identifier.ParseIdentifier; + protected override Func IdentifierToStringConverter { get; } = (identifier) => identifier.ToString(); + protected override Func StringToIdentifierConverter { get; } = Identifier.ParseIdentifier; +} +``` + + +### Step 4: Use the Context + +Use the context in your application to interact with the database. + +```csharp +var accountsContext = scope.ServiceProvider.GetRequiredService(); +await accountsContext.CreateTableIfNotExists(cancellationToken); +await accountsContext.Upsert(new Models.AccountDBO { Id = Identifier.Create(), Secondary = Identifier.Create(), Name = "Test", PasswordHash = "1234" }, cancellationToken); +await accountsContext.Find(Identifier.ParseIdentifier("1234"), cancellationToken); +``` + +## Attributes +Squealify provides the following attributes to define your schema: + +- `[Table(string name)]`: Specifies the table name. +- `[PrimaryKey]`: Marks a property as the primary key. +- `[ForeignKey(string referenceTable, string referenceField)]`: Defines a foreign key relationship. +- `[ColumnUnique]`: Marks a property as unique. +- `[TableUnique]`: Defines a table unique that can be a combination of any columns. +- `[Varchar(int length)]`: Specifies a `VARCHAR` column with a maximum length. + +## Supported Queries + +The following methods are generated for each schema: + +- `CreateTable` +- `CreateTableIfNotExists` +- `Insert` +- `Upsert` +- `Update` +- `Delete` +- `Find` \ No newline at end of file diff --git a/Squealify.csproj b/Squealify.csproj index 1bad9d1..5684891 100644 --- a/Squealify.csproj +++ b/Squealify.csproj @@ -2,7 +2,7 @@ netstandard2.0 - 0.1.75 + 0.8.0 latest true Alexandru Macocian