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 new file mode 100644 index 0000000..5344316 --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs @@ -0,0 +1,227 @@ +namespace PowerSync.Common.DB.Schema.Attributes; + +using System.Reflection; + +using Dapper; + +class AttributeParser +{ + private readonly Type _type; + private readonly TableAttribute _tableAttr; + + public string TableName + { + get { return _tableAttr.Name; } + } + + public AttributeParser(Type type) + { + _type = type; + + _tableAttr = _type.GetCustomAttribute(); + if (_tableAttr == null) + { + throw new InvalidOperationException("Table classes must be marked with TableAttribute."); + } + } + + public Table ParseTable() + { + return new Table( + name: _tableAttr.Name, + columns: ParseColumns(), + options: ParseTableOptions() + ); + } + + public Dictionary ParseColumns() + { + var columns = new Dictionary(); + PropertyInfo? idProperty = null; + + 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 + if (columnName.ToLowerInvariant() == "id") + { + if (idProperty != null) + { + throw new InvalidOperationException($"Cannot define multiple ID columns for table '{_tableAttr.Name}'."); + } + idProperty = prop; + continue; + } + + var userColumnType = columnAttr?.ColumnType ?? ColumnType.Inferred; + + // Infer column type from property's type + var columnType = userColumnType == ColumnType.Inferred + ? PropertyTypeToColumnType(prop.PropertyType) + : userColumnType; + columns.Add(columnName, columnType); + } + + // Validate 'id' property exists and is a string + if (idProperty == null) + { + throw new InvalidOperationException($"An 'id' property is required for table '{_tableAttr.Name}'."); + } + if (idProperty.PropertyType != typeof(string)) + { + throw new InvalidOperationException($"ID 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 + ( + $"ID Property '{idProperty.Name}' must have ColumnType set to ColumnType.Text or ColumnType.Inferred." + ); + } + } + + return columns; + } + + public Dictionary> ParseIndexes() + { + 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) + { + Type underlyingType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + + return underlyingType switch + { + // TEXT types + 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 + Type t when t == typeof(decimal) => ColumnType.Text, + + // INTEGER types + 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 + // Type t when t == typeof(nint) => ColumnType.Integer, // isize + // Type t when t == typeof(nuint) => ColumnType.Integer, // usize + + // REAL types + 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 '{underlyingType.Name}'."), + }; + } + + public TableOptions ParseTableOptions() + { + return new TableOptions( + indexes: ParseIndexes(), + localOnly: _tableAttr.LocalOnly, + insertOnly: _tableAttr.InsertOnly, + viewName: _tableAttr.ViewName, + trackMetadata: _tableAttr.TrackMetadata, + trackPreviousValues: ParseTrackPreviousOptions(), + ignoreEmptyUpdates: _tableAttr.IgnoreEmptyUpdates + ); + } + + public TrackPreviousOptions? ParseTrackPreviousOptions() + { + TrackPrevious trackPrevious = _tableAttr.TrackPreviousValues; + if (trackPrevious == TrackPrevious.None) + { + return null; + } + + if (trackPrevious.HasFlag(TrackPrevious.Columns) && trackPrevious.HasFlag(TrackPrevious.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 InvalidOperationException("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 : ParseTrackedColumns(), + OnlyWhenChanged = onlyWhenChanged, + }; + } + + 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()) + { + 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..c8597c8 --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs @@ -0,0 +1,14 @@ +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/Attributes/IgnoredAttribute.cs b/PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs new file mode 100644 index 0000000..c448119 --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/IgnoredAttribute.cs @@ -0,0 +1,5 @@ +namespace PowerSync.Common.DB.Schema.Attributes; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class IgnoredAttribute : Attribute { } + 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..60be649 --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs @@ -0,0 +1,27 @@ +namespace PowerSync.Common.DB.Schema.Attributes; + +[Flags] +public enum TrackPrevious +{ + 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; + } +} diff --git a/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs b/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs index 80c74bd..c8c91ed 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,8 @@ class ColumnJSON(ColumnJSONOptions options) public object ToJSONObject() { + if (Type == ColumnType.Inferred) throw new InvalidOperationException("Attempted to serialise Inferred column. ColumnType.Inferred is only valid as an argument to ColumnAttribute."); + return new { name = Name, 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 8a1e5d3..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 ?? []; } @@ -50,6 +51,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 +79,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}"); @@ -108,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, @@ -130,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/PowerSync/PowerSync.Common/DB/Schema/Schema.cs b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs index 8b932b3..7fca5c0 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs @@ -1,5 +1,7 @@ namespace PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; + public class Schema { private readonly List _tables; @@ -9,6 +11,18 @@ public Schema(params Table[] tables) _tables = tables.ToList(); } + public Schema(params Type[] types) + { + _tables = new(); + var indexes = new Dictionary>(); + foreach (Type type in types) + { + var parser = new AttributeParser(type); + parser.RegisterDapperTypeMap(); + _tables.Add(parser.ParseTable()); + } + } + internal CompiledSchema Compile() { Dictionary tableMap = new(); diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index bc4b01b..095fb8c 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -2,6 +2,8 @@ namespace PowerSync.Common.DB.Schema; using Newtonsoft.Json; +using PowerSync.Common.DB.Schema.Attributes; + public class TableOptions( Dictionary>? indexes = null, bool? localOnly = null, @@ -62,12 +64,11 @@ 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; } - public string Name { get; set; } = null!; + // Accessors public Dictionary> Indexes { get { return Options.Indexes; } @@ -106,9 +107,28 @@ 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.ParseColumns(); + Options = options ?? parser.ParseTableOptions(); + parser.RegisterDapperTypeMap(); + } + + 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) { 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/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) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index d3af172..87d3479 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -4,7 +4,10 @@ namespace PowerSync.Common.Tests.Client; using Microsoft.Data.Sqlite; +using Newtonsoft.Json; + using PowerSync.Common.Client; +using PowerSync.Common.Tests.Models; /// /// dotnet test -v n --framework net8.0 --filter "PowerSyncDatabaseTests" @@ -83,11 +86,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,7 +98,7 @@ 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); } @@ -646,7 +649,7 @@ await db.Execute( Assert.Equal(2, callCount); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 2500)] public async void WatchSingleCancelledTest() { int callCount = 0; @@ -691,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 new file mode 100644 index 0000000..f311c4b --- /dev/null +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -0,0 +1,381 @@ +namespace PowerSync.Common.Tests.DB.Schema; + +using Newtonsoft.Json; + +using PowerSync.Common.DB.Schema; +using PowerSync.Common.DB.Schema.Attributes; +using PowerSync.Common.Tests; + +/// +/// dotnet test -v n --framework net8.0 --filter "SchemaTests" +/// +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", + 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; } + + [Ignored] + public string non_table_field { 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 DateTime 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); + } + + enum LogLevel + { + Info, + Debug, + Warning, + Error + } + + [ + Table( + "test_logs", + LocalOnly = true, + InsertOnly = true, + ViewName = "logs_local", + IgnoreEmptyUpdates = true + ) + ] + class Log + { + [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] + public void AttributeParser_Logs_Test() + { + var expected = new CompiledTable( + "test_logs", + new Dictionary + { + ["description"] = ColumnType.Text, + ["timestamp"] = ColumnType.Text, + ["log_level"] = ColumnType.Integer, + }, + new TableOptions + { + Indexes = new Dictionary>(), + LocalOnly = true, + InsertOnly = true, + ViewName = "logs_local", + TrackMetadata = false, + TrackPreviousValues = null, + IgnoreEmptyUpdates = true, + } + ); + + TestParser(typeof(Log), expected); + } + + class Invalid1 { public string id { get; set; } } + [Fact] + public async void AttributeParser_InvalidSchema_1() + { + 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); + } + + [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/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..d404e1b 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/TestSchema.cs @@ -1,6 +1,15 @@ namespace PowerSync.Common.Tests; using PowerSync.Common.DB.Schema; +using PowerSync.Common.Tests.Models; + +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 { @@ -19,7 +28,8 @@ public class TestSchemaTodoList }, Indexes = { - ["list"] = ["list_id"] + ["list"] = ["list_id"], + ["list_rev"] = ["-list_id"] } }; diff --git a/demos/CommandLine/Demo.cs b/demos/CommandLine/Demo.cs index d1b861a..62abe10 100644 --- a/demos/CommandLine/Demo.cs +++ b/demos/CommandLine/Demo.cs @@ -1,153 +1,154 @@ -namespace CommandLine; - +namespace CommandLine; + using System.Reflection; using CommandLine.Utils; -using PowerSync.Common.Client; +using PowerSync.Common.Client; using PowerSync.Common.Client.Connection; -using Spectre.Console; - -class Demo -{ - private record ListResult +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() { - public string id; - public string owner_id; - public string name; - public string created_at; + var version = Assembly.GetExecutingAssembly().GetName().Version; + return version?.ToString() ?? "unknown"; } - 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; + } + } } diff --git a/demos/MAUITodo/Data/AppSchema.cs b/demos/MAUITodo/Data/AppSchema.cs index e261fd0..afbd66d 100644 --- a/demos/MAUITodo/Data/AppSchema.cs +++ b/demos/MAUITodo/Data/AppSchema.cs @@ -1,36 +1,11 @@ +using MAUITodo.Models; + using PowerSync.Common.DB.Schema; 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 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/Models/TodoItem.cs b/demos/MAUITodo/Models/TodoItem.cs index 2dcfb7e..e13ee8f 100644 --- a/demos/MAUITodo/Models/TodoItem.cs +++ b/demos/MAUITodo/Models/TodoItem.cs @@ -1,30 +1,34 @@ -using Newtonsoft.Json; +namespace MAUITodo.Models; -namespace MAUITodo.Models; +using PowerSync.Common.DB.Schema.Attributes; +[ + Table("todos"), + Index("list", ["list_id"]) +] public class TodoItem { - [JsonProperty("id")] + [Column("id")] public string ID { get; set; } = ""; - [JsonProperty("list_id")] + [Column("list_id")] public string ListId { get; set; } = null!; - [JsonProperty("created_at")] + [Column("created_at")] public string CreatedAt { get; set; } = null!; - [JsonProperty("completed_at")] + [Column("completed_at")] public string? CompletedAt { get; set; } - [JsonProperty("description")] + [Column("description")] public string Description { get; set; } = null!; - [JsonProperty("created_by")] + [Column("created_by")] public string CreatedBy { get; set; } = null!; - [JsonProperty("completed_by")] + [Column("completed_by")] public string CompletedBy { get; set; } = null!; - [JsonProperty("completed")] + [Column("completed")] public bool Completed { get; set; } } diff --git a/demos/MAUITodo/Models/TodoList.cs b/demos/MAUITodo/Models/TodoList.cs index 1b19f4f..22c418a 100644 --- a/demos/MAUITodo/Models/TodoList.cs +++ b/demos/MAUITodo/Models/TodoList.cs @@ -1,18 +1,19 @@ -using Newtonsoft.Json; - namespace MAUITodo.Models; +using PowerSync.Common.DB.Schema.Attributes; + +[Table("lists")] public class TodoList { - [JsonProperty("id")] + [Column("id")] public string ID { get; set; } = ""; - [JsonProperty("created_at")] + [Column("created_at")] public string CreatedAt { get; set; } = null!; - [JsonProperty("name")] + [Column("name")] public string Name { get; set; } = null!; - [JsonProperty("owner_id")] + [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