From eb02f9702e006a9b6b79d2480e2fd0806a813a82 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Fri, 12 Jun 2026 16:16:21 +0200 Subject: [PATCH 1/4] Remove 'Compiled*' schema objects --- .../Client/PowerSyncDatabase.cs | 11 +- .../PowerSync.Common/DB/Schema/ColumnJSON.cs | 37 ----- .../PowerSync.Common/DB/Schema/ColumnType.cs | 13 ++ .../DB/Schema/CompiledSchema.cs | 34 ---- .../DB/Schema/CompiledTable.cs | 149 ----------------- .../PowerSync.Common/DB/Schema/IndexJSON.cs | 23 --- .../DB/Schema/IndexedColumnJSON.cs | 26 --- .../PowerSync.Common/DB/Schema/Schema.cs | 38 +++-- PowerSync/PowerSync.Common/DB/Schema/Table.cs | 150 +++++++++++++++++- .../PowerSync.Common.Tests/DB/SchemaTests.cs | 18 +-- 10 files changed, 199 insertions(+), 300 deletions(-) delete mode 100644 PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs create mode 100644 PowerSync/PowerSync.Common/DB/Schema/ColumnType.cs delete mode 100644 PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs delete mode 100644 PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs delete mode 100644 PowerSync/PowerSync.Common/DB/Schema/IndexJSON.cs delete mode 100644 PowerSync/PowerSync.Common/DB/Schema/IndexedColumnJSON.cs diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index d547ec2..f14f1c5 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -130,7 +130,7 @@ public interface IPowerSyncDatabase : ICloseableAsync public class PowerSyncDatabase : IPowerSyncDatabase { public IDBAdapter Database { get; protected set; } - private CompiledSchema schema; + private Schema schema; private const int DEFAULT_WATCH_THROTTLE_MS = 30; private static readonly Regex POWERSYNC_TABLE_MATCH = new Regex(@"(^ps_data__|^ps_data_local__)", RegexOptions.Compiled); @@ -199,7 +199,7 @@ public PowerSyncDatabase(PowerSyncDatabaseOptions options) Closed = false; Ready = false; - schema = options.Schema.Compile(); + schema = options.Schema; SdkVersion = ""; remoteFactory = options.RemoteFactory ?? (connector => new Remote(connector)); @@ -402,7 +402,6 @@ protected async Task ResolveOfflineSyncStatus() /// public async Task UpdateSchema(Schema schema) { - CompiledSchema compiledSchema = schema.Compile(); if (syncStreamImplementation != null) { throw new Exception("Cannot update schema while connected"); @@ -410,15 +409,15 @@ public async Task UpdateSchema(Schema schema) try { - compiledSchema.Validate(); + schema.Validate(); } catch (Exception ex) { Logger.LogWarning("Schema validation failed. Unexpected behavior could occur: {Exception}", ex); } - this.schema = compiledSchema; - await Database.Execute("SELECT powersync_replace_schema(?)", [compiledSchema.ToJSON()]); + this.schema = schema; + await Database.Execute("SELECT powersync_replace_schema(?)", [JsonConvert.SerializeObject(schema)]); await Database.RefreshSchema(); Events.Emit(new PowerSyncDBEvents.SchemaChangedEvent(schema)); } diff --git a/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs b/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs deleted file mode 100644 index c8c91ed..0000000 --- a/PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace PowerSync.Common.DB.Schema; - -public enum ColumnType -{ - Text, - Integer, - 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) -{ - public string Name { get; set; } = Name; - public ColumnType? Type { get; set; } = Type; -} - -class ColumnJSON(ColumnJSONOptions options) -{ - public string Name { get; set; } = options.Name; - - public ColumnType Type { get; set; } = options.Type ?? ColumnType.Text; - - 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, - type = Type.ToString() - }; - } -} diff --git a/PowerSync/PowerSync.Common/DB/Schema/ColumnType.cs b/PowerSync/PowerSync.Common/DB/Schema/ColumnType.cs new file mode 100644 index 0000000..abfce44 --- /dev/null +++ b/PowerSync/PowerSync.Common/DB/Schema/ColumnType.cs @@ -0,0 +1,13 @@ +namespace PowerSync.Common.DB.Schema; + +public enum ColumnType +{ + Text, + Integer, + 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 +} diff --git a/PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs b/PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs deleted file mode 100644 index 806b045..0000000 --- a/PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace PowerSync.Common.DB.Schema; - -using Newtonsoft.Json; - -class CompiledSchema(Dictionary tables) -{ - private readonly Dictionary Tables = tables; - - public void Validate() - { - foreach (var kvp in Tables) - { - var tableName = kvp.Key; - var table = kvp.Value; - - if (CompiledTable.InvalidSQLCharacters.IsMatch(tableName)) - { - throw new Exception($"Invalid characters in table name: {tableName}"); - } - - table.Validate(); - } - } - - public string ToJSON() - { - var jsonObject = new - { - 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 deleted file mode 100644 index 9898ef5..0000000 --- a/PowerSync/PowerSync.Common/DB/Schema/CompiledTable.cs +++ /dev/null @@ -1,149 +0,0 @@ -namespace PowerSync.Common.DB.Schema; - -using System.Collections.Generic; -using System.Text.RegularExpressions; - -using Newtonsoft.Json; - -class CompiledTable -{ - public static readonly Regex InvalidSQLCharacters = new Regex(@"[""'%,.#\s\[\]]", RegexOptions.Compiled); - - public string Name { get; init; } = null!; - protected TableOptions Options { get; init; } = null!; - public IReadOnlyDictionary Columns { get; init; } - public IReadOnlyDictionary> Indexes { get; init; } - - private readonly ColumnJSON[] ColumnsJSON; - private readonly IndexJSON[] IndexesJSON; - - 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))) - .ToArray(); - - IndexesJSON = - (Options?.Indexes ?? []) - .Select(kvp => - new IndexJSON(new IndexJSONOptions( - kvp.Key, - kvp.Value.Select(name => - new IndexedColumnJSON(new IndexedColumnJSONOptions( - name.Replace("-", ""), !name.StartsWith("-"))) - ).ToArray() - )) - ) - .ToArray(); - - Indexes = Options?.Indexes ?? []; - } - - public void Validate() - { - if (string.IsNullOrWhiteSpace(Name)) - { - 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}"); - } - - if (Columns.Count > Table.MAX_AMOUNT_OF_COLUMNS) - { - throw new Exception( - $"Table has too many columns. The maximum number of columns is {Table.MAX_AMOUNT_OF_COLUMNS}."); - } - - if (Options.TrackMetadata && Options.LocalOnly) - { - throw new Exception("Can't include metadata for local-only tables."); - } - - if (Options.TrackPreviousValues != null && Options.LocalOnly) - { - throw new Exception("Can't include old values for local-only tables."); - } - - var columnNames = new HashSet { "id" }; - - 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}"); - } - - columnNames.Add(columnName); - } - - foreach (var index in IndexesJSON) - { - var indexName = index.Name; - var indexColumns = index.Columns; - - if (InvalidSQLCharacters.IsMatch(indexName)) - { - throw new Exception($"Invalid characters in index name: {indexName}"); - } - - foreach (var indexColumn in indexColumns) - { - if (!columnNames.Contains(indexColumn.Name)) - { - throw new Exception($"Column {indexColumn.Name} not found for index {indexName}"); - } - } - } - } - - public object ToJSONObject() - { - var trackPrevious = Options.TrackPreviousValues; - - return new - { - name = Name, - view_name = Options.ViewName ?? Name, - local_only = Options.LocalOnly, - insert_only = Options.InsertOnly, - columns = ColumnsJSON.Select(c => c.ToJSONObject()).ToList(), - indexes = IndexesJSON.Select(i => i.ToJSONObject(this)).ToList(), - - include_metadata = Options.TrackMetadata, - ignore_empty_update = Options.IgnoreEmptyUpdates, - include_old = (object)(trackPrevious switch - { - null => false, - { Columns: null } => true, - { Columns: var cols } => cols - }), - include_old_only_when_changed = trackPrevious?.OnlyWhenChanged ?? false - }; - } -} diff --git a/PowerSync/PowerSync.Common/DB/Schema/IndexJSON.cs b/PowerSync/PowerSync.Common/DB/Schema/IndexJSON.cs deleted file mode 100644 index 8435670..0000000 --- a/PowerSync/PowerSync.Common/DB/Schema/IndexJSON.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace PowerSync.Common.DB.Schema; - -class IndexJSONOptions(string name, IndexedColumnJSON[]? columns = null) -{ - public string Name { get; set; } = name; - public IndexedColumnJSON[]? Columns { get; set; } = columns ?? []; -} - -class IndexJSON(IndexJSONOptions options) -{ - public string Name { get; set; } = options.Name; - - public IndexedColumnJSON[] Columns => options.Columns ?? []; - - public object ToJSONObject(CompiledTable table) - { - return new - { - name = Name, - 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 deleted file mode 100644 index 347cfa2..0000000 --- a/PowerSync/PowerSync.Common/DB/Schema/IndexedColumnJSON.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace PowerSync.Common.DB.Schema; - -class IndexedColumnJSONOptions(string Name, bool Ascending = true) -{ - public string Name { get; set; } = Name; - public bool Ascending { get; set; } = Ascending; -} - -class IndexedColumnJSON(IndexedColumnJSONOptions options) -{ - public string Name { get; set; } = options.Name; - - public bool Ascending { get; set; } = options.Ascending; - - public object ToJSONObject(CompiledTable parentTable) - { - var colType = parentTable.Columns.TryGetValue(Name, out var value) ? value : default; - - 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 7fca5c0..dc8d7b0 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Schema.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Schema.cs @@ -1,20 +1,24 @@ namespace PowerSync.Common.DB.Schema; +using Newtonsoft.Json; + using PowerSync.Common.DB.Schema.Attributes; +[JsonConverter(typeof(SchemaJsonConverter))] public class Schema { private readonly List _tables; + public IReadOnlyList
Tables => _tables; + public Schema(params Table[] tables) { - _tables = tables.ToList(); + _tables = [.. tables]; } public Schema(params Type[] types) { - _tables = new(); - var indexes = new Dictionary>(); + _tables = []; foreach (Type type in types) { var parser = new AttributeParser(type); @@ -23,14 +27,30 @@ public Schema(params Type[] types) } } - internal CompiledSchema Compile() + public void Validate() { - Dictionary tableMap = new(); - foreach (Table table in _tables) + foreach (var table in _tables) { - var compiled = table.Compile(); - tableMap[compiled.Name] = compiled; + table.Validate(); } - return new CompiledSchema(tableMap); + } +} + +/// +/// Serializes a into the JSON format expected by the +/// `powersync_replace_schema` SQLite function. +/// +public class SchemaJsonConverter : JsonConverter +{ + public override bool CanRead => false; + + public override Schema ReadJson(JsonReader reader, Type objectType, Schema? existingValue, bool hasExistingValue, JsonSerializer serializer) + => throw new NotSupportedException("Deserializing a Schema from JSON is not supported."); + + public override void WriteJson(JsonWriter writer, Schema? value, JsonSerializer serializer) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + + serializer.Serialize(writer, new { tables = value.Tables }); } } diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index eddabe9..cd2f933 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -1,5 +1,7 @@ namespace PowerSync.Common.DB.Schema; +using System.Text.RegularExpressions; + using Newtonsoft.Json; using PowerSync.Common.DB.Schema.Attributes; @@ -60,13 +62,17 @@ public class TrackPreviousOptions public bool? OnlyWhenChanged { get; set; } } +[JsonConverter(typeof(TableJsonConverter))] public class Table { + public static readonly Regex InvalidSQLCharacters = new Regex(@"[""'%,.#\s\[\]]", RegexOptions.Compiled); + public const int MAX_AMOUNT_OF_COLUMNS = 1999; + public string Name { get; set; } + public Dictionary Columns { get; set; } public TableOptions Options { get; set; } - public string Name { get; set; } = null!; // Accessors public Dictionary> Indexes @@ -107,10 +113,17 @@ public bool IgnoreEmptyUpdates public Table() { - Columns = new Dictionary(); + Name = ""; + Columns = []; Options = new TableOptions(); } + /// + /// Generate a table implementation from a Type object and registers its shape with the + /// internal Dapper type mapper. + /// + /// The given type is required to have the attribute. + /// public Table(Type type, TableOptions? options = null) { var parser = new AttributeParser(type); @@ -120,6 +133,9 @@ public Table(Type type, TableOptions? options = null) parser.RegisterDapperTypeMap(); } + /// + /// Clone the table "" with an optional override for table options. + /// public Table(Table other, TableOptions? options = null) { if (other == null) throw new ArgumentNullException(nameof(other)); @@ -129,7 +145,6 @@ public Table(Table other, TableOptions? options = null) 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) { Name = name; @@ -137,14 +152,137 @@ public Table(string name, Dictionary columns, TableOptions? Options = options ?? new TableOptions(); } - internal CompiledTable Compile() + public void Validate() { if (string.IsNullOrWhiteSpace(Name)) { - throw new InvalidOperationException("Table name is required."); + 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}"); + } + + if (Columns.Count > MAX_AMOUNT_OF_COLUMNS) + { + throw new Exception( + $"Table has too many columns. The maximum number of columns is {MAX_AMOUNT_OF_COLUMNS}."); + } + + if (Options.TrackMetadata && Options.LocalOnly) + { + throw new Exception("Can't include metadata for local-only tables."); + } + + if (Options.TrackPreviousValues != null && Options.LocalOnly) + { + throw new Exception("Can't include old values for local-only tables."); + } + + var columnNames = new HashSet { "id" }; + + 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}"); + } + + columnNames.Add(columnName); } - return new CompiledTable(Name, Columns, Options); + foreach (var index in Indexes) + { + var indexName = index.Key; + var indexColumns = index.Value; + + if (InvalidSQLCharacters.IsMatch(indexName)) + { + throw new Exception($"Invalid characters in index name: {indexName}"); + } + + foreach (var column in indexColumns) + { + if (!columnNames.Contains(column.TrimStart('-'))) + { + throw new Exception($"Column {column} not found for index {indexName}"); + } + } + } + } +} + +/// +/// Serializes a into the JSON format expected by the +/// `powersync_replace_schema` SQLite function. +/// +public class TableJsonConverter : JsonConverter
+{ + public override bool CanRead => false; + + public override Table ReadJson(JsonReader reader, Type objectType, Table? existingValue, bool hasExistingValue, JsonSerializer serializer) + => throw new NotSupportedException("Deserializing a Table from JSON is not supported."); + + public override void WriteJson(JsonWriter writer, Table? value, JsonSerializer serializer) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + + var trackPrevious = value.TrackPreviousValues; + + serializer.Serialize(writer, new + { + name = value.Name, + view_name = value.ViewName ?? value.Name, + local_only = value.LocalOnly, + insert_only = value.InsertOnly, + columns = value.Columns.Select(column => column.Value == ColumnType.Inferred + ? throw new InvalidOperationException($"Attempted to serialise Inferred column {column.Key}. ColumnType.Inferred is only valid as an argument to ColumnAttribute.") + : new { name = column.Key, type = column.Value.ToString() }), + indexes = value.Indexes.Select(index => new + { + name = index.Key, + columns = index.Value.Select(column => + { + // A leading "-" denotes a descending index on the column. + var columnName = column.Replace("-", ""); + return new + { + name = columnName, + ascending = !column.StartsWith("-"), + type = (value.Columns.TryGetValue(columnName, out var columnType) ? columnType : default).ToString() + }; + }) + }), + include_metadata = value.TrackMetadata, + ignore_empty_update = value.IgnoreEmptyUpdates, + // false when disabled, true when tracking all columns, or a list of tracked column names. + include_old = (object)(trackPrevious switch + { + null => false, + { Columns: null } => true, + { Columns: var columns } => columns + }), + include_old_only_when_changed = trackPrevious?.OnlyWhenChanged ?? false + }); } } diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index e5d8ebf..87879e3 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -11,10 +11,10 @@ namespace PowerSync.Common.Tests.DB.Schema; /// public class SchemaTests { - private void TestParser(Type type, CompiledTable expected) + private void TestParser(Type type, Table expected) { var parser = new AttributeParser(type); - var table = parser.ParseTable().Compile(); + var table = parser.ParseTable(); Assert.Equivalent(expected, table, strict: true); } @@ -48,7 +48,7 @@ class Asset [Fact] public void AttributeParser_Assets_Test() { - var expected = new CompiledTable( + var expected = new Table( "test_assets", new Dictionary { @@ -68,7 +68,7 @@ public void AttributeParser_Assets_Test() LocalOnly = false, InsertOnly = false, ViewName = "test_assets_viewname", - TrackMetadata = true, + TrackMetadata = false, TrackPreviousValues = null, IgnoreEmptyUpdates = true, } @@ -106,7 +106,7 @@ class Product [Fact] public void AttributeParser_Products_Test() { - var expected = new CompiledTable( + var expected = new Table( "test_products", new Dictionary { @@ -176,7 +176,7 @@ class Log [Fact] public void AttributeParser_Logs_Test() { - var expected = new CompiledTable( + var expected = new Table( "test_logs", new Dictionary { @@ -322,7 +322,7 @@ public void AttributeParser_TypeMap_DefaultRegistered() } [Fact] - public void CompiledSchema_ToJSON() + public void Schema_SerializesToJSON() { object expectedJson = new { @@ -381,8 +381,6 @@ public void CompiledSchema_ToJSON() }, } }; - var schema = TestSchemaTodoList.AppSchema.Compile(); - - Assert.Equal(JsonConvert.SerializeObject(expectedJson), schema.ToJSON()); + Assert.Equal(JsonConvert.SerializeObject(expectedJson), JsonConvert.SerializeObject(TestSchemaTodoList.AppSchema)); } } From 57231e6cdd5633ccea580ddee3c1325406643ea4 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Fri, 12 Jun 2026 16:28:05 +0200 Subject: [PATCH 2/4] Fix hyphenated columns in indexes not parsing correctly --- PowerSync/PowerSync.Common/DB/Schema/Table.cs | 10 +++- .../PowerSync.Common.Tests/DB/SchemaTests.cs | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/Table.cs b/PowerSync/PowerSync.Common/DB/Schema/Table.cs index cd2f933..b70ebd9 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/Table.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/Table.cs @@ -222,7 +222,10 @@ public void Validate() foreach (var column in indexColumns) { - if (!columnNames.Contains(column.TrimStart('-'))) + // A leading "-" denotes a descending index on the column. + var columnName = column.StartsWith("-") ? column.Substring(1) : column; + + if (!columnNames.Contains(columnName)) { throw new Exception($"Column {column} not found for index {indexName}"); } @@ -263,11 +266,12 @@ public override void WriteJson(JsonWriter writer, Table? value, JsonSerializer s columns = index.Value.Select(column => { // A leading "-" denotes a descending index on the column. - var columnName = column.Replace("-", ""); + var descending = column.StartsWith("-"); + var columnName = descending ? column.Substring(1) : column; return new { name = columnName, - ascending = !column.StartsWith("-"), + ascending = !descending, type = (value.Columns.TryGetValue(columnName, out var columnType) ? columnType : default).ToString() }; }) diff --git a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs index 87879e3..20c6e29 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/DB/SchemaTests.cs @@ -383,4 +383,61 @@ public void Schema_SerializesToJSON() }; Assert.Equal(JsonConvert.SerializeObject(expectedJson), JsonConvert.SerializeObject(TestSchemaTodoList.AppSchema)); } + + [Fact] + public void Schema_SerializesHyphenatedColumnNames() + { + object expectedJson = new + { + tables = new List + { + new + { + name = "events", + view_name = "events", + local_only = false, + insert_only = false, + columns = new List { + new { name = "created-at", type = "Text" }, + }, + indexes = new List { + new { + name = "created", + columns = new List { + new { name = "created-at", ascending = true, type = "Text" }, + } + }, + new { + name = "created_rev", + columns = new List { + new { name = "created-at", ascending = false, type = "Text" }, + } + } + }, + include_metadata = false, + ignore_empty_update = false, + include_old = false, + include_old_only_when_changed = false + }, + } + }; + + var schema = new Schema(new Table + { + Name = "events", + Columns = + { + ["created-at"] = ColumnType.Text, + }, + Indexes = + { + ["created"] = ["created-at"], + ["created_rev"] = ["-created-at"], + } + }); + + schema.Validate(); + + Assert.Equal(JsonConvert.SerializeObject(expectedJson), JsonConvert.SerializeObject(schema)); + } } From faff4214bb7aacbf6edcd7ce11cac71ad538adfa Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Fri, 12 Jun 2026 16:36:15 +0200 Subject: [PATCH 3/4] Update comment --- PowerSync/PowerSync.Common/DB/Schema/ColumnType.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PowerSync/PowerSync.Common/DB/Schema/ColumnType.cs b/PowerSync/PowerSync.Common/DB/Schema/ColumnType.cs index abfce44..8fcfe84 100644 --- a/PowerSync/PowerSync.Common/DB/Schema/ColumnType.cs +++ b/PowerSync/PowerSync.Common/DB/Schema/ColumnType.cs @@ -6,8 +6,8 @@ public enum ColumnType Integer, 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. + /// Infers the column type based on the associated property's PropertyType. + /// NB: `ColumnType.Inferred` can only be used when using the syntax. /// Inferred } From d2b8a478415382f171a9b7522225ae46b20bb8c9 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Fri, 12 Jun 2026 16:41:42 +0200 Subject: [PATCH 4/4] Changelog --- PowerSync/PowerSync.Common/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index 7950fd3..e1726cf 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -2,8 +2,10 @@ ## 0.1.3 +- **Breaking:** Made `Table.Name` non-nullable (default ""). This change may affect 0% of users, but it is technically a breaking change. - Add support for loading custom SQLite extensions via `MDSQLiteOptions.Extensions`. - Fix streaming sync retry loop reconnecting with no delay after an exception, ignoring `RetryDelayMs`. +- (internal) Remove `Compiled*` classes in favor of working with `Table` and `Schema` objects directly. ## 0.1.2