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
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..8fcfe84
--- /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 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..b70ebd9 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,141 @@ 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.");
}
- return new CompiledTable(Name, Columns, Options);
+ 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 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)
+ {
+ // 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}");
+ }
+ }
+ }
+ }
+}
+
+///
+/// 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 descending = column.StartsWith("-");
+ var columnName = descending ? column.Substring(1) : column;
+ return new
+ {
+ name = columnName,
+ ascending = !descending,
+ 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..20c6e29 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,63 @@ public void CompiledSchema_ToJSON()
},
}
};
- var schema = TestSchemaTodoList.AppSchema.Compile();
+ Assert.Equal(JsonConvert.SerializeObject(expectedJson), JsonConvert.SerializeObject(TestSchemaTodoList.AppSchema));
+ }
+
+ [Fact]
+ public void Schema_SerializesHyphenatedColumnNames()
+ {
+ object expectedJson = new
+ {
+ tables = new List