From 378de84880685208426d044bf0622ac3159e2645 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 3 Feb 2026 10:53:09 +0200 Subject: [PATCH 01/19] First implementation --- .../DB/Schema/Attributes/AttributeParser.cs | 179 ++++++++++++++++++ .../DB/Schema/Attributes/ColumnAttribute.cs | 13 ++ .../DB/Schema/Attributes/IndexAttribute.cs | 15 ++ .../DB/Schema/Attributes/TableAttribute.cs | 58 ++++++ .../DB/Schema/SchemaFactory.cs | 13 ++ demos/CommandLine/AppSchema.cs | 58 +++--- demos/CommandLine/Demo.cs | 10 +- 7 files changed, 307 insertions(+), 39 deletions(-) create mode 100644 PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs create mode 100644 PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs create mode 100644 PowerSync/PowerSync.Common/DB/Schema/Attributes/IndexAttribute.cs create mode 100644 PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs new file mode 100644 index 0000000..7e1941c --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -0,0 +1,179 @@ +namespace PowerSync.Common.DB.Schema.Attributes; + +using System.Reflection; + +internal class AttributeParser +{ + private readonly Type _type; + private readonly TableAttribute _tableAttr; + private readonly Dictionary _columns; + private readonly Dictionary> _indexes; + private readonly TableOptions _options; + + public AttributeParser(Type type) + { + _type = type; + + _tableAttr = _type.GetCustomAttribute(); + if (_tableAttr == null) + { + throw new CustomAttributeFormatException("Table classes must be marked with TableAttribute."); + } + + _columns = GetColumns(); + _indexes = GetIndexes(); + _options = GetTableOptions(); + } + + public Table GetTable() + { + return new Table( + _tableAttr.Name, + _columns, + _options + ); + } + + private Dictionary GetColumns() + { + var columns = new Dictionary(); + PropertyInfo? idProperty = null; + + foreach (var prop in _type.GetProperties()) + { + // TODO: Allow setting name via ColumnAttribute? + var name = prop.Name; + + // Handle 'id' field separately + if (name.ToLowerInvariant() == "id") + { + idProperty = prop; + continue; + } + + var columnType = PropertyTypeToColumnType(prop.PropertyType); + columns.Add(name, columnType); + } + + // Validate 'id' property + if (idProperty == null) + { + throw new InvalidOperationException("A public string 'id' property is required."); + } + if (idProperty.PropertyType != typeof(string)) + { + throw new InvalidOperationException("Property 'id' must be of type string."); + } + + return columns; + } + + private Dictionary> GetIndexes() + { + var indexes = new Dictionary>(); + var indexAttrs = _type.GetCustomAttributes(); + foreach (var index in indexAttrs) + { + var name = index.Name; + var columns = index.Columns.ToList(); + indexes.Add(name, columns); + } + return indexes; + } + + private ColumnType PropertyTypeToColumnType(Type propertyType) + { + var innerType = Nullable.GetUnderlyingType(propertyType); + + // TODO: First try to read column type from ColumnAttribute + + return propertyType switch + { + // TEXT types + _ when propertyType == typeof(string) => ColumnType.Text, + _ when propertyType == typeof(char) => ColumnType.Text, + _ when propertyType == typeof(Guid) => ColumnType.Text, + _ when propertyType == typeof(DateTime) => ColumnType.Text, + _ when propertyType == typeof(DateTimeOffset) => ColumnType.Text, + + // INTEGER types + _ when propertyType == typeof(Enum) => ColumnType.Integer, + _ when propertyType == typeof(bool) => ColumnType.Integer, // bool + _ when propertyType == typeof(sbyte) => ColumnType.Integer, // i8 + _ when propertyType == typeof(byte) => ColumnType.Integer, // u8 + _ when propertyType == typeof(short) => ColumnType.Integer, // i16 + _ when propertyType == typeof(ushort) => ColumnType.Integer, // u16 + _ when propertyType == typeof(int) => ColumnType.Integer, // i32 + _ when propertyType == typeof(uint) => ColumnType.Integer, // u32 + _ when propertyType == typeof(long) => ColumnType.Integer, // i64 + _ when propertyType == typeof(ulong) => ColumnType.Integer, // u64 + // .NET 5.0+, but we need to support .NET Standard 2.0 + // _ when propertyType == typeof(nint) => ColumnType.Integer, // isize + // _ when propertyType == typeof(nuint) => ColumnType.Integer, // usize + + // REAL types + _ when propertyType == typeof(float) => ColumnType.Real, + _ when propertyType == typeof(double) => ColumnType.Real, + _ when propertyType == typeof(decimal) => ColumnType.Real, + + // Fallback + _ => ColumnType.Text + }; + } + + private TableOptions GetTableOptions() + { + return new TableOptions( + indexes: _indexes, + localOnly: _tableAttr.LocalOnly, + insertOnly: _tableAttr.InsertOnly, + viewName: _tableAttr.ViewName, + trackMetadata: _tableAttr.TrackMetadata, + trackPreviousValues: GetTrackPreviousOptions(), + ignoreEmptyUpdates: _tableAttr.IgnoreEmptyUpdates + ); + } + + private TrackPreviousOptions? GetTrackPreviousOptions() + { + TrackPrevious trackPrevious = _tableAttr.TrackPreviousValues; + if (trackPrevious == TrackPrevious.None) + { + return null; + } + + if (trackPrevious.HasFlag(TrackPrevious.Columns) && trackPrevious.HasFlag(TrackPrevious.Table)) + { + throw new CustomAttributeFormatException("Cannot specify both TrackPrevious.Columns and TrackPrevious.Table on the same table."); + } + + if (!trackPrevious.HasFlag(TrackPrevious.Columns) + && !trackPrevious.HasFlag(TrackPrevious.Table) + && trackPrevious.HasFlag(TrackPrevious.OnlyWhenChanged)) + { + throw new CustomAttributeFormatException("Cannot specify TrackPrevious.OnlyWhenChanged without also specifying either TrackPrevious.Columns or TrackPrevious.Table."); + } + + bool trackWholeTable = _tableAttr.TrackPreviousValues.HasFlag(TrackPrevious.Table); + bool onlyWhenChanged = trackPrevious.HasFlag(TrackPrevious.OnlyWhenChanged); + + return new TrackPreviousOptions + { + Columns = trackWholeTable ? null : GetTrackedColumns(), + OnlyWhenChanged = onlyWhenChanged, + }; + } + + private List GetTrackedColumns() + { + var trackedColumns = new List(); + foreach (var prop in _type.GetProperties()) + { + var columnAttr = prop.GetCustomAttribute(); + if (columnAttr == null || !columnAttr.TrackPrevious) continue; + + trackedColumns.Add(prop.Name); + } + return trackedColumns; + } +} diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs new file mode 100644 index 0000000..76652d5 --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs @@ -0,0 +1,13 @@ +namespace PowerSync.Common.DB.Schema.Attributes; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class ColumnAttribute : Attribute +{ + public ColumnType ColumnType { get; } + public bool TrackPrevious { get; set; } + + public ColumnAttribute(ColumnType columnType) + { + ColumnType = columnType; + } +} diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/IndexAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/IndexAttribute.cs new file mode 100644 index 0000000..b5b79cc --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/IndexAttribute.cs @@ -0,0 +1,15 @@ +namespace PowerSync.Common.DB.Schema.Attributes; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class IndexAttribute : Attribute +{ + public string Name { get; } + public string[] Columns { get; } + + public IndexAttribute(string name, string[] columns) + { + Name = name; + Columns = columns; + } +} + diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs new file mode 100644 index 0000000..4826ad5 --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs @@ -0,0 +1,58 @@ +namespace PowerSync.Common.DB.Schema.Attributes; + +[Flags] +public enum TrackPrevious +{ + // TODO: Consider finding a method that can't represent invalid states (eg. TrackPrevious.Table | TrackPrevious.Columns) + None = 0, + Table = 1 << 0, + Columns = 1 << 1, + OnlyWhenChanged = 1 << 2, +} + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public sealed class TableAttribute : Attribute +{ + public string Name { get; } + public bool LocalOnly { get; set; } + public bool InsertOnly { get; set; } + public string? ViewName { get; set; } + public bool TrackMetadata { get; set; } + public bool IgnoreEmptyUpdates { get; set; } + public TrackPrevious TrackPreviousValues { get; set; } + + public TableAttribute(string name) + { + Name = name; + } +} + +[ + Table( + "test", + LocalOnly = true, + InsertOnly = false, + ViewName = "test_local", + TrackPreviousValues = TrackPrevious.Columns | TrackPrevious.OnlyWhenChanged + ), + Index("list", [nameof(list_id)]), + Index("completed", [nameof(completed), nameof(completed_by)]) +] +public class Test +{ + public string id { get; set; } + + [Column(ColumnType.Integer)] // ColumnType overrides + public string list_id { get; set; } + + public DateTime created_at { get; set; } + + [Column(ColumnType.Text, TrackPrevious = true)] // Column-wise track previous + public DateTime? completed_at { get; set; } + + public string created_by { get; set; } + + public string? completed_by { get; set; } + + public bool completed { get; set; } +} diff --git a/PowerSync/PowerSync.Common/DB/Schema/SchemaFactory.cs b/PowerSync/PowerSync.Common/DB/Schema/SchemaFactory.cs index 85b4f08..6d90ec0 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/SchemaFactory.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/SchemaFactory.cs @@ -1,5 +1,7 @@ namespace PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; + public class SchemaFactory { private readonly List _tables; @@ -14,6 +16,17 @@ public SchemaFactory(params TableFactory[] tableFactories) _tables = tableFactories.Select((f) => f.Create()).ToList(); } + public SchemaFactory(params Type[] types) + { + _tables = new(); + var indexes = new Dictionary>(); + foreach (Type type in types) + { + var parser = new AttributeParser(type); + _tables.Add(parser.GetTable()); + } + } + public Schema Create() { Dictionary tableMap = new(); diff --git a/demos/CommandLine/AppSchema.cs b/demos/CommandLine/AppSchema.cs index f531973..53fd95d 100644 --- a/demos/CommandLine/AppSchema.cs +++ b/demos/CommandLine/AppSchema.cs @@ -1,38 +1,34 @@ -namespace CommandLine; +namespace CommandLine.Schema; using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; -class AppSchema +[ + Table("todos"), + Index("list", ["list_id"]), + Index("created_at", ["created_at"]) +] +class Todo { - public static TableFactory Todos = new TableFactory() - { - Name = "todos", - Columns = - { - ["list_id"] = ColumnType.Text, - ["created_at"] = ColumnType.Text, - ["completed_at"] = ColumnType.Text, - ["description"] = ColumnType.Text, - ["created_by"] = ColumnType.Text, - ["completed_by"] = ColumnType.Text, - ["completed"] = ColumnType.Integer - }, - Indexes = - { - ["list"] = ["list_id"], - ["created_at"] = ["created_at"], - } - }; + public string id { get; set; } + public string list_id { get; set; } + public DateTime created_at { get; set; } + public DateTime? completed_at { get; set; } + public string created_by { get; set; } + public string? completed_by { get; set; } + public bool completed { get; set; } +} - public static TableFactory Lists = new TableFactory() - { - Name = "lists", - Columns = { - ["created_at"] = ColumnType.Text, - ["name"] = ColumnType.Text, - ["owner_id"] = ColumnType.Text - } - }; +[Table("lists")] +class List +{ + public string id { get; set; } + public string created_at { get; set; } + public string name { get; set; } + public string owner_id { get; set; } +} - public static Schema PowerSyncSchema = new SchemaFactory(Todos, Lists).Create(); +class AppSchema +{ + public static Schema PowerSyncSchema = new SchemaFactory(typeof(Todo), typeof(List)).Create(); } diff --git a/demos/CommandLine/Demo.cs b/demos/CommandLine/Demo.cs index d1b861a..20fedad 100644 --- a/demos/CommandLine/Demo.cs +++ b/demos/CommandLine/Demo.cs @@ -3,6 +3,7 @@ using System.Reflection; using CommandLine.Utils; +using CommandLine.Schema; using PowerSync.Common.Client; using PowerSync.Common.Client.Connection; @@ -11,13 +12,6 @@ class Demo { - private record ListResult - { - public string id; - public string owner_id; - public string name; - public string created_at; - } static async Task Main() { var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions @@ -66,7 +60,7 @@ static async Task Main() bool running = true; - await db.Watch("select * from lists", null, new WatchHandler + await db.Watch("select * from lists", null, new WatchHandler { OnResult = (results) => { From 9c6bf4799da885ac8ad61e16522d1715eb8aee70 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 5 Feb 2026 15:15:45 +0200 Subject: [PATCH 02/19] Read ColumnType from ColumnAttribute --- .../DB/Schema/Attributes/AttributeParser.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs index 7e1941c..dcf86b3 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -51,7 +51,11 @@ private Dictionary GetColumns() continue; } - var columnType = PropertyTypeToColumnType(prop.PropertyType); + + var columnAttr = prop.GetCustomAttribute(); + var columnType = columnAttr != null + ? columnAttr.ColumnType + : PropertyTypeToColumnType(prop.PropertyType); columns.Add(name, columnType); } @@ -85,8 +89,6 @@ private ColumnType PropertyTypeToColumnType(Type propertyType) { var innerType = Nullable.GetUnderlyingType(propertyType); - // TODO: First try to read column type from ColumnAttribute - return propertyType switch { // TEXT types From cf63a70545a6beabb4459e23b0ac6f058b29306c Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 5 Feb 2026 15:39:57 +0200 Subject: [PATCH 03/19] Remove test class --- .../DB/Schema/Attributes/TableAttribute.cs | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs index 4826ad5..0d5fb94 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs @@ -26,33 +26,3 @@ public TableAttribute(string name) Name = name; } } - -[ - Table( - "test", - LocalOnly = true, - InsertOnly = false, - ViewName = "test_local", - TrackPreviousValues = TrackPrevious.Columns | TrackPrevious.OnlyWhenChanged - ), - Index("list", [nameof(list_id)]), - Index("completed", [nameof(completed), nameof(completed_by)]) -] -public class Test -{ - public string id { get; set; } - - [Column(ColumnType.Integer)] // ColumnType overrides - public string list_id { get; set; } - - public DateTime created_at { get; set; } - - [Column(ColumnType.Text, TrackPrevious = true)] // Column-wise track previous - public DateTime? completed_at { get; set; } - - public string created_by { get; set; } - - public string? completed_by { get; set; } - - public bool completed { get; set; } -} From 806652c217fc125e0367493815511fa34394aa5a Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 5 Feb 2026 16:03:19 +0200 Subject: [PATCH 04/19] Add mechanism for overriding table options dynamically --- .../DB/Schema/Attributes/AttributeParser.cs | 11 ++++++++--- PowerSync/PowerSync.Common/DB/Schema/Table.cs | 12 +++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs index dcf86b3..6a1c6f4 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -10,6 +10,11 @@ internal class AttributeParser private readonly Dictionary> _indexes; private readonly TableOptions _options; + public string TableName + { + get { return _tableAttr.Name; } + } + public AttributeParser(Type type) { _type = type; @@ -34,7 +39,7 @@ public Table GetTable() ); } - private Dictionary GetColumns() + public Dictionary GetColumns() { var columns = new Dictionary(); PropertyInfo? idProperty = null; @@ -72,7 +77,7 @@ private Dictionary GetColumns() return columns; } - private Dictionary> GetIndexes() + public Dictionary> GetIndexes() { var indexes = new Dictionary>(); var indexAttrs = _type.GetCustomAttributes(); @@ -123,7 +128,7 @@ private ColumnType PropertyTypeToColumnType(Type propertyType) }; } - private TableOptions GetTableOptions() + public TableOptions GetTableOptions() { return new TableOptions( indexes: _indexes, diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index bc4b01b..bbf11aa 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -1,6 +1,7 @@ namespace PowerSync.Common.DB.Schema; using Newtonsoft.Json; +using PowerSync.Common.DB.Schema.Attributes; public class TableOptions( Dictionary>? indexes = null, @@ -62,7 +63,7 @@ public class Table { public const int MAX_AMOUNT_OF_COLUMNS = 1999; - public Dictionary Columns { get; set; } = new(); + public Dictionary Columns { get; set; } public TableOptions Options { get; set; } @@ -106,9 +107,18 @@ bool IgnoreEmptyUpdates public Table() { + Columns = new Dictionary(); Options = new TableOptions(); } + public Table(Type type, TableOptions? options = null) + { + var parser = new AttributeParser(type); + Name = parser.TableName; + Columns = parser.GetColumns(); + Options = options ?? parser.GetTableOptions(); + } + // Mirrors the legacy syntax, as well as the syntax found in the other SDKs. public Table(string name, Dictionary columns, TableOptions? options = null) { From a1bc9b12ac99ecfa9094eab9775fc458707aa3b9 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Thu, 5 Feb 2026 16:16:29 +0200 Subject: [PATCH 05/19] Additional Table constructors --- PowerSync/PowerSync.Common/DB/Schema/Table.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index bbf11aa..d62af05 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -64,11 +64,10 @@ public class Table public const int MAX_AMOUNT_OF_COLUMNS = 1999; public Dictionary Columns { get; set; } - public TableOptions Options { get; set; } - public string Name { get; set; } = null!; + // Accessors public Dictionary> Indexes { get { return Options.Indexes; } @@ -119,6 +118,15 @@ public Table(Type type, TableOptions? options = null) Options = options ?? parser.GetTableOptions(); } + public Table(Table other, TableOptions? options = null) + { + if (other == null) throw new ArgumentNullException(nameof(other)); + + Name = other.Name; + Columns = other.Columns; + Options = options ?? other.Options; + } + // Mirrors the legacy syntax, as well as the syntax found in the other SDKs. public Table(string name, Dictionary columns, TableOptions? options = null) { From e7e8b301b54bf84f133736c15ba271f565f926e8 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 9 Feb 2026 10:50:56 +0200 Subject: [PATCH 06/19] Tests + update inferred column types --- ; | 32 +++ .../DB/Schema/Attributes/AttributeParser.cs | 69 ++++--- .../DB/Schema/Attributes/ColumnAttribute.cs | 7 +- .../PowerSync.Common/DB/Schema/ColumnJSON.cs | 10 +- .../DB/Schema/CompiledTable.cs | 15 +- .../PowerSync.Common/DB/Schema/Schema.cs | 2 +- PowerSync/PowerSync.Common/DB/Schema/Table.cs | 4 +- .../PowerSync.Common/PowerSync.Common.csproj | 4 + .../Client/PowerSyncDatabaseTests.cs | 2 +- .../PowerSync.Common.Tests/DB/SchemaTests.cs | 182 ++++++++++++++++++ 10 files changed, 287 insertions(+), 40 deletions(-) create mode 100644 ; create mode 100644 Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs diff --git a/; b/; new file mode 100644 index 0000000..45714d6 --- /dev/null +++ b/; @@ -0,0 +1,32 @@ +namespace PowerSync.Common.Tests.DB.Schema; + +namespace PowerSync.Common.Tests.DB.Schema.Attributes; + +using System.Diagnostics; + +using PowerSync.Common.Client; + +// TODO: Schema comparer - or can we re-use DeepEquals utility? + +/// +/// dotnet test -v n --framework net8.0 --filter "SchemaTests" +/// +public class SchemaTests : IAsyncLifetime +{ + public async Task InitializeAsync() + { + // TODO + } + + public async Task DisposeAsync() + { + // TODO + } + + // TODO AttributeParser tests + public void AttributeParser_ParseOptions_Test() + { + var parser = new AttributeParser + } + +} diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs index 6a1c6f4..d1453df 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -6,9 +6,6 @@ internal class AttributeParser { private readonly Type _type; private readonly TableAttribute _tableAttr; - private readonly Dictionary _columns; - private readonly Dictionary> _indexes; - private readonly TableOptions _options; public string TableName { @@ -24,22 +21,18 @@ public AttributeParser(Type type) { throw new CustomAttributeFormatException("Table classes must be marked with TableAttribute."); } - - _columns = GetColumns(); - _indexes = GetIndexes(); - _options = GetTableOptions(); } - public Table GetTable() + public Table ParseTable() { return new Table( - _tableAttr.Name, - _columns, - _options + name: _tableAttr.Name, + columns: ParseColumns(), + options: ParseTableOptions() ); } - public Dictionary GetColumns() + public Dictionary ParseColumns() { var columns = new Dictionary(); PropertyInfo? idProperty = null; @@ -50,34 +43,49 @@ public Dictionary GetColumns() var name = prop.Name; // Handle 'id' field separately - if (name.ToLowerInvariant() == "id") + // TODO prevent defining multiple id columns (eg. 'id', 'Id', 'ID') + if (idProperty == null && name.ToLowerInvariant() == "id") { idProperty = prop; continue; } - var columnAttr = prop.GetCustomAttribute(); - var columnType = columnAttr != null - ? columnAttr.ColumnType - : PropertyTypeToColumnType(prop.PropertyType); + var userColumnType = columnAttr?.ColumnType ?? ColumnType.Inferred; + + // Infer column type from property's type + var columnType = userColumnType == ColumnType.Inferred + ? PropertyTypeToColumnType(prop.PropertyType) + : userColumnType; columns.Add(name, columnType); } - // Validate 'id' property + // Validate 'id' property exists and is a string if (idProperty == null) { throw new InvalidOperationException("A public string 'id' property is required."); } if (idProperty.PropertyType != typeof(string)) { - throw new InvalidOperationException("Property 'id' must be of type string."); + throw new InvalidOperationException($"Property '{idProperty.Name}' must be of type string."); + } + var idAttr = idProperty.GetCustomAttribute(); + if (idAttr != null) + { + // ID column only supports Text and Inferred as options + if (idAttr.ColumnType != ColumnType.Text || idAttr.ColumnType != ColumnType.Inferred) + { + throw new InvalidOperationException + ( + $"Property '{idProperty.Name}' must have ColumnType set to either ColumnType.Text or ColumnType.Inferred." + ); + } } return columns; } - public Dictionary> GetIndexes() + public Dictionary> ParseIndexes() { var indexes = new Dictionary>(); var indexAttrs = _type.GetCustomAttributes(); @@ -102,6 +110,9 @@ private ColumnType PropertyTypeToColumnType(Type propertyType) _ when propertyType == typeof(Guid) => ColumnType.Text, _ when propertyType == typeof(DateTime) => ColumnType.Text, _ when propertyType == typeof(DateTimeOffset) => ColumnType.Text, + _ when propertyType == typeof(TimeSpan) => ColumnType.Text, + // 'decimal' is 128-bit, ColumnType.Real is only 64-bit + _ when propertyType == typeof(decimal) => ColumnType.Text, // INTEGER types _ when propertyType == typeof(Enum) => ColumnType.Integer, @@ -114,34 +125,35 @@ private ColumnType PropertyTypeToColumnType(Type propertyType) _ when propertyType == typeof(uint) => ColumnType.Integer, // u32 _ when propertyType == typeof(long) => ColumnType.Integer, // i64 _ when propertyType == typeof(ulong) => ColumnType.Integer, // u64 - // .NET 5.0+, but we need to support .NET Standard 2.0 + // .NET 5.0+ only // _ when propertyType == typeof(nint) => ColumnType.Integer, // isize // _ when propertyType == typeof(nuint) => ColumnType.Integer, // usize // REAL types _ when propertyType == typeof(float) => ColumnType.Real, _ when propertyType == typeof(double) => ColumnType.Real, - _ when propertyType == typeof(decimal) => ColumnType.Real, // Fallback + // TODO: Maybe raise a console warning / throw an error if unable to infer type? _ => ColumnType.Text }; } - public TableOptions GetTableOptions() + public TableOptions ParseTableOptions() { return new TableOptions( - indexes: _indexes, + indexes: ParseIndexes(), localOnly: _tableAttr.LocalOnly, insertOnly: _tableAttr.InsertOnly, viewName: _tableAttr.ViewName, trackMetadata: _tableAttr.TrackMetadata, - trackPreviousValues: GetTrackPreviousOptions(), + trackPreviousValues: ParseTrackPreviousOptions(), ignoreEmptyUpdates: _tableAttr.IgnoreEmptyUpdates ); } - private TrackPreviousOptions? GetTrackPreviousOptions() + // TODO: Make public? + private TrackPreviousOptions? ParseTrackPreviousOptions() { TrackPrevious trackPrevious = _tableAttr.TrackPreviousValues; if (trackPrevious == TrackPrevious.None) @@ -166,12 +178,13 @@ public TableOptions GetTableOptions() return new TrackPreviousOptions { - Columns = trackWholeTable ? null : GetTrackedColumns(), + Columns = trackWholeTable ? null : ParseTrackedColumns(), OnlyWhenChanged = onlyWhenChanged, }; } - private List GetTrackedColumns() + // TODO: Make public? + private List ParseTrackedColumns() { var trackedColumns = new List(); foreach (var prop in _type.GetProperties()) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs index 76652d5..20b7767 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs @@ -3,11 +3,6 @@ namespace PowerSync.Common.DB.Schema.Attributes; [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] public class ColumnAttribute : Attribute { - public ColumnType ColumnType { get; } + public ColumnType ColumnType { get; set; } = ColumnType.Inferred; public bool TrackPrevious { get; set; } - - public ColumnAttribute(ColumnType columnType) - { - ColumnType = columnType; - } } diff --git a/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs b/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs index 80c74bd..56bd92f 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs @@ -4,7 +4,12 @@ public enum ColumnType { Text, Integer, - Real + Real, + /// + /// Infers the column type based on the associated property's PropertyType. + /// **NB:** `ColumnType.Inferred` can only be used when using the schema attributes syntax. + /// + Inferred } class ColumnJSONOptions(string Name, ColumnType? Type) @@ -21,6 +26,9 @@ class ColumnJSON(ColumnJSONOptions options) public object ToJSONObject() { + // TODO find good exception type / message, or catch/throw somewhere else + if (Type == ColumnType.Inferred) throw new InvalidOperationException("Attempted to serialise Inferred column."); + return new { name = Name, diff --git a/PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs b/PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs index 8a1e5d3..55a4da5 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs @@ -50,6 +50,11 @@ public void Validate() throw new Exception($"Table name is required."); } + if (InvalidSQLCharacters.IsMatch(Name)) + { + throw new Exception($"Invalid characters in table name: {Name}"); + } + if (!string.IsNullOrWhiteSpace(Options.ViewName) && InvalidSQLCharacters.IsMatch(Options.ViewName!)) { throw new Exception($"Invalid characters in view name: {Options.ViewName}"); @@ -73,13 +78,21 @@ public void Validate() var columnNames = new HashSet { "id" }; - foreach (var columnName in Columns.Keys) + foreach (var kvp in Columns) { + string columnName = kvp.Key; + ColumnType columnType = kvp.Value; + if (columnName == "id") { throw new Exception("An id column is automatically added, custom id columns are not supported"); } + if (columnType == ColumnType.Inferred) + { + throw new Exception($"Invalid ColumnType for {kvp.Key}: ColumnType.Inferred. ColumnType.Inferred is only supported when using the schema attribute syntax for defining tables."); + } + if (InvalidSQLCharacters.IsMatch(columnName)) { throw new Exception($"Invalid characters in column name: {columnName}"); diff --git a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs index d639a02..b857935 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs @@ -18,7 +18,7 @@ public Schema(params Type[] types) foreach (Type type in types) { var parser = new AttributeParser(type); - _tables.Add(parser.GetTable()); + _tables.Add(parser.ParseTable()); } } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index d62af05..15d7bcf 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -114,8 +114,8 @@ public Table(Type type, TableOptions? options = null) { var parser = new AttributeParser(type); Name = parser.TableName; - Columns = parser.GetColumns(); - Options = options ?? parser.GetTableOptions(); + Columns = parser.ParseColumns(); + Options = options ?? parser.ParseTableOptions(); } public Table(Table other, TableOptions? options = null) diff --git a/PowerSync/PowerSync.Common/PowerSync.Common.csproj b/PowerSync/PowerSync.Common/PowerSync.Common.csproj index a355965..c6f891d 100644 --- a/PowerSync/PowerSync.Common/PowerSync.Common.csproj +++ b/PowerSync/PowerSync.Common/PowerSync.Common.csproj @@ -21,6 +21,10 @@ README.md $(DefaultItemExcludes);runtimes/**/*.*; + + + + diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index d3af172..6c1ab9e 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -646,7 +646,7 @@ await db.Execute( Assert.Equal(2, callCount); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 2500)] public async void WatchSingleCancelledTest() { int callCount = 0; diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs new file mode 100644 index 0000000..7ca4659 --- /dev/null +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -0,0 +1,182 @@ +namespace PowerSync.Common.Tests.DB.Schema; + +using PowerSync.Common.Tests.Utils; +using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; + +using System.Diagnostics; + +using PowerSync.Common.Client; + +// TODO: Schema comparer - or can we re-use DeepEquals utility? + +/// +/// dotnet test -v n --framework net8.0 --filter "SchemaTests" +/// +public class SchemaTests +{ + [ + Table( + "test_assets", + ViewName = "test_assets_viewname", + IgnoreEmptyUpdates = true + ), + Index("makemodel", ["make", "model"]), + Index("quantity", ["quantity"]), + ] + class Asset + { + public string id { get; set; } + + public DateTime created_at { get; set; } + + public string make { get; set; } + + public string model { get; set; } + + public int quantity { get; set; } + + public string description { get; set; } + } + + [Fact] + public void AttributeParser_Assets_Test() + { + var expected = new CompiledTable( + "test_assets", + new Dictionary + { + ["created_at"] = ColumnType.Text, + ["make"] = ColumnType.Text, + ["model"] = ColumnType.Text, + ["quantity"] = ColumnType.Integer, + ["description"] = ColumnType.Text, + }, + new TableOptions + { + Indexes = new Dictionary> + { + ["makemodel"] = ["make", "model"], + ["quantity"] = ["quantity"], + }, + LocalOnly = false, + InsertOnly = false, + ViewName = "test_assets_viewname", + TrackMetadata = true, + TrackPreviousValues = null, + IgnoreEmptyUpdates = true, + } + ); + + TestParser(typeof(Asset), expected); + } + + [ + Table( + "test_products", + TrackMetadata = true, + TrackPreviousValues = TrackPrevious.Columns | TrackPrevious.OnlyWhenChanged + ), + Index("seller_id_idx", ["seller_id"]), + ] + class Product + { + public string id { get; set; } + + [Column(ColumnType = ColumnType.Real)] + public string created_at { get; set; } + + public string description { get; set; } + + [Column(TrackPrevious = true)] + public int quantity { get; set; } + + [Column(TrackPrevious = true)] + public decimal ppu { get; set; } + + public string seller_id { get; set; } + } + + [Fact] + public void AttributeParser_Products_Test() + { + var expected = new CompiledTable( + "test_products", + new Dictionary + { + ["created_at"] = ColumnType.Real, // Explicit override + ["description"] = ColumnType.Text, + ["quantity"] = ColumnType.Integer, + ["ppu"] = ColumnType.Text, // 'decimal' should cast to Text for lossless conversion + ["seller_id"] = ColumnType.Text, + }, + new TableOptions + { + Indexes = new Dictionary> + { + ["seller_id_idx"] = ["seller_id"], + }, + LocalOnly = false, + InsertOnly = false, + ViewName = null, + TrackMetadata = true, + TrackPreviousValues = new TrackPreviousOptions + { + Columns = ["quantity", "ppu"], + OnlyWhenChanged = true, + }, + IgnoreEmptyUpdates = false, + } + ); + + TestParser(typeof(Product), expected); + } + + [ + Table( + "test_logs", + LocalOnly = true, + InsertOnly = true, + ViewName = "logs_local", + IgnoreEmptyUpdates = true + ) + ] + class Logs + { + public string id { get; set; } + public string description { get; set; } + public DateTimeOffset timestamp { get; set; } + } + + [Fact] + public void AttributeParser_Logs_Test() + { + var expected = new CompiledTable( + "test_logs", + new Dictionary + { + ["description"] = ColumnType.Text, + ["timestamp"] = ColumnType.Text, + }, + new TableOptions + { + Indexes = new Dictionary>(), + LocalOnly = true, + InsertOnly = true, + ViewName = "logs_local", + TrackMetadata = false, + TrackPreviousValues = null, + IgnoreEmptyUpdates = true, + } + ); + + TestParser(typeof(Logs), expected); + } + + private void TestParser(Type type, CompiledTable expected) + { + var parser = new AttributeParser(type); + var table = parser.ParseTable().Compile(); + Assert.Equivalent(expected, table, strict: true); + } +} From 37fff111f3074ac93030b9841352e3ad2fc0c0d5 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 9 Feb 2026 10:57:13 +0200 Subject: [PATCH 07/19] Add nullable to test --- Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 7ca4659..0655da5 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -36,7 +36,7 @@ class Asset public int quantity { get; set; } - public string description { get; set; } + public string? description { get; set; } } [Fact] From 86eba48e631de2e311c99be61d3d7d99b1fac58b Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 9 Feb 2026 11:04:06 +0200 Subject: [PATCH 08/19] Remove random file called ';' (thanks neovim) --- ; | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 ; diff --git a/; b/; deleted file mode 100644 index 45714d6..0000000 --- a/; +++ /dev/null @@ -1,32 +0,0 @@ -namespace PowerSync.Common.Tests.DB.Schema; - -namespace PowerSync.Common.Tests.DB.Schema.Attributes; - -using System.Diagnostics; - -using PowerSync.Common.Client; - -// TODO: Schema comparer - or can we re-use DeepEquals utility? - -/// -/// dotnet test -v n --framework net8.0 --filter "SchemaTests" -/// -public class SchemaTests : IAsyncLifetime -{ - public async Task InitializeAsync() - { - // TODO - } - - public async Task DisposeAsync() - { - // TODO - } - - // TODO AttributeParser tests - public void AttributeParser_ParseOptions_Test() - { - var parser = new AttributeParser - } - -} From 58e46a3b1b07615eb9e48608280d58476f09f847 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Mon, 9 Feb 2026 16:51:30 +0200 Subject: [PATCH 09/19] Add auto-typemap generation, fix bugs --- .../DB/Schema/Attributes/AttributeParser.cs | 43 +++++++++--- .../DB/Schema/Attributes/ColumnAttribute.cs | 6 ++ .../PowerSync.Common/DB/Schema/Schema.cs | 1 + PowerSync/PowerSync.Common/DB/Schema/Table.cs | 1 + .../Client/PowerSyncDatabaseTests.cs | 29 +++++++- .../PowerSync.Common.Tests/DB/SchemaTests.cs | 4 +- .../PowerSync.Common.Tests/Models/Asset.cs | 38 +++++++++++ .../PowerSync.Common.Tests/Models/Customer.cs | 17 +++++ .../PowerSync.Common.Tests/Models/List.cs | 20 ++++++ .../PowerSync.Common.Tests/Models/Todo.cs | 36 ++++++++++ .../PowerSync.Common.Tests/TestSchema.cs | 68 ++----------------- demos/MAUITodo/Data/AppSchema.cs | 34 +--------- demos/MAUITodo/Data/PowerSyncData.cs | 10 +-- demos/MAUITodo/Models/List.cs | 15 ++++ demos/MAUITodo/Models/Todo.cs | 32 +++++++++ demos/MAUITodo/Models/TodoItem.cs | 30 -------- demos/MAUITodo/Models/TodoList.cs | 18 ----- demos/MAUITodo/Views/TodoListPage.xaml.cs | 6 +- 18 files changed, 243 insertions(+), 165 deletions(-) create mode 100644 Tests/PowerSync/PowerSync.Common.Tests/Models/Asset.cs create mode 100644 Tests/PowerSync/PowerSync.Common.Tests/Models/Customer.cs create mode 100644 Tests/PowerSync/PowerSync.Common.Tests/Models/List.cs create mode 100644 Tests/PowerSync/PowerSync.Common.Tests/Models/Todo.cs create mode 100644 demos/MAUITodo/Models/List.cs create mode 100644 demos/MAUITodo/Models/Todo.cs delete mode 100644 demos/MAUITodo/Models/TodoItem.cs delete mode 100644 demos/MAUITodo/Models/TodoList.cs diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs index d1453df..77baa2e 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -1,6 +1,7 @@ namespace PowerSync.Common.DB.Schema.Attributes; using System.Reflection; +using Dapper; internal class AttributeParser { @@ -39,25 +40,24 @@ public Dictionary ParseColumns() foreach (var prop in _type.GetProperties()) { - // TODO: Allow setting name via ColumnAttribute? - var name = prop.Name; + var columnAttr = prop.GetCustomAttribute(); + var columnName = columnAttr?.Name ?? prop.Name; // Handle 'id' field separately // TODO prevent defining multiple id columns (eg. 'id', 'Id', 'ID') - if (idProperty == null && name.ToLowerInvariant() == "id") + if (columnName.ToLowerInvariant() == "id") { idProperty = prop; continue; } - var columnAttr = prop.GetCustomAttribute(); var userColumnType = columnAttr?.ColumnType ?? ColumnType.Inferred; // Infer column type from property's type var columnType = userColumnType == ColumnType.Inferred ? PropertyTypeToColumnType(prop.PropertyType) : userColumnType; - columns.Add(name, columnType); + columns.Add(columnName, columnType); } // Validate 'id' property exists and is a string @@ -73,7 +73,7 @@ public Dictionary ParseColumns() if (idAttr != null) { // ID column only supports Text and Inferred as options - if (idAttr.ColumnType != ColumnType.Text || idAttr.ColumnType != ColumnType.Inferred) + if (idAttr.ColumnType != ColumnType.Text && idAttr.ColumnType != ColumnType.Inferred) { throw new InvalidOperationException ( @@ -152,8 +152,7 @@ public TableOptions ParseTableOptions() ); } - // TODO: Make public? - private TrackPreviousOptions? ParseTrackPreviousOptions() + public TrackPreviousOptions? ParseTrackPreviousOptions() { TrackPrevious trackPrevious = _tableAttr.TrackPreviousValues; if (trackPrevious == TrackPrevious.None) @@ -183,8 +182,32 @@ public TableOptions ParseTableOptions() }; } - // TODO: Make public? - private List ParseTrackedColumns() + public CustomPropertyTypeMap ParseDapperTypeMap() + { + return new( + _type, + (type, columnName) => type.GetProperties() + .FirstOrDefault(prop => prop.GetCustomAttributes() + .OfType() + .Any(columnAttr => columnAttr.Name == columnName)) + ); + } + + public void RegisterDapperTypeMap() + { + // Only register type map if some Column("custom_name") is found + if (_type + .GetProperties() + .Any(prop => prop + .GetCustomAttributes() + .OfType() + .Any(attr => attr.Name != null))) + { + Dapper.SqlMapper.SetTypeMap(_type, ParseDapperTypeMap()); + } + } + + public List ParseTrackedColumns() { var trackedColumns = new List(); foreach (var prop in _type.GetProperties()) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs index 20b7767..c8597c8 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs @@ -3,6 +3,12 @@ namespace PowerSync.Common.DB.Schema.Attributes; [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] public class ColumnAttribute : Attribute { + public string? Name { get; set; } = ""; public ColumnType ColumnType { get; set; } = ColumnType.Inferred; public bool TrackPrevious { get; set; } + + public ColumnAttribute(string? name = null) + { + Name = name; + } } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs index b857935..7fca5c0 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs @@ -18,6 +18,7 @@ public Schema(params Type[] types) foreach (Type type in types) { var parser = new AttributeParser(type); + parser.RegisterDapperTypeMap(); _tables.Add(parser.ParseTable()); } } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index 15d7bcf..0ab757b 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -116,6 +116,7 @@ public Table(Type type, TableOptions? options = null) Name = parser.TableName; Columns = parser.ParseColumns(); Options = options ?? parser.ParseTableOptions(); + parser.RegisterDapperTypeMap(); } public Table(Table other, TableOptions? options = null) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index 6c1ab9e..2ba3612 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -5,6 +5,7 @@ namespace PowerSync.Common.Tests.Client; using Microsoft.Data.Sqlite; using PowerSync.Common.Client; +using PowerSync.Common.Tests.Models; /// /// dotnet test -v n --framework net8.0 --filter "PowerSyncDatabaseTests" @@ -83,11 +84,11 @@ await db.Execute( public async Task QueryWithNullParams() { var id = Guid.NewGuid().ToString(); - var name = "Test user"; + var description = "Test description"; await db.Execute( "INSERT INTO assets(id, description, make) VALUES(?, ?, ?)", - [id, name, null] + [id, description, null] ); var result = await db.GetAll("SELECT id, description, make FROM assets"); @@ -95,10 +96,32 @@ await db.Execute( Assert.Single(result); var row = result.First(); Assert.Equal(id, row.id); - Assert.Equal(name, row.description); + Assert.Equal(description, row.description); Assert.Null(row.make); } + [Fact] + public async Task QueryModelResult() + { + var id = Guid.NewGuid().ToString(); + var description = "Test description"; + var make = "Test make"; + var createdAt = DateTimeOffset.Now; + + await db.Execute( + "INSERT INTO assets(id, description, make, created_at) VALUES(?, ?, ?, ?)", + [id, description, make, createdAt] + ); + + var results = await db.GetAll("SELECT * FROM assets"); + Assert.Single(results); + var row = results.First(); + Assert.Equal(id, row.AssetId); + Assert.Equal(description, row.Description); + Assert.Equal(make, row.Make); + Assert.Equal(createdAt, row.CreatedAt); + } + [Fact] public async Task FailedInsertTest() { diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 0655da5..31d4c22 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -8,8 +8,6 @@ namespace PowerSync.Common.Tests.DB.Schema; using PowerSync.Common.Client; -// TODO: Schema comparer - or can we re-use DeepEquals utility? - /// /// dotnet test -v n --framework net8.0 --filter "SchemaTests" /// @@ -84,7 +82,7 @@ class Product public string id { get; set; } [Column(ColumnType = ColumnType.Real)] - public string created_at { get; set; } + public DateTime created_at { get; set; } public string description { get; set; } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Models/Asset.cs b/Tests/PowerSync/PowerSync.Common.Tests/Models/Asset.cs new file mode 100644 index 0000000..c627599 --- /dev/null +++ b/Tests/PowerSync/PowerSync.Common.Tests/Models/Asset.cs @@ -0,0 +1,38 @@ +namespace PowerSync.Common.Tests.Models; + +using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; + +[ + Table("assets"), + Index("makemodel", ["make", "model"]) +] +public class Asset +{ + [Column("id")] + public string AssetId { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + + [Column("make")] + public string Make { get; set; } + + [Column("model")] + public string Model { get; set; } + + [Column("serial_number")] + public string SerialNumber { get; set; } + + [Column("quantity")] + public int Quantity { get; set; } + + [Column("user_id")] + public string UserId { get; set; } + + [Column("customer_id")] + public string CustomerId { get; set; } + + [Column("description")] + public string Description { get; set; } +} diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Models/Customer.cs b/Tests/PowerSync/PowerSync.Common.Tests/Models/Customer.cs new file mode 100644 index 0000000..747e544 --- /dev/null +++ b/Tests/PowerSync/PowerSync.Common.Tests/Models/Customer.cs @@ -0,0 +1,17 @@ +namespace PowerSync.Common.Tests.Models; + +using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; + +[Table("customers")] +public class Customer +{ + [Column("id")] + public string CustomerId { get; set; } + + [Column("name")] + public string Name { get; set; } + + [Column("email")] + public string Email { get; set; } +} diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Models/List.cs b/Tests/PowerSync/PowerSync.Common.Tests/Models/List.cs new file mode 100644 index 0000000..83e041e --- /dev/null +++ b/Tests/PowerSync/PowerSync.Common.Tests/Models/List.cs @@ -0,0 +1,20 @@ +namespace PowerSync.Common.Tests.Models; + +using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; + +[Table("lists")] +public class List +{ + [Column("id")] + public string ListId { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + + [Column("name")] + public string Name { get; set; } + + [Column("owner_id")] + public string OwnerId { get; set; } +} diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Models/Todo.cs b/Tests/PowerSync/PowerSync.Common.Tests/Models/Todo.cs new file mode 100644 index 0000000..a94b70c --- /dev/null +++ b/Tests/PowerSync/PowerSync.Common.Tests/Models/Todo.cs @@ -0,0 +1,36 @@ +namespace PowerSync.Common.Tests.Models; + +using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; + +[ + Table("todos"), + Index("list", ["list_id"]) +] +public class Todo +{ + [Column("id")] + public string TodoId { get; set; } + + [Column("list_id")] + public string ListId { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + + [Column("completed_at")] + public DateTime? CompletedAt { get; set; } + + [Column("description")] + public string Description { get; set; } + + [Column("created_by")] + public string CreatedBy { get; set; } + + [Column("completed_by")] + public string? CompletedBy { get; set; } + + [Column("completed")] + public bool Completed { get; set; } + +} diff --git a/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs b/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs index 55b9a97..a0215d9 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs @@ -1,82 +1,26 @@ namespace PowerSync.Common.Tests; using PowerSync.Common.DB.Schema; +using PowerSync.Common.Tests.Models; public class TestSchemaTodoList { - public static Table Todos = new Table - { - Name = "todos", - Columns = - { - ["list_id"] = ColumnType.Text, - ["created_at"] = ColumnType.Text, - ["completed_at"] = ColumnType.Text, - ["description"] = ColumnType.Text, - ["created_by"] = ColumnType.Text, - ["completed_by"] = ColumnType.Text, - ["completed"] = ColumnType.Integer, - }, - Indexes = - { - ["list"] = ["list_id"] - } - }; - - public static Table Lists = new Table - { - Name = "lists", - Columns = - { - ["created_at"] = ColumnType.Text, - ["name"] = ColumnType.Text, - ["owner_id"] = ColumnType.Text, - } - }; + public static Table Todos = new Table(typeof(Todo)); + public static Table Lists = new Table(typeof(List)); public static readonly Schema AppSchema = new Schema(Todos, Lists); } public class TestSchema { - public static readonly Dictionary AssetsColumns = new() - { - ["created_at"] = ColumnType.Text, - ["make"] = ColumnType.Text, - ["model"] = ColumnType.Text, - ["serial_number"] = ColumnType.Text, - ["quantity"] = ColumnType.Integer, - ["user_id"] = ColumnType.Text, - ["customer_id"] = ColumnType.Text, - ["description"] = ColumnType.Text, - }; - - public static readonly Table Assets = new Table - { - Name = "assets", - Columns = AssetsColumns, - Indexes = - { - ["makemodel"] = ["make", "model"] - } - }; - - public static readonly Table Customers = new Table - { - Name = "customers", - Columns = - { - ["name"] = ColumnType.Text, - ["email"] = ColumnType.Text, - } - }; + public static readonly Table Assets = new Table(typeof(Asset)); + public static readonly Table Customers = new Table(typeof(Customer)); public static readonly Schema AppSchema = new Schema(Assets, Customers); public static Schema GetSchemaWithCustomAssetOptions(TableOptions? assetOptions = null) { - var customAssets = new Table("assets", AssetsColumns, assetOptions); - + var customAssets = new Table(Assets, assetOptions); return new Schema(customAssets, Customers); } } diff --git a/demos/MAUITodo/Data/AppSchema.cs b/demos/MAUITodo/Data/AppSchema.cs index e261fd0..821a29e 100644 --- a/demos/MAUITodo/Data/AppSchema.cs +++ b/demos/MAUITodo/Data/AppSchema.cs @@ -1,36 +1,8 @@ using PowerSync.Common.DB.Schema; +using MAUITodo.Models; + class AppSchema { - public static Table Todos = new Table - { - Name = "todos", - Columns = - { - ["list_id"] = ColumnType.Text, - ["created_at"] = ColumnType.Text, - ["completed_at"] = ColumnType.Text, - ["description"] = ColumnType.Text, - ["created_by"] = ColumnType.Text, - ["completed_by"] = ColumnType.Text, - ["completed"] = ColumnType.Integer, - }, - Indexes = - { - ["list"] = ["list_id"], - } - }; - - public static Table Lists = new Table - { - Name = "lists", - Columns = - { - ["created_at"] = ColumnType.Text, - ["name"] = ColumnType.Text, - ["owner_id"] = ColumnType.Text - } - }; - - public static Schema PowerSyncSchema = new Schema(Todos, Lists); + public static Schema PowerSyncSchema = new Schema(typeof(List), typeof(Todo)); } diff --git a/demos/MAUITodo/Data/PowerSyncData.cs b/demos/MAUITodo/Data/PowerSyncData.cs index 5e2f8ba..d7bd6b9 100644 --- a/demos/MAUITodo/Data/PowerSyncData.cs +++ b/demos/MAUITodo/Data/PowerSyncData.cs @@ -41,7 +41,7 @@ public PowerSyncData() Db.Connect(nodeConnector); } - public async Task SaveListAsync(TodoList list) + public async Task SaveListAsync(List list) { if (list.ID != "") { @@ -57,7 +57,7 @@ await Db.Execute( } } - public async Task DeleteListAsync(TodoList list) + public async Task DeleteListAsync(Todo list) { var listId = list.ID; // First delete all todo items in this list @@ -65,7 +65,7 @@ public async Task DeleteListAsync(TodoList list) await Db.Execute("DELETE FROM lists WHERE id = ?", [listId]); } - public async Task SaveItemAsync(TodoItem item) + public async Task SaveItemAsync(Todo item) { if (item.ID != "") { @@ -88,7 +88,7 @@ await Db.Execute( (id, list_id, description, created_at, created_by, completed, completed_at, completed_by) VALUES (uuid(), ?, ?, datetime(), ?, ?, ?, ?)", [ - item.ListId, + item.ListID, item.Description, UserId, item.Completed ? 1 : 0, @@ -123,7 +123,7 @@ await Db.Execute( } } - public async Task DeleteItemAsync(TodoItem item) + public async Task DeleteItemAsync(Todo item) { await Db.Execute("DELETE FROM todos WHERE id = ?", [item.ID]); } diff --git a/demos/MAUITodo/Models/List.cs b/demos/MAUITodo/Models/List.cs new file mode 100644 index 0000000..b92b42e --- /dev/null +++ b/demos/MAUITodo/Models/List.cs @@ -0,0 +1,15 @@ +namespace MAUITodo.Models; + +using PowerSync.Common.DB.Schema.Attributes; + +[Table("lists")] +public class List +{ + public string ID { get; set; } + + public DateTime CreatedAt { get; set; } + + public string Name { get; set; } + + public string OwnerID { get; set; } +} diff --git a/demos/MAUITodo/Models/Todo.cs b/demos/MAUITodo/Models/Todo.cs new file mode 100644 index 0000000..12b1bc8 --- /dev/null +++ b/demos/MAUITodo/Models/Todo.cs @@ -0,0 +1,32 @@ +namespace MAUITodo.Models; + +using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; + +[Table("todos")] +public class Todo +{ + [Column("id")] + public string ID { get; set; } + + [Column("list_id")] + public string ListID { get; set; } + + [Column("created_at")] + public string CreatedAt { get; set; } + + [Column("completed_at")] + public string? CompletedAt { get; set; } + + [Column("description")] + public string Description { get; set; } + + [Column("created_by")] + public string CreatedBy { get; set; } + + [Column("completed_by")] + public string? CompletedBy { get; set; } + + [Column("completed")] + public bool Completed { get; set; } +} diff --git a/demos/MAUITodo/Models/TodoItem.cs b/demos/MAUITodo/Models/TodoItem.cs deleted file mode 100644 index 2dcfb7e..0000000 --- a/demos/MAUITodo/Models/TodoItem.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Newtonsoft.Json; - -namespace MAUITodo.Models; - -public class TodoItem -{ - [JsonProperty("id")] - public string ID { get; set; } = ""; - - [JsonProperty("list_id")] - public string ListId { get; set; } = null!; - - [JsonProperty("created_at")] - public string CreatedAt { get; set; } = null!; - - [JsonProperty("completed_at")] - public string? CompletedAt { get; set; } - - [JsonProperty("description")] - public string Description { get; set; } = null!; - - [JsonProperty("created_by")] - public string CreatedBy { get; set; } = null!; - - [JsonProperty("completed_by")] - public string CompletedBy { get; set; } = null!; - - [JsonProperty("completed")] - public bool Completed { get; set; } -} diff --git a/demos/MAUITodo/Models/TodoList.cs b/demos/MAUITodo/Models/TodoList.cs deleted file mode 100644 index 1b19f4f..0000000 --- a/demos/MAUITodo/Models/TodoList.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Newtonsoft.Json; - -namespace MAUITodo.Models; - -public class TodoList -{ - [JsonProperty("id")] - public string ID { get; set; } = ""; - - [JsonProperty("created_at")] - public string CreatedAt { get; set; } = null!; - - [JsonProperty("name")] - public string Name { get; set; } = null!; - - [JsonProperty("owner_id")] - public string OwnerId { get; set; } = null!; -} diff --git a/demos/MAUITodo/Views/TodoListPage.xaml.cs b/demos/MAUITodo/Views/TodoListPage.xaml.cs index e709593..e9a7458 100644 --- a/demos/MAUITodo/Views/TodoListPage.xaml.cs +++ b/demos/MAUITodo/Views/TodoListPage.xaml.cs @@ -24,7 +24,7 @@ protected override async void OnAppearing() { base.OnAppearing(); - await database.Db.Watch("select * from todos where list_id = ?", [selectedList.ID], new WatchHandler + await database.Db.Watch("select * from todos where list_id = ?", [selectedList.ID], new WatchHandler { OnResult = (results) => { @@ -42,10 +42,10 @@ private async void OnAddClicked(object sender, EventArgs e) var description = await DisplayPromptAsync("New Todo", "Enter todo description:"); if (!string.IsNullOrWhiteSpace(description)) { - var todo = new TodoItem + var todo = new Todo { Description = description, - ListId = selectedList.ID + ListID = selectedList.ID }; await database.SaveItemAsync(todo); } From d28c640322266c4df4b4c610244f7e939ad66660 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 10 Feb 2026 12:16:33 +0200 Subject: [PATCH 10/19] Fix Maui demo erroring when console has no results, use Attribute syntax --- demos/MAUITodo/Data/AppSchema.cs | 5 ++- demos/MAUITodo/Data/PowerSyncData.cs | 10 +++--- demos/MAUITodo/Models/List.cs | 15 --------- demos/MAUITodo/Models/Todo.cs | 32 ------------------- demos/MAUITodo/Models/TodoItem.cs | 34 +++++++++++++++++++++ demos/MAUITodo/Models/TodoList.cs | 19 ++++++++++++ demos/MAUITodo/Views/SqlConsolePage.xaml.cs | 6 ++++ demos/MAUITodo/Views/TodoListPage.xaml.cs | 6 ++-- 8 files changed, 71 insertions(+), 56 deletions(-) delete mode 100644 demos/MAUITodo/Models/List.cs delete mode 100644 demos/MAUITodo/Models/Todo.cs create mode 100644 demos/MAUITodo/Models/TodoItem.cs create mode 100644 demos/MAUITodo/Models/TodoList.cs diff --git a/demos/MAUITodo/Data/AppSchema.cs b/demos/MAUITodo/Data/AppSchema.cs index 821a29e..cdb239c 100644 --- a/demos/MAUITodo/Data/AppSchema.cs +++ b/demos/MAUITodo/Data/AppSchema.cs @@ -4,5 +4,8 @@ class AppSchema { - public static Schema PowerSyncSchema = new Schema(typeof(List), typeof(Todo)); + public static Table Todos = new Table(typeof(TodoItem)); + public static Table Lists = new Table(typeof(TodoList)); + + public static Schema PowerSyncSchema = new Schema(Todos, Lists); } diff --git a/demos/MAUITodo/Data/PowerSyncData.cs b/demos/MAUITodo/Data/PowerSyncData.cs index d7bd6b9..5e2f8ba 100644 --- a/demos/MAUITodo/Data/PowerSyncData.cs +++ b/demos/MAUITodo/Data/PowerSyncData.cs @@ -41,7 +41,7 @@ public PowerSyncData() Db.Connect(nodeConnector); } - public async Task SaveListAsync(List list) + public async Task SaveListAsync(TodoList list) { if (list.ID != "") { @@ -57,7 +57,7 @@ await Db.Execute( } } - public async Task DeleteListAsync(Todo list) + public async Task DeleteListAsync(TodoList list) { var listId = list.ID; // First delete all todo items in this list @@ -65,7 +65,7 @@ public async Task DeleteListAsync(Todo list) await Db.Execute("DELETE FROM lists WHERE id = ?", [listId]); } - public async Task SaveItemAsync(Todo item) + public async Task SaveItemAsync(TodoItem item) { if (item.ID != "") { @@ -88,7 +88,7 @@ await Db.Execute( (id, list_id, description, created_at, created_by, completed, completed_at, completed_by) VALUES (uuid(), ?, ?, datetime(), ?, ?, ?, ?)", [ - item.ListID, + item.ListId, item.Description, UserId, item.Completed ? 1 : 0, @@ -123,7 +123,7 @@ await Db.Execute( } } - public async Task DeleteItemAsync(Todo item) + public async Task DeleteItemAsync(TodoItem item) { await Db.Execute("DELETE FROM todos WHERE id = ?", [item.ID]); } diff --git a/demos/MAUITodo/Models/List.cs b/demos/MAUITodo/Models/List.cs deleted file mode 100644 index b92b42e..0000000 --- a/demos/MAUITodo/Models/List.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace MAUITodo.Models; - -using PowerSync.Common.DB.Schema.Attributes; - -[Table("lists")] -public class List -{ - public string ID { get; set; } - - public DateTime CreatedAt { get; set; } - - public string Name { get; set; } - - public string OwnerID { get; set; } -} diff --git a/demos/MAUITodo/Models/Todo.cs b/demos/MAUITodo/Models/Todo.cs deleted file mode 100644 index 12b1bc8..0000000 --- a/demos/MAUITodo/Models/Todo.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace MAUITodo.Models; - -using PowerSync.Common.DB.Schema; -using PowerSync.Common.DB.Schema.Attributes; - -[Table("todos")] -public class Todo -{ - [Column("id")] - public string ID { get; set; } - - [Column("list_id")] - public string ListID { get; set; } - - [Column("created_at")] - public string CreatedAt { get; set; } - - [Column("completed_at")] - public string? CompletedAt { get; set; } - - [Column("description")] - public string Description { get; set; } - - [Column("created_by")] - public string CreatedBy { get; set; } - - [Column("completed_by")] - public string? CompletedBy { get; set; } - - [Column("completed")] - public bool Completed { get; set; } -} diff --git a/demos/MAUITodo/Models/TodoItem.cs b/demos/MAUITodo/Models/TodoItem.cs new file mode 100644 index 0000000..e13ee8f --- /dev/null +++ b/demos/MAUITodo/Models/TodoItem.cs @@ -0,0 +1,34 @@ +namespace MAUITodo.Models; + +using PowerSync.Common.DB.Schema.Attributes; + +[ + Table("todos"), + Index("list", ["list_id"]) +] +public class TodoItem +{ + [Column("id")] + public string ID { get; set; } = ""; + + [Column("list_id")] + public string ListId { get; set; } = null!; + + [Column("created_at")] + public string CreatedAt { get; set; } = null!; + + [Column("completed_at")] + public string? CompletedAt { get; set; } + + [Column("description")] + public string Description { get; set; } = null!; + + [Column("created_by")] + public string CreatedBy { get; set; } = null!; + + [Column("completed_by")] + public string CompletedBy { get; set; } = null!; + + [Column("completed")] + public bool Completed { get; set; } +} diff --git a/demos/MAUITodo/Models/TodoList.cs b/demos/MAUITodo/Models/TodoList.cs new file mode 100644 index 0000000..22c418a --- /dev/null +++ b/demos/MAUITodo/Models/TodoList.cs @@ -0,0 +1,19 @@ +namespace MAUITodo.Models; + +using PowerSync.Common.DB.Schema.Attributes; + +[Table("lists")] +public class TodoList +{ + [Column("id")] + public string ID { get; set; } = ""; + + [Column("created_at")] + public string CreatedAt { get; set; } = null!; + + [Column("name")] + public string Name { get; set; } = null!; + + [Column("owner_id")] + public string OwnerId { get; set; } = null!; +} diff --git a/demos/MAUITodo/Views/SqlConsolePage.xaml.cs b/demos/MAUITodo/Views/SqlConsolePage.xaml.cs index 2cd3aaa..7ce0afd 100644 --- a/demos/MAUITodo/Views/SqlConsolePage.xaml.cs +++ b/demos/MAUITodo/Views/SqlConsolePage.xaml.cs @@ -29,6 +29,12 @@ private async void OnQuerySubmitted(object sender, EventArgs e) return; var results = await database.Db.GetAll(query); + if (results.Length == 0) + { + Headers.Text = "No results found."; + Results.Text = ""; + return; + } var keys = JObject.Parse(JsonConvert.SerializeObject(results[0])).Properties().Select(p => p.Name).ToList(); var allValues = results diff --git a/demos/MAUITodo/Views/TodoListPage.xaml.cs b/demos/MAUITodo/Views/TodoListPage.xaml.cs index e9a7458..e709593 100644 --- a/demos/MAUITodo/Views/TodoListPage.xaml.cs +++ b/demos/MAUITodo/Views/TodoListPage.xaml.cs @@ -24,7 +24,7 @@ protected override async void OnAppearing() { base.OnAppearing(); - await database.Db.Watch("select * from todos where list_id = ?", [selectedList.ID], new WatchHandler + await database.Db.Watch("select * from todos where list_id = ?", [selectedList.ID], new WatchHandler { OnResult = (results) => { @@ -42,10 +42,10 @@ private async void OnAddClicked(object sender, EventArgs e) var description = await DisplayPromptAsync("New Todo", "Enter todo description:"); if (!string.IsNullOrWhiteSpace(description)) { - var todo = new Todo + var todo = new TodoItem { Description = description, - ListID = selectedList.ID + ListId = selectedList.ID }; await database.SaveItemAsync(todo); } From a9bc4a9a65b30a06cbedee7b872231168c841d34 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 10 Feb 2026 12:18:28 +0200 Subject: [PATCH 11/19] Restore CommandLine to old schema syntax, formatting --- demos/CommandLine/AppSchema.cs | 58 +++-- demos/CommandLine/Demo.cs | 287 +++++++++++---------- demos/CommandLine/Models/Supabase/List.cs | 1 - demos/CommandLine/Models/Supabase/Todos.cs | 3 - demos/CommandLine/NodeConnector.cs | 252 +++++++++--------- 5 files changed, 304 insertions(+), 297 deletions(-) diff --git a/demos/CommandLine/AppSchema.cs b/demos/CommandLine/AppSchema.cs index ab9dac1..3631667 100644 --- a/demos/CommandLine/AppSchema.cs +++ b/demos/CommandLine/AppSchema.cs @@ -1,34 +1,38 @@ -namespace CommandLine.Schema; +namespace CommandLine; using PowerSync.Common.DB.Schema; -using PowerSync.Common.DB.Schema.Attributes; -[ - Table("todos"), - Index("list", ["list_id"]), - Index("created_at", ["created_at"]) -] -class Todo +class AppSchema { - public string id { get; set; } - public string list_id { get; set; } - public DateTime created_at { get; set; } - public DateTime? completed_at { get; set; } - public string created_by { get; set; } - public string? completed_by { get; set; } - public bool completed { get; set; } -} + public static Table Todos = new Table + { + Name = "todos", + Columns = + { + ["list_id"] = ColumnType.Text, + ["created_at"] = ColumnType.Text, + ["completed_at"] = ColumnType.Text, + ["description"] = ColumnType.Text, + ["created_by"] = ColumnType.Text, + ["completed_by"] = ColumnType.Text, + ["completed"] = ColumnType.Integer + }, + Indexes = + { + ["list"] = ["list_id"], + ["created_at"] = ["created_at"], + } + }; -[Table("lists")] -class List -{ - public string id { get; set; } - public string created_at { get; set; } - public string name { get; set; } - public string owner_id { get; set; } -} + public static Table Lists = new Table + { + Name = "lists", + Columns = { + ["created_at"] = ColumnType.Text, + ["name"] = ColumnType.Text, + ["owner_id"] = ColumnType.Text + } + }; -class AppSchema -{ - public static Schema PowerSyncSchema = new Schema(typeof(Todo), typeof(List)); + public static Schema PowerSyncSchema = new Schema(Todos, Lists); } diff --git a/demos/CommandLine/Demo.cs b/demos/CommandLine/Demo.cs index 20fedad..62abe10 100644 --- a/demos/CommandLine/Demo.cs +++ b/demos/CommandLine/Demo.cs @@ -1,147 +1,154 @@ -namespace CommandLine; - +namespace CommandLine; + using System.Reflection; using CommandLine.Utils; -using CommandLine.Schema; -using PowerSync.Common.Client; +using PowerSync.Common.Client; using PowerSync.Common.Client.Connection; -using Spectre.Console; - -class Demo -{ - static async Task Main() - { - var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions - { - Database = new SQLOpenOptions { DbFilename = "cli-example.db" }, - Schema = AppSchema.PowerSyncSchema, - }); - await db.Init(); - - var config = new Config(); - - IPowerSyncBackendConnector connector; - - string connectorUserId = ""; - - if (config.UseSupabase) - { - var supabaseConnector = new SupabaseConnector(config); - - // Ensure this user already exists - await supabaseConnector.Login(config.SupabaseUsername, config.SupabasePassword); - - connectorUserId = supabaseConnector.UserId; - - connector = supabaseConnector; - } - else - { - var nodeConnector = new NodeConnector(config); - - connectorUserId = nodeConnector.UserId; - - connector = nodeConnector; - } - - var table = new Table() - .AddColumn("id") - .AddColumn("name") - .AddColumn("owner_id") - .AddColumn("created_at"); - - Console.WriteLine("Press ESC to exit."); - Console.WriteLine("Press Enter to add a new row."); - Console.WriteLine("Press Backspace to delete the last row."); - Console.WriteLine(""); - - bool running = true; - - await db.Watch("select * from lists", null, new WatchHandler - { - OnResult = (results) => - { - table.Rows.Clear(); - foreach (var line in results) - { - table.AddRow(line.id, line.name, line.owner_id, line.created_at); - } - }, - OnError = (error) => - { - Console.WriteLine("Error: " + error.Message); - } - }); - - var _ = Task.Run(async () => - { - while (running) - { - if (Console.KeyAvailable) - { - var key = Console.ReadKey(intercept: true); - if (key.Key == ConsoleKey.Escape) - { - running = false; - } - else if (key.Key == ConsoleKey.Enter) - { - await db.Execute("insert into lists (id, name, owner_id, created_at) values (uuid(), 'New User', ?, datetime())", [connectorUserId]); - } - else if (key.Key == ConsoleKey.Backspace) - { - await db.Execute("delete from lists where id = (select id from lists order by created_at desc limit 1)"); - } - } - Thread.Sleep(100); - } - }); - - await db.Connect(connector, new PowerSync.Common.Client.Sync.Stream.PowerSyncConnectionOptions - { - AppMetadata = new Dictionary - { - { "app_version", GetAppVersion() }, - } - }); - await db.WaitForFirstSync(); - - var panel = new Panel(table) - { - Header = new PanelHeader("") - }; - var connected = false; - - db.RunListener((update) => - { - if (update.StatusChanged != null) - { - connected = update.StatusChanged.Connected; - } - }); - - // Start live updating table - await AnsiConsole.Live(panel) - .StartAsync(async ctx => - { - while (running) - { - panel.Header = new PanelHeader($"| Connected: {connected} |"); - await Task.Delay(1000); - ctx.Refresh(); - - } - }); - - Console.WriteLine("\nExited live table. Press any key to exit."); - } - - private static string GetAppVersion() - { - var version = Assembly.GetExecutingAssembly().GetName().Version; - return version?.ToString() ?? "unknown"; - } +using Spectre.Console; + +class Demo +{ + class ListResult + { + public string id { get; set; } + public string name { get; set; } + public string owner_id { get; set; } + public string created_at { get; set; } + } + + static async Task Main() + { + var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions + { + Database = new SQLOpenOptions { DbFilename = "cli-example.db" }, + Schema = AppSchema.PowerSyncSchema, + }); + await db.Init(); + + var config = new Config(); + + IPowerSyncBackendConnector connector; + + string connectorUserId = ""; + + if (config.UseSupabase) + { + var supabaseConnector = new SupabaseConnector(config); + + // Ensure this user already exists + await supabaseConnector.Login(config.SupabaseUsername, config.SupabasePassword); + + connectorUserId = supabaseConnector.UserId; + + connector = supabaseConnector; + } + else + { + var nodeConnector = new NodeConnector(config); + + connectorUserId = nodeConnector.UserId; + + connector = nodeConnector; + } + + var table = new Table() + .AddColumn("id") + .AddColumn("name") + .AddColumn("owner_id") + .AddColumn("created_at"); + + Console.WriteLine("Press ESC to exit."); + Console.WriteLine("Press Enter to add a new row."); + Console.WriteLine("Press Backspace to delete the last row."); + Console.WriteLine(""); + + bool running = true; + + await db.Watch("select * from lists", null, new WatchHandler + { + OnResult = (results) => + { + table.Rows.Clear(); + foreach (var line in results) + { + table.AddRow(line.id, line.name, line.owner_id, line.created_at); + } + }, + OnError = (error) => + { + Console.WriteLine("Error: " + error.Message); + } + }); + + var _ = Task.Run(async () => + { + while (running) + { + if (Console.KeyAvailable) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Escape) + { + running = false; + } + else if (key.Key == ConsoleKey.Enter) + { + await db.Execute("insert into lists (id, name, owner_id, created_at) values (uuid(), 'New User', ?, datetime())", [connectorUserId]); + } + else if (key.Key == ConsoleKey.Backspace) + { + await db.Execute("delete from lists where id = (select id from lists order by created_at desc limit 1)"); + } + } + Thread.Sleep(100); + } + }); + + await db.Connect(connector, new PowerSync.Common.Client.Sync.Stream.PowerSyncConnectionOptions + { + AppMetadata = new Dictionary + { + { "app_version", GetAppVersion() }, + } + }); + await db.WaitForFirstSync(); + + var panel = new Panel(table) + { + Header = new PanelHeader("") + }; + var connected = false; + + db.RunListener((update) => + { + if (update.StatusChanged != null) + { + connected = update.StatusChanged.Connected; + } + }); + + // Start live updating table + await AnsiConsole.Live(panel) + .StartAsync(async ctx => + { + while (running) + { + panel.Header = new PanelHeader($"| Connected: {connected} |"); + await Task.Delay(1000); + ctx.Refresh(); + + } + }); + + Console.WriteLine("\nExited live table. Press any key to exit."); + } + + private static string GetAppVersion() + { + var version = Assembly.GetExecutingAssembly().GetName().Version; + return version?.ToString() ?? "unknown"; + } } diff --git a/demos/CommandLine/Models/Supabase/List.cs b/demos/CommandLine/Models/Supabase/List.cs index 4823885..c9eaec5 100644 --- a/demos/CommandLine/Models/Supabase/List.cs +++ b/demos/CommandLine/Models/Supabase/List.cs @@ -1,4 +1,3 @@ - using Newtonsoft.Json; using Supabase.Postgrest.Attributes; diff --git a/demos/CommandLine/Models/Supabase/Todos.cs b/demos/CommandLine/Models/Supabase/Todos.cs index 591b5c9..412b73d 100644 --- a/demos/CommandLine/Models/Supabase/Todos.cs +++ b/demos/CommandLine/Models/Supabase/Todos.cs @@ -1,6 +1,3 @@ - -using Microsoft.VisualBasic; - using Newtonsoft.Json; using Supabase.Postgrest.Attributes; diff --git a/demos/CommandLine/NodeConnector.cs b/demos/CommandLine/NodeConnector.cs index 13889f3..03dbc17 100644 --- a/demos/CommandLine/NodeConnector.cs +++ b/demos/CommandLine/NodeConnector.cs @@ -1,127 +1,127 @@ -namespace CommandLine; - -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; - -using CommandLine.Utils; - -using PowerSync.Common.Client; -using PowerSync.Common.Client.Connection; -using PowerSync.Common.DB.Crud; - -public class NodeConnector : IPowerSyncBackendConnector -{ - private static readonly string StorageFilePath = "user_id.txt"; // Simulating local storage - private readonly HttpClient _httpClient; - - public string BackendUrl { get; } - public string PowerSyncUrl { get; } - public string UserId { get; private set; } - private string? clientId; - - public NodeConnector(Config config) - { - _httpClient = new HttpClient(); - - // Load or generate User ID - UserId = LoadOrGenerateUserId(); - - BackendUrl = config.BackendUrl; - PowerSyncUrl = config.PowerSyncUrl; - - clientId = null; - } - - public string LoadOrGenerateUserId() - { - if (File.Exists(StorageFilePath)) - { - return File.ReadAllText(StorageFilePath); - } - - string newUserId = Guid.NewGuid().ToString(); - File.WriteAllText(StorageFilePath, newUserId); - return newUserId; - } - - public async Task FetchCredentials() - { - string tokenEndpoint = "api/auth/token"; - string url = $"{BackendUrl}/{tokenEndpoint}?user_id={UserId}"; - - HttpResponseMessage response = await _httpClient.GetAsync(url); - if (!response.IsSuccessStatusCode) - { - throw new Exception($"Received {response.StatusCode} from {tokenEndpoint}: {await response.Content.ReadAsStringAsync()}"); - } - - string responseBody = await response.Content.ReadAsStringAsync(); - var jsonResponse = JsonSerializer.Deserialize>(responseBody); - - if (jsonResponse == null || !jsonResponse.ContainsKey("token")) - { - throw new Exception("Invalid response received from authentication endpoint."); - } - - return new PowerSyncCredentials(PowerSyncUrl, jsonResponse["token"]); - } - - public async Task UploadData(IPowerSyncDatabase database) - { - CrudTransaction? transaction; - try - { - transaction = await database.GetNextCrudTransaction(); - } - catch (Exception ex) - { - Console.WriteLine($"UploadData Error: {ex.Message}"); - return; - } - - if (transaction == null) - { - return; - } - - clientId ??= await database.GetClientId(); - - try - { - var batch = new List(); - - foreach (var operation in transaction.Crud) - { - batch.Add(new - { - op = operation.Op.ToString(), - table = operation.Table, - id = operation.Id, - data = operation.OpData - }); - } - - var payload = JsonSerializer.Serialize(new { batch }); - var content = new StringContent(payload, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _httpClient.PostAsync($"{BackendUrl}/api/data", content); - - if (!response.IsSuccessStatusCode) - { - throw new Exception($"Received {response.StatusCode} from /api/data: {await response.Content.ReadAsStringAsync()}"); - } - - await transaction.Complete(); - } - catch (Exception ex) - { - Console.WriteLine($"UploadData Error: {ex.Message}"); - throw; - } - } +namespace CommandLine; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +using CommandLine.Utils; + +using PowerSync.Common.Client; +using PowerSync.Common.Client.Connection; +using PowerSync.Common.DB.Crud; + +public class NodeConnector : IPowerSyncBackendConnector +{ + private static readonly string StorageFilePath = "user_id.txt"; // Simulating local storage + private readonly HttpClient _httpClient; + + public string BackendUrl { get; } + public string PowerSyncUrl { get; } + public string UserId { get; private set; } + private string? clientId; + + public NodeConnector(Config config) + { + _httpClient = new HttpClient(); + + // Load or generate User ID + UserId = LoadOrGenerateUserId(); + + BackendUrl = config.BackendUrl; + PowerSyncUrl = config.PowerSyncUrl; + + clientId = null; + } + + public string LoadOrGenerateUserId() + { + if (File.Exists(StorageFilePath)) + { + return File.ReadAllText(StorageFilePath); + } + + string newUserId = Guid.NewGuid().ToString(); + File.WriteAllText(StorageFilePath, newUserId); + return newUserId; + } + + public async Task FetchCredentials() + { + string tokenEndpoint = "api/auth/token"; + string url = $"{BackendUrl}/{tokenEndpoint}?user_id={UserId}"; + + HttpResponseMessage response = await _httpClient.GetAsync(url); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Received {response.StatusCode} from {tokenEndpoint}: {await response.Content.ReadAsStringAsync()}"); + } + + string responseBody = await response.Content.ReadAsStringAsync(); + var jsonResponse = JsonSerializer.Deserialize>(responseBody); + + if (jsonResponse == null || !jsonResponse.ContainsKey("token")) + { + throw new Exception("Invalid response received from authentication endpoint."); + } + + return new PowerSyncCredentials(PowerSyncUrl, jsonResponse["token"]); + } + + public async Task UploadData(IPowerSyncDatabase database) + { + CrudTransaction? transaction; + try + { + transaction = await database.GetNextCrudTransaction(); + } + catch (Exception ex) + { + Console.WriteLine($"UploadData Error: {ex.Message}"); + return; + } + + if (transaction == null) + { + return; + } + + clientId ??= await database.GetClientId(); + + try + { + var batch = new List(); + + foreach (var operation in transaction.Crud) + { + batch.Add(new + { + op = operation.Op.ToString(), + table = operation.Table, + id = operation.Id, + data = operation.OpData + }); + } + + var payload = JsonSerializer.Serialize(new { batch }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient.PostAsync($"{BackendUrl}/api/data", content); + + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Received {response.StatusCode} from /api/data: {await response.Content.ReadAsStringAsync()}"); + } + + await transaction.Complete(); + } + catch (Exception ex) + { + Console.WriteLine($"UploadData Error: {ex.Message}"); + throw; + } + } } From 0826ef4c4e4fea7f068ee62999f3590cbbd8eb20 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 10 Feb 2026 12:19:20 +0200 Subject: [PATCH 12/19] Formatting --- .../DB/Schema/Attributes/AttributeParser.cs | 1 + .../DB/Schema/Attributes/IgnoredAttribute.cs | 5 +++++ PowerSync/PowerSync.Common/DB/Schema/Table.cs | 1 + Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs | 7 +++---- demos/MAUITodo/Data/AppSchema.cs | 4 ++-- 5 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs index 77baa2e..f6e1033 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -1,6 +1,7 @@ namespace PowerSync.Common.DB.Schema.Attributes; using System.Reflection; + using Dapper; internal class AttributeParser diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs new file mode 100644 index 0000000..824a25f --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs @@ -0,0 +1,5 @@ +namespace PowerSync.Common.DB.Schema.Attributes; + +[AttributeUsage(AttributeTargets.Property)] +public class IgnoredAttribute : Attribute { } + diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index 0ab757b..095fb8c 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -1,6 +1,7 @@ namespace PowerSync.Common.DB.Schema; using Newtonsoft.Json; + using PowerSync.Common.DB.Schema.Attributes; public class TableOptions( diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 31d4c22..fad8ba6 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -1,12 +1,11 @@ namespace PowerSync.Common.Tests.DB.Schema; -using PowerSync.Common.Tests.Utils; -using PowerSync.Common.DB.Schema; -using PowerSync.Common.DB.Schema.Attributes; - using System.Diagnostics; using PowerSync.Common.Client; +using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; +using PowerSync.Common.Tests.Utils; /// /// dotnet test -v n --framework net8.0 --filter "SchemaTests" diff --git a/demos/MAUITodo/Data/AppSchema.cs b/demos/MAUITodo/Data/AppSchema.cs index cdb239c..afbd66d 100644 --- a/demos/MAUITodo/Data/AppSchema.cs +++ b/demos/MAUITodo/Data/AppSchema.cs @@ -1,7 +1,7 @@ -using PowerSync.Common.DB.Schema; - using MAUITodo.Models; +using PowerSync.Common.DB.Schema; + class AppSchema { public static Table Todos = new Table(typeof(TodoItem)); From fecef0ffb182ea8d76e2075a6a882be10a9ba287 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 10 Feb 2026 12:29:50 +0200 Subject: [PATCH 13/19] Add IgnoredAttribute, update errors and error messages --- .../DB/Schema/Attributes/AttributeParser.cs | 16 ++++++++++------ .../DB/Schema/Attributes/IgnoredAttribute.cs | 2 +- .../DB/Schema/Attributes/TableAttribute.cs | 1 - .../PowerSync.Common/DB/Schema/ColumnJSON.cs | 3 +-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs index f6e1033..4beafd0 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -41,13 +41,18 @@ public Dictionary ParseColumns() foreach (var prop in _type.GetProperties()) { + if (prop.GetCustomAttribute() != null) continue; + var columnAttr = prop.GetCustomAttribute(); var columnName = columnAttr?.Name ?? prop.Name; // Handle 'id' field separately - // TODO prevent defining multiple id columns (eg. 'id', 'Id', 'ID') if (columnName.ToLowerInvariant() == "id") { + if (idProperty != null) + { + throw new InvalidOperationException($"Cannot define multiple ID columns for table '{_tableAttr.Name}'."); + } idProperty = prop; continue; } @@ -64,11 +69,11 @@ public Dictionary ParseColumns() // Validate 'id' property exists and is a string if (idProperty == null) { - throw new InvalidOperationException("A public string 'id' property is required."); + throw new InvalidOperationException($"An 'id' property is required for table '{_tableAttr.Name}'."); } if (idProperty.PropertyType != typeof(string)) { - throw new InvalidOperationException($"Property '{idProperty.Name}' must be of type string."); + throw new InvalidOperationException($"ID Property '{idProperty.Name}' must be of type string."); } var idAttr = idProperty.GetCustomAttribute(); if (idAttr != null) @@ -78,7 +83,7 @@ public Dictionary ParseColumns() { throw new InvalidOperationException ( - $"Property '{idProperty.Name}' must have ColumnType set to either ColumnType.Text or ColumnType.Inferred." + $"ID Property '{idProperty.Name}' must have ColumnType set to ColumnType.Text or ColumnType.Inferred." ); } } @@ -135,8 +140,7 @@ private ColumnType PropertyTypeToColumnType(Type propertyType) _ when propertyType == typeof(double) => ColumnType.Real, // Fallback - // TODO: Maybe raise a console warning / throw an error if unable to infer type? - _ => ColumnType.Text + _ => throw new InvalidOperationException($"Unable to automatically infer ColumnType of property type '{propertyType.Name}'."), }; } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs index 824a25f..c448119 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs @@ -1,5 +1,5 @@ namespace PowerSync.Common.DB.Schema.Attributes; -[AttributeUsage(AttributeTargets.Property)] +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] public class IgnoredAttribute : Attribute { } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs index 0d5fb94..60be649 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs @@ -3,7 +3,6 @@ namespace PowerSync.Common.DB.Schema.Attributes; [Flags] public enum TrackPrevious { - // TODO: Consider finding a method that can't represent invalid states (eg. TrackPrevious.Table | TrackPrevious.Columns) None = 0, Table = 1 << 0, Columns = 1 << 1, diff --git a/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs b/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs index 56bd92f..c8c91ed 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs @@ -26,8 +26,7 @@ class ColumnJSON(ColumnJSONOptions options) public object ToJSONObject() { - // TODO find good exception type / message, or catch/throw somewhere else - if (Type == ColumnType.Inferred) throw new InvalidOperationException("Attempted to serialise Inferred column."); + if (Type == ColumnType.Inferred) throw new InvalidOperationException("Attempted to serialise Inferred column. ColumnType.Inferred is only valid as an argument to ColumnAttribute."); return new { From 37e6fe04aa374b278ac9b6d70446f592890305b8 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 10 Feb 2026 12:34:53 +0200 Subject: [PATCH 14/19] Fix PropertyTypeToColumnType --- .../DB/Schema/Attributes/AttributeParser.cs | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs index 4beafd0..cd332ef 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -106,41 +106,41 @@ public Dictionary> ParseIndexes() private ColumnType PropertyTypeToColumnType(Type propertyType) { - var innerType = Nullable.GetUnderlyingType(propertyType); + Type underlyingType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; - return propertyType switch + return underlyingType switch { // TEXT types - _ when propertyType == typeof(string) => ColumnType.Text, - _ when propertyType == typeof(char) => ColumnType.Text, - _ when propertyType == typeof(Guid) => ColumnType.Text, - _ when propertyType == typeof(DateTime) => ColumnType.Text, - _ when propertyType == typeof(DateTimeOffset) => ColumnType.Text, - _ when propertyType == typeof(TimeSpan) => ColumnType.Text, + Type t when t == typeof(string) => ColumnType.Text, + Type t when t == typeof(char) => ColumnType.Text, + Type t when t == typeof(Guid) => ColumnType.Text, + Type t when t == typeof(DateTime) => ColumnType.Text, + Type t when t == typeof(DateTimeOffset) => ColumnType.Text, + Type t when t == typeof(TimeSpan) => ColumnType.Text, // 'decimal' is 128-bit, ColumnType.Real is only 64-bit - _ when propertyType == typeof(decimal) => ColumnType.Text, + Type t when t == typeof(decimal) => ColumnType.Text, // INTEGER types - _ when propertyType == typeof(Enum) => ColumnType.Integer, - _ when propertyType == typeof(bool) => ColumnType.Integer, // bool - _ when propertyType == typeof(sbyte) => ColumnType.Integer, // i8 - _ when propertyType == typeof(byte) => ColumnType.Integer, // u8 - _ when propertyType == typeof(short) => ColumnType.Integer, // i16 - _ when propertyType == typeof(ushort) => ColumnType.Integer, // u16 - _ when propertyType == typeof(int) => ColumnType.Integer, // i32 - _ when propertyType == typeof(uint) => ColumnType.Integer, // u32 - _ when propertyType == typeof(long) => ColumnType.Integer, // i64 - _ when propertyType == typeof(ulong) => ColumnType.Integer, // u64 + Type t when t.IsEnum => ColumnType.Integer, + Type t when t == typeof(bool) => ColumnType.Integer, // bool + Type t when t == typeof(sbyte) => ColumnType.Integer, // i8 + Type t when t == typeof(byte) => ColumnType.Integer, // u8 + Type t when t == typeof(short) => ColumnType.Integer, // i16 + Type t when t == typeof(ushort) => ColumnType.Integer, // u16 + Type t when t == typeof(int) => ColumnType.Integer, // i32 + Type t when t == typeof(uint) => ColumnType.Integer, // u32 + Type t when t == typeof(long) => ColumnType.Integer, // i64 + Type t when t == typeof(ulong) => ColumnType.Integer, // u64 // .NET 5.0+ only - // _ when propertyType == typeof(nint) => ColumnType.Integer, // isize - // _ when propertyType == typeof(nuint) => ColumnType.Integer, // usize + // Type t when t == typeof(nint) => ColumnType.Integer, // isize + // Type t when t == typeof(nuint) => ColumnType.Integer, // usize // REAL types - _ when propertyType == typeof(float) => ColumnType.Real, - _ when propertyType == typeof(double) => ColumnType.Real, + Type t when t == typeof(float) => ColumnType.Real, + Type t when t == typeof(double) => ColumnType.Real, // Fallback - _ => throw new InvalidOperationException($"Unable to automatically infer ColumnType of property type '{propertyType.Name}'."), + _ => throw new InvalidOperationException($"Unable to automatically infer ColumnType of property type '{underlyingType.Name}'."), }; } From 10af1f23b3cd106d260a8d26bb5451671e56543b Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 10 Feb 2026 12:56:58 +0200 Subject: [PATCH 15/19] Add IgnoredAttribute test --- Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index fad8ba6..3d1035a 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -34,6 +34,9 @@ class Asset public int quantity { get; set; } public string? description { get; set; } + + [Ignored] + public string non_table_field { get; set; } } [Fact] From 363e6131f1ed1a8a83b427eccfcd550e46a699d5 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 10 Feb 2026 13:41:26 +0200 Subject: [PATCH 16/19] Add more tests, update error types --- .../DB/Schema/Attributes/AttributeParser.cs | 6 +- .../PowerSync.Common.Tests/DB/SchemaTests.cs | 153 ++++++++++++++++-- 2 files changed, 147 insertions(+), 12 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs index cd332ef..da7dc4f 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -21,7 +21,7 @@ public AttributeParser(Type type) _tableAttr = _type.GetCustomAttribute(); if (_tableAttr == null) { - throw new CustomAttributeFormatException("Table classes must be marked with TableAttribute."); + throw new InvalidOperationException("Table classes must be marked with TableAttribute."); } } @@ -167,14 +167,14 @@ public TableOptions ParseTableOptions() if (trackPrevious.HasFlag(TrackPrevious.Columns) && trackPrevious.HasFlag(TrackPrevious.Table)) { - throw new CustomAttributeFormatException("Cannot specify both TrackPrevious.Columns and TrackPrevious.Table on the same table."); + throw new InvalidOperationException("Cannot specify both TrackPrevious.Columns and TrackPrevious.Table on the same table."); } if (!trackPrevious.HasFlag(TrackPrevious.Columns) && !trackPrevious.HasFlag(TrackPrevious.Table) && trackPrevious.HasFlag(TrackPrevious.OnlyWhenChanged)) { - throw new CustomAttributeFormatException("Cannot specify TrackPrevious.OnlyWhenChanged without also specifying either TrackPrevious.Columns or TrackPrevious.Table."); + throw new InvalidOperationException("Cannot specify TrackPrevious.OnlyWhenChanged without also specifying either TrackPrevious.Columns or TrackPrevious.Table."); } bool trackWholeTable = _tableAttr.TrackPreviousValues.HasFlag(TrackPrevious.Table); diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 3d1035a..4eb7e03 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -12,6 +12,13 @@ namespace PowerSync.Common.Tests.DB.Schema; /// public class SchemaTests { + private void TestParser(Type type, CompiledTable expected) + { + var parser = new AttributeParser(type); + var table = parser.ParseTable().Compile(); + Assert.Equivalent(expected, table, strict: true); + } + [ Table( "test_assets", @@ -132,6 +139,14 @@ public void AttributeParser_Products_Test() TestParser(typeof(Product), expected); } + enum LogLevel + { + Info, + Debug, + Warning, + Error + } + [ Table( "test_logs", @@ -141,11 +156,22 @@ public void AttributeParser_Products_Test() IgnoreEmptyUpdates = true ) ] - class Logs + class Log { - public string id { get; set; } - public string description { get; set; } - public DateTimeOffset timestamp { get; set; } + [Column("id")] + public string LogId { get; set; } + + [Column("description")] + public string Description { get; set; } + + [Column("timestamp")] + public DateTimeOffset Timestamp { get; set; } + + [Column("log_level")] + public LogLevel LogLevel { get; set; } + + [Ignored] + public string LogLevelString { get { return LogLevel.ToString(); } } } [Fact] @@ -157,6 +183,7 @@ public void AttributeParser_Logs_Test() { ["description"] = ColumnType.Text, ["timestamp"] = ColumnType.Text, + ["log_level"] = ColumnType.Integer, }, new TableOptions { @@ -170,13 +197,121 @@ public void AttributeParser_Logs_Test() } ); - TestParser(typeof(Logs), expected); + TestParser(typeof(Log), expected); } - private void TestParser(Type type, CompiledTable expected) + class Invalid1 { public string id { get; set; } } + [Fact] + public async void AttributeParser_InvalidSchema_1() { - var parser = new AttributeParser(type); - var table = parser.ParseTable().Compile(); - Assert.Equivalent(expected, table, strict: true); + var ex = await Assert.ThrowsAsync(async () => + { + new AttributeParser(typeof(Invalid1)).ParseTable(); + }); + Assert.Contains("must be marked with TableAttribute", ex.Message); + } + + [Table("invalid")] + class Invalid2 { } + [Fact] + public async void AttributeParser_InvalidSchema_2() + { + var ex = await Assert.ThrowsAsync(async () => + { + new AttributeParser(typeof(Invalid2)).ParseTable(); + }); + Assert.Contains("'id' property is required", ex.Message); + } + + [Table("invalid")] + class Invalid3 { public int id { get; set; } } + [Fact] + public async void AttributeParser_InvalidSchema_3() + { + var ex = await Assert.ThrowsAsync(async () => + { + new AttributeParser(typeof(Invalid3)).ParseTable(); + }); + Assert.Contains("must be of type string", ex.Message); + } + + [Table("invalid")] + class Invalid4 + { + [Column(ColumnType = ColumnType.Real)] + public string id { get; set; } + } + [Fact] + public async void AttributeParser_InvalidSchema_4() + { + var ex = await Assert.ThrowsAsync(async () => + { + new AttributeParser(typeof(Invalid4)).ParseTable(); + }); + Assert.Contains("must have ColumnType set to ColumnType.Text or ColumnType.Inferred", ex.Message); + } + + [Table("invalid")] + class Invalid5 + { + public string id { get; set; } + public Invalid1 invalid_type { get; set; } + } + [Fact] + public async void AttributeParser_InvalidSchema_5() + { + var ex = await Assert.ThrowsAsync(async () => + { + new AttributeParser(typeof(Invalid5)).ParseTable(); + }); + Assert.Contains("Unable to automatically infer ColumnType", ex.Message); + } + + [Table("invalid", TrackPreviousValues = TrackPrevious.Columns | TrackPrevious.Table)] + class Invalid6 + { + public string id { get; set; } + } + [Fact] + public async void AttributeParser_InvalidSchema_6() + { + var ex = await Assert.ThrowsAsync(async () => + { + new AttributeParser(typeof(Invalid6)).ParseTable(); + }); + Assert.Contains("Cannot specify both TrackPrevious.Columns and TrackPrevious.Table", ex.Message); + } + + [Table("invalid", TrackPreviousValues = TrackPrevious.OnlyWhenChanged)] + class Invalid7 + { + public string id { get; set; } + } + [Fact] + public async void AttributeParser_InvalidSchema_7() + { + var ex = await Assert.ThrowsAsync(async () => + { + new AttributeParser(typeof(Invalid7)).ParseTable(); + }); + Assert.Contains("Cannot specify TrackPrevious.OnlyWhenChanged without also specifying", ex.Message); + } + + [Fact] + public async void AttributeParser_TypeMap_CustomRegistered() + { + // Log has Column aliases + new AttributeParser(typeof(Log)).RegisterDapperTypeMap(); + var typeMap = Dapper.SqlMapper.GetTypeMap(typeof(Log)); + Assert.False(typeMap is Dapper.DefaultTypeMap); + } + + [Fact] + public async void AttributeParser_TypeMap_DefaultRegistered() + { + // Asset has no Column aliases + new AttributeParser(typeof(Asset)).RegisterDapperTypeMap(); + var typeMap = Dapper.SqlMapper.GetTypeMap(typeof(Asset)); + Assert.True(typeMap is Dapper.DefaultTypeMap); } } From e329b5f13a84984f23ca28cc5636a97a297b6e10 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 11 Feb 2026 12:29:05 +0200 Subject: [PATCH 17/19] Fix indexes not being created, better testing practices --- .../DB/Schema/CompiledSchema.cs | 9 +-- .../DB/Schema/CompiledTable.cs | 14 ++-- .../PowerSync.Common/DB/Schema/IndexJSON.cs | 2 +- .../DB/Schema/IndexedColumnJSON.cs | 20 ++--- .../Client/PowerSyncDatabaseTests.cs | 67 +++++++++++------ .../PowerSync.Common.Tests/DB/SchemaTests.cs | 70 +++++++++++++++++- .../PowerSync.Common.Tests/Models/Asset.cs | 38 ---------- .../PowerSync.Common.Tests/Models/Customer.cs | 17 ----- .../PowerSync.Common.Tests/TestSchema.cs | 74 ++++++++++++++++++- 9 files changed, 199 insertions(+), 112 deletions(-) delete mode 100644 Tests/PowerSync/PowerSync.Common.Tests/Models/Asset.cs delete mode 100644 Tests/PowerSync/PowerSync.Common.Tests/Models/Customer.cs diff --git a/PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs b/PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs index 8055e21..806b045 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs @@ -1,7 +1,6 @@ namespace PowerSync.Common.DB.Schema; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; class CompiledSchema(Dictionary tables) { @@ -27,13 +26,7 @@ public string ToJSON() { var jsonObject = new { - tables = Tables.Select(kv => - { - var json = JObject.Parse(kv.Value.ToJSON(kv.Key)); - var orderedJson = new JObject { ["name"] = kv.Key }; - orderedJson.Merge(json, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Concat }); - return orderedJson; - }).ToList() + tables = Tables.Select(kvp => kvp.Value.ToJSONObject()).ToArray(), }; return JsonConvert.SerializeObject(jsonObject); diff --git a/PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs b/PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs index 55a4da5..c5cf790 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs @@ -19,6 +19,10 @@ class CompiledTable public CompiledTable(string name, Dictionary columns, TableOptions options) { + Name = name; + Options = options; + Columns = columns; + ColumnsJSON = columns .Select(kvp => new ColumnJSON(new ColumnJSONOptions(kvp.Key, kvp.Value))) @@ -37,9 +41,6 @@ public CompiledTable(string name, Dictionary columns, TableO ) .ToArray(); - Name = name; - Columns = columns; - Options = options; Indexes = Options?.Indexes ?? []; } @@ -121,12 +122,13 @@ public void Validate() } } - public string ToJSON(string Name = "") + public object ToJSONObject() { var trackPrevious = Options.TrackPreviousValues; - var jsonObject = new + return new { + name = Name, view_name = Options.ViewName ?? Name, local_only = Options.LocalOnly, insert_only = Options.InsertOnly, @@ -143,7 +145,5 @@ public string ToJSON(string Name = "") }), include_old_only_when_changed = trackPrevious?.OnlyWhenChanged ?? false }; - - return JsonConvert.SerializeObject(jsonObject); } } diff --git a/PowerSync/PowerSync.Common/DB/Schema/IndexJSON.cs b/PowerSync/PowerSync.Common/DB/Schema/IndexJSON.cs index e316017..8435670 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/IndexJSON.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/IndexJSON.cs @@ -17,7 +17,7 @@ public object ToJSONObject(CompiledTable table) return new { name = Name, - columns = Columns.Select(column => column.ToJSON(table)).ToList() + columns = Columns.Select(column => column.ToJSONObject(table)).ToList() }; } } diff --git a/PowerSync/PowerSync.Common/DB/Schema/IndexedColumnJSON.cs b/PowerSync/PowerSync.Common/DB/Schema/IndexedColumnJSON.cs index 6e499a1..29ce1b1 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/IndexedColumnJSON.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/IndexedColumnJSON.cs @@ -1,7 +1,5 @@ namespace PowerSync.Common.DB.Schema; -using Newtonsoft.Json; - class IndexedColumnJSONOptions(string Name, bool Ascending = true) { public string Name { get; set; } = Name; @@ -14,17 +12,15 @@ class IndexedColumnJSON(IndexedColumnJSONOptions options) protected bool Ascending { get; set; } = options.Ascending; - public string ToJSON(CompiledTable table) + public object ToJSONObject(CompiledTable parentTable) { - var colType = table.Columns.TryGetValue(Name, out var value) ? value : default; + var colType = parentTable.Columns.TryGetValue(Name, out var value) ? value : default; - return JsonConvert.SerializeObject( - new - { - name = Name, - ascending = Ascending, - type = colType.ToString() - } - ); + return new + { + name = Name, + ascending = Ascending, + type = colType.ToString() + }; } } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index 2ba3612..87d3479 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -4,6 +4,8 @@ namespace PowerSync.Common.Tests.Client; using Microsoft.Data.Sqlite; +using Newtonsoft.Json; + using PowerSync.Common.Client; using PowerSync.Common.Tests.Models; @@ -100,28 +102,6 @@ await db.Execute( Assert.Null(row.make); } - [Fact] - public async Task QueryModelResult() - { - var id = Guid.NewGuid().ToString(); - var description = "Test description"; - var make = "Test make"; - var createdAt = DateTimeOffset.Now; - - await db.Execute( - "INSERT INTO assets(id, description, make, created_at) VALUES(?, ?, ?, ?)", - [id, description, make, createdAt] - ); - - var results = await db.GetAll("SELECT * FROM assets"); - Assert.Single(results); - var row = results.First(); - Assert.Equal(id, row.AssetId); - Assert.Equal(description, row.Description); - Assert.Equal(make, row.Make); - Assert.Equal(createdAt, row.CreatedAt); - } - [Fact] public async Task FailedInsertTest() { @@ -714,4 +694,47 @@ await db.Execute( await semAlwaysRunning.WaitAsync(); Assert.Equal(5, callCount); } + + [Fact] + public async Task Attributes_ColumnAliasing() + { + var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions + { + Database = new SQLOpenOptions { DbFilename = "PowerSyncAttributesTest.db" }, + Schema = TestSchemaAttributes.AppSchema, + }); + await db.DisconnectAndClear(); + + var id = Guid.NewGuid().ToString(); + var description = "Test description"; + var completed = false; + var createdAt = DateTimeOffset.Now; + + await db.Execute( + "INSERT INTO todos(id, description, completed, created_at, list_id) VALUES(?, ?, ?, ?, uuid())", + [id, description, completed, createdAt] + ); + + var results = await db.GetAll("SELECT * FROM todos"); + Assert.Single(results); + var row = results.First(); + Assert.Equal(id, row.TodoId); + Assert.Equal(description, row.Description); + Assert.Equal(completed, row.Completed); + Assert.Equal(createdAt, row.CreatedAt); + } + + [Fact] + public async Task IndexesCreatedOnTable() + { + dynamic[] result = await db.GetAll("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'ps_data__assets'"); + Assert.Equal(2, result.Length); + Assert.Equal("sqlite_autoindex_ps_data__assets_1", result[0].name); + Assert.Equal("ps_data__assets__makemodel", result[1].name); + + result = await db.GetAll("PRAGMA index_info('sqlite_autoindex_ps_data__assets_1')"); + Assert.Single(result); // id + result = await db.GetAll("PRAGMA index_info('ps_data__assets__makemodel')"); + Assert.Equal(2, result.Length); // make, model + } } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 4eb7e03..f311c4b 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -1,11 +1,10 @@ namespace PowerSync.Common.Tests.DB.Schema; -using System.Diagnostics; +using Newtonsoft.Json; -using PowerSync.Common.Client; using PowerSync.Common.DB.Schema; using PowerSync.Common.DB.Schema.Attributes; -using PowerSync.Common.Tests.Utils; +using PowerSync.Common.Tests; /// /// dotnet test -v n --framework net8.0 --filter "SchemaTests" @@ -314,4 +313,69 @@ public async void AttributeParser_TypeMap_DefaultRegistered() var typeMap = Dapper.SqlMapper.GetTypeMap(typeof(Asset)); Assert.True(typeMap is Dapper.DefaultTypeMap); } + + [Fact] + public void CompiledSchema_ToJSON() + { + object expectedJson = new + { + tables = new List + { + new + { + name = "todos", + view_name = "todos", + local_only = false, + insert_only = false, + columns = new List { + new { name = "list_id", type = "Text" }, + new { name = "created_at", type = "Text" }, + new { name = "completed_at", type = "Text" }, + new { name = "description", type = "Text" }, + new { name = "created_by", type = "Text" }, + new { name = "completed_by", type = "Text" }, + new { name = "completed", type = "Integer" }, + }, + indexes = new List { + new { + name = "list", + columns = new List { + new { name = "list_id", ascending = true, type = "Text" }, + } + }, + new { + name = "list_rev", + columns = new List { + new { name = "list_id", ascending = false, type = "Text" }, + } + } + }, + include_metadata = false, + ignore_empty_update = false, + include_old = false, + include_old_only_when_changed = false + }, + new + { + name = "lists", + view_name = "lists", + local_only = false, + insert_only = false, + columns = new List { + new { name = "created_at", type = "Text" }, + new { name = "name", type = "Text" }, + new { name = "owner_id", type = "Text" } + }, + indexes = new List(), + include_metadata = false, + ignore_empty_update = false, + include_old = false, + include_old_only_when_changed = false + }, + } + }; + var schema = TestSchemaTodoList.AppSchema.Compile(); + + Assert.Equal(JsonConvert.SerializeObject(expectedJson), schema.ToJSON()); + } } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Models/Asset.cs b/Tests/PowerSync/PowerSync.Common.Tests/Models/Asset.cs deleted file mode 100644 index c627599..0000000 --- a/Tests/PowerSync/PowerSync.Common.Tests/Models/Asset.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace PowerSync.Common.Tests.Models; - -using PowerSync.Common.DB.Schema; -using PowerSync.Common.DB.Schema.Attributes; - -[ - Table("assets"), - Index("makemodel", ["make", "model"]) -] -public class Asset -{ - [Column("id")] - public string AssetId { get; set; } - - [Column("created_at")] - public DateTime CreatedAt { get; set; } - - [Column("make")] - public string Make { get; set; } - - [Column("model")] - public string Model { get; set; } - - [Column("serial_number")] - public string SerialNumber { get; set; } - - [Column("quantity")] - public int Quantity { get; set; } - - [Column("user_id")] - public string UserId { get; set; } - - [Column("customer_id")] - public string CustomerId { get; set; } - - [Column("description")] - public string Description { get; set; } -} diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Models/Customer.cs b/Tests/PowerSync/PowerSync.Common.Tests/Models/Customer.cs deleted file mode 100644 index 747e544..0000000 --- a/Tests/PowerSync/PowerSync.Common.Tests/Models/Customer.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace PowerSync.Common.Tests.Models; - -using PowerSync.Common.DB.Schema; -using PowerSync.Common.DB.Schema.Attributes; - -[Table("customers")] -public class Customer -{ - [Column("id")] - public string CustomerId { get; set; } - - [Column("name")] - public string Name { get; set; } - - [Column("email")] - public string Email { get; set; } -} diff --git a/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs b/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs index a0215d9..d404e1b 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs @@ -3,24 +3,90 @@ namespace PowerSync.Common.Tests; using PowerSync.Common.DB.Schema; using PowerSync.Common.Tests.Models; -public class TestSchemaTodoList +public class TestSchemaAttributes { public static Table Todos = new Table(typeof(Todo)); public static Table Lists = new Table(typeof(List)); + public static Schema AppSchema = new Schema(Todos, Lists); +} + +public class TestSchemaTodoList +{ + public static Table Todos = new Table + { + Name = "todos", + Columns = + { + ["list_id"] = ColumnType.Text, + ["created_at"] = ColumnType.Text, + ["completed_at"] = ColumnType.Text, + ["description"] = ColumnType.Text, + ["created_by"] = ColumnType.Text, + ["completed_by"] = ColumnType.Text, + ["completed"] = ColumnType.Integer, + }, + Indexes = + { + ["list"] = ["list_id"], + ["list_rev"] = ["-list_id"] + } + }; + + public static Table Lists = new Table + { + Name = "lists", + Columns = + { + ["created_at"] = ColumnType.Text, + ["name"] = ColumnType.Text, + ["owner_id"] = ColumnType.Text, + } + }; + public static readonly Schema AppSchema = new Schema(Todos, Lists); } public class TestSchema { - public static readonly Table Assets = new Table(typeof(Asset)); - public static readonly Table Customers = new Table(typeof(Customer)); + public static readonly Dictionary AssetsColumns = new() + { + ["created_at"] = ColumnType.Text, + ["make"] = ColumnType.Text, + ["model"] = ColumnType.Text, + ["serial_number"] = ColumnType.Text, + ["quantity"] = ColumnType.Integer, + ["user_id"] = ColumnType.Text, + ["customer_id"] = ColumnType.Text, + ["description"] = ColumnType.Text, + }; + + public static readonly Table Assets = new Table + { + Name = "assets", + Columns = AssetsColumns, + Indexes = + { + ["makemodel"] = ["make", "model"] + } + }; + + public static readonly Table Customers = new Table + { + Name = "customers", + Columns = + { + ["name"] = ColumnType.Text, + ["email"] = ColumnType.Text, + } + }; public static readonly Schema AppSchema = new Schema(Assets, Customers); public static Schema GetSchemaWithCustomAssetOptions(TableOptions? assetOptions = null) { - var customAssets = new Table(Assets, assetOptions); + var customAssets = new Table("assets", AssetsColumns, assetOptions); + return new Schema(customAssets, Customers); } } From d438ff57674bdee0b5c6b9d8e644dc46f696ded6 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 11 Feb 2026 15:01:18 +0200 Subject: [PATCH 18/19] Changelog --- PowerSync/PowerSync.Common/CHANGELOG.md | 40 +++++++++++++++++++ .../DB/Schema/Attributes/AttributeParser.cs | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index 5e01c47..0091701 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -1,5 +1,45 @@ # PowerSync.Common Changelog +## 0.0.10-alpha.1 + +- Fixed a bug where custom indexes were not being sent to the PowerSync SQLite extension. +- Added a new model-based syntax for defining the PowerSync schema (the old syntax is still functional). This syntax uses classes marked with attributes to define the PowerSync schema. The classes can then also be used for queries later on. + +```csharp +using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; + +[ + Table("todos"), + Index("list", ["list_id"]) +] +public class Todo +{ + [Column("id")] + public string TodoId { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + + [Column("name")] + public string Name { get; set; } + + [Column("description")] + public string? Description { get; set; } + + [Column("completed")] + public bool Completed { get; set; } +} + +public class Schema +{ + public static Schema AppSchema = new Schema(typeof(Todo)); +} + +// Usage +var todos = powerSync.GetAll("SELECT * FROM todos"); +``` + ## 0.0.9-alpha.1 - _Breaking:_ Further updated schema definition syntax. diff --git a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs index da7dc4f..5344316 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -4,7 +4,7 @@ namespace PowerSync.Common.DB.Schema.Attributes; using Dapper; -internal class AttributeParser +class AttributeParser { private readonly Type _type; private readonly TableAttribute _tableAttr; From 3b2d0223374be1dfdef43fff158ef2b60d681b96 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Wed, 11 Feb 2026 15:02:38 +0200 Subject: [PATCH 19/19] MAUI Changelog --- PowerSync/PowerSync.Maui/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/PowerSync/PowerSync.Maui/CHANGELOG.md b/PowerSync/PowerSync.Maui/CHANGELOG.md index f903a1d..3ca510c 100644 --- a/PowerSync/PowerSync.Maui/CHANGELOG.md +++ b/PowerSync/PowerSync.Maui/CHANGELOG.md @@ -1,5 +1,9 @@ # PowerSync.Maui Changelog +## 0.0.8-alpha.1 + +- Upstream PowerSync.Common version bump (See Powersync.Common changelog 0.0.10-alpha.1 for more information) + ## 0.0.7-alpha.1 - Upstream PowerSync.Common version bump (See Powersync.Common changelog 0.0.9-alpha.1 for more information)