Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions PowerSync/PowerSync.Common/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<Todo>("SELECT * FROM todos");
```

## 0.0.9-alpha.1

- _Breaking:_ Further updated schema definition syntax.
Expand Down
227 changes: 227 additions & 0 deletions PowerSync/PowerSync.Common/DB/Schema/Attributes/AttributeParser.cs
Original file line number Diff line number Diff line change
@@ -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<TableAttribute>();
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<string, ColumnType> ParseColumns()
{
var columns = new Dictionary<string, ColumnType>();
PropertyInfo? idProperty = null;

foreach (var prop in _type.GetProperties())
{
if (prop.GetCustomAttribute<IgnoredAttribute>() != null) continue;

var columnAttr = prop.GetCustomAttribute<ColumnAttribute>();
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<ColumnAttribute>();
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<string, List<string>> ParseIndexes()
{
var indexes = new Dictionary<string, List<string>>();
var indexAttrs = _type.GetCustomAttributes<IndexAttribute>();
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<ColumnAttribute>()
.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<ColumnAttribute>()
.Any(attr => attr.Name != null)))
{
Dapper.SqlMapper.SetTypeMap(_type, ParseDapperTypeMap());
}
}

public List<string> ParseTrackedColumns()
{
var trackedColumns = new List<string>();
foreach (var prop in _type.GetProperties())
{
var columnAttr = prop.GetCustomAttribute<ColumnAttribute>();
if (columnAttr == null || !columnAttr.TrackPrevious) continue;

trackedColumns.Add(prop.Name);
}
return trackedColumns;
}
}
14 changes: 14 additions & 0 deletions PowerSync/PowerSync.Common/DB/Schema/Attributes/ColumnAttribute.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace PowerSync.Common.DB.Schema.Attributes;

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class IgnoredAttribute : Attribute { }

15 changes: 15 additions & 0 deletions PowerSync/PowerSync.Common/DB/Schema/Attributes/IndexAttribute.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}

27 changes: 27 additions & 0 deletions PowerSync/PowerSync.Common/DB/Schema/Attributes/TableAttribute.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
9 changes: 8 additions & 1 deletion PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ public enum ColumnType
{
Text,
Integer,
Real
Real,
/// <summary>
/// <para>Infers the column type based on the associated property's PropertyType.</para>
/// <para>**NB:** `ColumnType.Inferred` can only be used when using the schema attributes syntax.</para>
/// </summary>
Inferred
}

class ColumnJSONOptions(string Name, ColumnType? Type)
Expand All @@ -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,
Expand Down
9 changes: 1 addition & 8 deletions PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
namespace PowerSync.Common.DB.Schema;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

class CompiledSchema(Dictionary<string, CompiledTable> tables)
{
Expand All @@ -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);
Expand Down
Loading