Soft delete
Mark a boolean column with [InquirySoftDelete] and the generator does two things:
- Every
SELECTautomatically composes the active-row filter — the SQL grows aWHERE IsDeleted = 0clause (or the dialect's equivalent). DeleteByKeybecomes a soft delete — it emitsUPDATE … SET IsDeleted = 1 WHERE …instead of a literalDELETE.
Both behaviors can be overridden per-method when you need to bypass them (admin tools, GDPR purges, etc.).
You write
using Inquiry.Entities;
[InquiryTable("Customers")]
public sealed class Customer
{
[InquiryKey] public string CustomerID { get; set; } = "";
[InquiryColumn] public string CompanyName { get; set; } = "";
[InquirySoftDelete]
[InquiryColumn]
public bool IsDeleted { get; set; }
}
public partial class CustomerStore : InquiryStore<Customer>
{
// Auto-composes WHERE IsDeleted = 0
[InquirySelectAll]
public partial Task<IReadOnlyList<Customer>> SelectAllAsync(CancellationToken ct = default);
// Same — IsDeleted filter AND-composed with the predicate
[InquirySelectAllByField("CompanyName")]
public partial Task<IReadOnlyList<Customer>> SelectByCompanyAsync(string companyName, CancellationToken ct = default);
// Soft delete: emits UPDATE … SET IsDeleted = 1, not DELETE
[InquiryDelete]
public partial Task<bool> DeleteAsync(string customerID, CancellationToken ct = default);
// Opt-out: include soft-deleted rows in this select
[InquirySelectAll(IncludeDeleted = true)]
public partial Task<IReadOnlyList<Customer>> SelectAllIncludingDeletedAsync(CancellationToken ct = default);
// Opt-out: literal DELETE, bypassing the soft-delete column
[InquiryHardDelete]
public partial Task<bool> HardDeleteAsync(string customerID, CancellationToken ct = default);
}
The generator emits
The active-filter literal is per-dialect — SQL Server / SQLite use 0/1, PostgreSQL uses FALSE/TRUE, Oracle uses 0/1 over a NUMBER(1) column.
// Default selects: filter is AND-composed
private const string _sqlSelectAll =
"SELECT \"CustomerID\", \"CompanyName\", \"IsDeleted\" FROM \"Customers\" WHERE \"IsDeleted\" = 0";
private const string _sqlSelectByCompany =
"SELECT \"CustomerID\", \"CompanyName\", \"IsDeleted\" FROM \"Customers\" " +
"WHERE \"CompanyName\" = @CompanyName AND \"IsDeleted\" = 0";
// Soft delete (default)
private const string _sqlDelete =
"UPDATE \"Customers\" SET \"IsDeleted\" = 1 WHERE \"CustomerID\" = @CustomerID";
// IncludeDeleted opt-out: filter is omitted
private const string _sqlSelectAllIncludingDeleted =
"SELECT \"CustomerID\", \"CompanyName\", \"IsDeleted\" FROM \"Customers\"";
// HardDelete opt-out: literal DELETE
private const string _sqlHardDelete =
"DELETE FROM \"Customers\" WHERE \"CustomerID\" = @CustomerID";
Composition with other features
Soft delete composes correctly with everything else the generator emits:
- Pagination. The filter is AND-composed before
ORDER BY/LIMIT:WHERE <predicate> AND IsDeleted = 0 ORDER BY … LIMIT …. - Keyset paging. The filter is appended after the keyset cursor predicate.
- Aggregates.
SELECT COUNT(*) FROM … WHERE IsDeleted = 0. - Projections. A projection over a soft-delete entity composes the filter too —
SELECT <subset> FROM … WHERE IsDeleted = 0— even though the projected column subset doesn't include the indicator.IncludeDeleted = trueopts out, exactly like a full-entity select. - Optimistic concurrency. Update / delete still check the row-version column in addition to the soft-delete filter.
Per-dialect literal
| Dialect | Active-row literal | Column type |
|---|---|---|
| Sqlite | IsDeleted = 0 |
INTEGER NOT NULL |
| SQL Server | [IsDeleted] = 0 |
BIT NOT NULL |
| PostgreSQL | "IsDeleted" = FALSE |
BOOLEAN NOT NULL |
| MySQL | `IsDeleted` = 0 |
TINYINT(1) NOT NULL |
| Oracle | IsDeleted = 0 (unquoted) |
NUMBER(1) NOT NULL |
When to soft-delete
Soft delete is right when you need:
- Audit / undo capability — "who deleted this customer and when?"
- Foreign-key preservation — child rows shouldn't break when a parent is "removed"
- GDPR right-to-erasure with a two-phase workflow — mark deleted, purge later
Soft delete is not right when:
- Storage is the constraint (deleted rows still occupy disk)
- True compliance erasure is mandatory (use
[InquiryHardDelete]instead) - Foreign keys cascade is the desired behavior
See also
- Global query filters — the same active-row machinery generalized to columns you define (tenancy, publish state, activation).
- CRUD — the baseline operations soft-delete extends.
- Optimistic concurrency — composes with soft delete.