Skip to content

Commit b993169

Browse files
authored
[Feature] Class-based schema definition (#41)
1 parent 606d7cc commit b993169

28 files changed

Lines changed: 1212 additions & 357 deletions

PowerSync/PowerSync.Common/CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,45 @@
11
# PowerSync.Common Changelog
22

3+
## 0.0.10-alpha.1
4+
5+
- Fixed a bug where custom indexes were not being sent to the PowerSync SQLite extension.
6+
- 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.
7+
8+
```csharp
9+
using PowerSync.Common.DB.Schema;
10+
using PowerSync.Common.DB.Schema.Attributes;
11+
12+
[
13+
Table("todos"),
14+
Index("list", ["list_id"])
15+
]
16+
public class Todo
17+
{
18+
[Column("id")]
19+
public string TodoId { get; set; }
20+
21+
[Column("created_at")]
22+
public DateTime CreatedAt { get; set; }
23+
24+
[Column("name")]
25+
public string Name { get; set; }
26+
27+
[Column("description")]
28+
public string? Description { get; set; }
29+
30+
[Column("completed")]
31+
public bool Completed { get; set; }
32+
}
33+
34+
public class Schema
35+
{
36+
public static Schema AppSchema = new Schema(typeof(Todo));
37+
}
38+
39+
// Usage
40+
var todos = powerSync.GetAll<Todo>("SELECT * FROM todos");
41+
```
42+
343
## 0.0.9-alpha.1
444

545
- _Breaking:_ Further updated schema definition syntax.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
namespace PowerSync.Common.DB.Schema.Attributes;
2+
3+
using System.Reflection;
4+
5+
using Dapper;
6+
7+
class AttributeParser
8+
{
9+
private readonly Type _type;
10+
private readonly TableAttribute _tableAttr;
11+
12+
public string TableName
13+
{
14+
get { return _tableAttr.Name; }
15+
}
16+
17+
public AttributeParser(Type type)
18+
{
19+
_type = type;
20+
21+
_tableAttr = _type.GetCustomAttribute<TableAttribute>();
22+
if (_tableAttr == null)
23+
{
24+
throw new InvalidOperationException("Table classes must be marked with TableAttribute.");
25+
}
26+
}
27+
28+
public Table ParseTable()
29+
{
30+
return new Table(
31+
name: _tableAttr.Name,
32+
columns: ParseColumns(),
33+
options: ParseTableOptions()
34+
);
35+
}
36+
37+
public Dictionary<string, ColumnType> ParseColumns()
38+
{
39+
var columns = new Dictionary<string, ColumnType>();
40+
PropertyInfo? idProperty = null;
41+
42+
foreach (var prop in _type.GetProperties())
43+
{
44+
if (prop.GetCustomAttribute<IgnoredAttribute>() != null) continue;
45+
46+
var columnAttr = prop.GetCustomAttribute<ColumnAttribute>();
47+
var columnName = columnAttr?.Name ?? prop.Name;
48+
49+
// Handle 'id' field separately
50+
if (columnName.ToLowerInvariant() == "id")
51+
{
52+
if (idProperty != null)
53+
{
54+
throw new InvalidOperationException($"Cannot define multiple ID columns for table '{_tableAttr.Name}'.");
55+
}
56+
idProperty = prop;
57+
continue;
58+
}
59+
60+
var userColumnType = columnAttr?.ColumnType ?? ColumnType.Inferred;
61+
62+
// Infer column type from property's type
63+
var columnType = userColumnType == ColumnType.Inferred
64+
? PropertyTypeToColumnType(prop.PropertyType)
65+
: userColumnType;
66+
columns.Add(columnName, columnType);
67+
}
68+
69+
// Validate 'id' property exists and is a string
70+
if (idProperty == null)
71+
{
72+
throw new InvalidOperationException($"An 'id' property is required for table '{_tableAttr.Name}'.");
73+
}
74+
if (idProperty.PropertyType != typeof(string))
75+
{
76+
throw new InvalidOperationException($"ID Property '{idProperty.Name}' must be of type string.");
77+
}
78+
var idAttr = idProperty.GetCustomAttribute<ColumnAttribute>();
79+
if (idAttr != null)
80+
{
81+
// ID column only supports Text and Inferred as options
82+
if (idAttr.ColumnType != ColumnType.Text && idAttr.ColumnType != ColumnType.Inferred)
83+
{
84+
throw new InvalidOperationException
85+
(
86+
$"ID Property '{idProperty.Name}' must have ColumnType set to ColumnType.Text or ColumnType.Inferred."
87+
);
88+
}
89+
}
90+
91+
return columns;
92+
}
93+
94+
public Dictionary<string, List<string>> ParseIndexes()
95+
{
96+
var indexes = new Dictionary<string, List<string>>();
97+
var indexAttrs = _type.GetCustomAttributes<IndexAttribute>();
98+
foreach (var index in indexAttrs)
99+
{
100+
var name = index.Name;
101+
var columns = index.Columns.ToList();
102+
indexes.Add(name, columns);
103+
}
104+
return indexes;
105+
}
106+
107+
private ColumnType PropertyTypeToColumnType(Type propertyType)
108+
{
109+
Type underlyingType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
110+
111+
return underlyingType switch
112+
{
113+
// TEXT types
114+
Type t when t == typeof(string) => ColumnType.Text,
115+
Type t when t == typeof(char) => ColumnType.Text,
116+
Type t when t == typeof(Guid) => ColumnType.Text,
117+
Type t when t == typeof(DateTime) => ColumnType.Text,
118+
Type t when t == typeof(DateTimeOffset) => ColumnType.Text,
119+
Type t when t == typeof(TimeSpan) => ColumnType.Text,
120+
// 'decimal' is 128-bit, ColumnType.Real is only 64-bit
121+
Type t when t == typeof(decimal) => ColumnType.Text,
122+
123+
// INTEGER types
124+
Type t when t.IsEnum => ColumnType.Integer,
125+
Type t when t == typeof(bool) => ColumnType.Integer, // bool
126+
Type t when t == typeof(sbyte) => ColumnType.Integer, // i8
127+
Type t when t == typeof(byte) => ColumnType.Integer, // u8
128+
Type t when t == typeof(short) => ColumnType.Integer, // i16
129+
Type t when t == typeof(ushort) => ColumnType.Integer, // u16
130+
Type t when t == typeof(int) => ColumnType.Integer, // i32
131+
Type t when t == typeof(uint) => ColumnType.Integer, // u32
132+
Type t when t == typeof(long) => ColumnType.Integer, // i64
133+
Type t when t == typeof(ulong) => ColumnType.Integer, // u64
134+
// .NET 5.0+ only
135+
// Type t when t == typeof(nint) => ColumnType.Integer, // isize
136+
// Type t when t == typeof(nuint) => ColumnType.Integer, // usize
137+
138+
// REAL types
139+
Type t when t == typeof(float) => ColumnType.Real,
140+
Type t when t == typeof(double) => ColumnType.Real,
141+
142+
// Fallback
143+
_ => throw new InvalidOperationException($"Unable to automatically infer ColumnType of property type '{underlyingType.Name}'."),
144+
};
145+
}
146+
147+
public TableOptions ParseTableOptions()
148+
{
149+
return new TableOptions(
150+
indexes: ParseIndexes(),
151+
localOnly: _tableAttr.LocalOnly,
152+
insertOnly: _tableAttr.InsertOnly,
153+
viewName: _tableAttr.ViewName,
154+
trackMetadata: _tableAttr.TrackMetadata,
155+
trackPreviousValues: ParseTrackPreviousOptions(),
156+
ignoreEmptyUpdates: _tableAttr.IgnoreEmptyUpdates
157+
);
158+
}
159+
160+
public TrackPreviousOptions? ParseTrackPreviousOptions()
161+
{
162+
TrackPrevious trackPrevious = _tableAttr.TrackPreviousValues;
163+
if (trackPrevious == TrackPrevious.None)
164+
{
165+
return null;
166+
}
167+
168+
if (trackPrevious.HasFlag(TrackPrevious.Columns) && trackPrevious.HasFlag(TrackPrevious.Table))
169+
{
170+
throw new InvalidOperationException("Cannot specify both TrackPrevious.Columns and TrackPrevious.Table on the same table.");
171+
}
172+
173+
if (!trackPrevious.HasFlag(TrackPrevious.Columns)
174+
&& !trackPrevious.HasFlag(TrackPrevious.Table)
175+
&& trackPrevious.HasFlag(TrackPrevious.OnlyWhenChanged))
176+
{
177+
throw new InvalidOperationException("Cannot specify TrackPrevious.OnlyWhenChanged without also specifying either TrackPrevious.Columns or TrackPrevious.Table.");
178+
}
179+
180+
bool trackWholeTable = _tableAttr.TrackPreviousValues.HasFlag(TrackPrevious.Table);
181+
bool onlyWhenChanged = trackPrevious.HasFlag(TrackPrevious.OnlyWhenChanged);
182+
183+
return new TrackPreviousOptions
184+
{
185+
Columns = trackWholeTable ? null : ParseTrackedColumns(),
186+
OnlyWhenChanged = onlyWhenChanged,
187+
};
188+
}
189+
190+
public CustomPropertyTypeMap ParseDapperTypeMap()
191+
{
192+
return new(
193+
_type,
194+
(type, columnName) => type.GetProperties()
195+
.FirstOrDefault(prop => prop.GetCustomAttributes()
196+
.OfType<ColumnAttribute>()
197+
.Any(columnAttr => columnAttr.Name == columnName))
198+
);
199+
}
200+
201+
public void RegisterDapperTypeMap()
202+
{
203+
// Only register type map if some Column("custom_name") is found
204+
if (_type
205+
.GetProperties()
206+
.Any(prop => prop
207+
.GetCustomAttributes()
208+
.OfType<ColumnAttribute>()
209+
.Any(attr => attr.Name != null)))
210+
{
211+
Dapper.SqlMapper.SetTypeMap(_type, ParseDapperTypeMap());
212+
}
213+
}
214+
215+
public List<string> ParseTrackedColumns()
216+
{
217+
var trackedColumns = new List<string>();
218+
foreach (var prop in _type.GetProperties())
219+
{
220+
var columnAttr = prop.GetCustomAttribute<ColumnAttribute>();
221+
if (columnAttr == null || !columnAttr.TrackPrevious) continue;
222+
223+
trackedColumns.Add(prop.Name);
224+
}
225+
return trackedColumns;
226+
}
227+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace PowerSync.Common.DB.Schema.Attributes;
2+
3+
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
4+
public class ColumnAttribute : Attribute
5+
{
6+
public string? Name { get; set; } = "";
7+
public ColumnType ColumnType { get; set; } = ColumnType.Inferred;
8+
public bool TrackPrevious { get; set; }
9+
10+
public ColumnAttribute(string? name = null)
11+
{
12+
Name = name;
13+
}
14+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
namespace PowerSync.Common.DB.Schema.Attributes;
2+
3+
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
4+
public class IgnoredAttribute : Attribute { }
5+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace PowerSync.Common.DB.Schema.Attributes;
2+
3+
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
4+
public class IndexAttribute : Attribute
5+
{
6+
public string Name { get; }
7+
public string[] Columns { get; }
8+
9+
public IndexAttribute(string name, string[] columns)
10+
{
11+
Name = name;
12+
Columns = columns;
13+
}
14+
}
15+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace PowerSync.Common.DB.Schema.Attributes;
2+
3+
[Flags]
4+
public enum TrackPrevious
5+
{
6+
None = 0,
7+
Table = 1 << 0,
8+
Columns = 1 << 1,
9+
OnlyWhenChanged = 1 << 2,
10+
}
11+
12+
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
13+
public sealed class TableAttribute : Attribute
14+
{
15+
public string Name { get; }
16+
public bool LocalOnly { get; set; }
17+
public bool InsertOnly { get; set; }
18+
public string? ViewName { get; set; }
19+
public bool TrackMetadata { get; set; }
20+
public bool IgnoreEmptyUpdates { get; set; }
21+
public TrackPrevious TrackPreviousValues { get; set; }
22+
23+
public TableAttribute(string name)
24+
{
25+
Name = name;
26+
}
27+
}

PowerSync/PowerSync.Common/DB/Schema/ColumnJSON.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ public enum ColumnType
44
{
55
Text,
66
Integer,
7-
Real
7+
Real,
8+
/// <summary>
9+
/// <para>Infers the column type based on the associated property's PropertyType.</para>
10+
/// <para>**NB:** `ColumnType.Inferred` can only be used when using the schema attributes syntax.</para>
11+
/// </summary>
12+
Inferred
813
}
914

1015
class ColumnJSONOptions(string Name, ColumnType? Type)
@@ -21,6 +26,8 @@ class ColumnJSON(ColumnJSONOptions options)
2126

2227
public object ToJSONObject()
2328
{
29+
if (Type == ColumnType.Inferred) throw new InvalidOperationException("Attempted to serialise Inferred column. ColumnType.Inferred is only valid as an argument to ColumnAttribute.");
30+
2431
return new
2532
{
2633
name = Name,

PowerSync/PowerSync.Common/DB/Schema/CompiledSchema.cs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
namespace PowerSync.Common.DB.Schema;
22

33
using Newtonsoft.Json;
4-
using Newtonsoft.Json.Linq;
54

65
class CompiledSchema(Dictionary<string, CompiledTable> tables)
76
{
@@ -27,13 +26,7 @@ public string ToJSON()
2726
{
2827
var jsonObject = new
2928
{
30-
tables = Tables.Select(kv =>
31-
{
32-
var json = JObject.Parse(kv.Value.ToJSON(kv.Key));
33-
var orderedJson = new JObject { ["name"] = kv.Key };
34-
orderedJson.Merge(json, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Concat });
35-
return orderedJson;
36-
}).ToList()
29+
tables = Tables.Select(kvp => kvp.Value.ToJSONObject()).ToArray(),
3730
};
3831

3932
return JsonConvert.SerializeObject(jsonObject);

0 commit comments

Comments
 (0)