- C++ 88.3%
- Swift 11.7%
| Sources | ||
| Tests | ||
| .gitignore | ||
| .swiftformat | ||
| CLAUDE.md | ||
| Package.resolved | ||
| Package.swift | ||
| README.md | ||
SwiftViewModelServer
A Swift server implementation of IknowViewModels, for use with Vapor. It speaks the same JSON API as the Ruby library, and so works with SwiftViewModelClient.
This is an early implementation: the wire format, update parsing, errors, migrations and a Fluent integration for loading and updating models are in place (see Status).
Products
ViewModelServer: the core library and macros, independent of any web framework.ViewModelServerVapor: request parsing, response rendering and error handling for Vapor.ViewModelServerFluent: viewmodels backed by Fluent models, which are loaded, serialized, updated and deleted as trees. Works with Postgres, MySQL and SQLite.
All are compiled with strict memory safety. JSON is parsed by simdjson (vendored in Sources/CSIMDJSON), whose tape is copied into Swift arrays after parsing, so the only unsafe code is the small bridge in SIMDJSONParser.swift.
Defining viewmodels
Viewmodels are usually value types wrapping a model. The @ViewModel macro generates their member table from properties marked @Attribute and @Association:
@ViewModel(version: 2, root: true)
struct ParentView {
static let migrations = [
Migration(
from: 1,
to: 2,
up: { view, _ in view["name"] = view.removeValue(forKey: "title") },
down: { view, _ in view["title"] = view.removeValue(forKey: "name") }
),
]
var model: Parent
var id: Int? { model.id }
@Attribute var name: String {
get { model.name }
set { model.name = newValue }
}
@Attribute(readOnly: true) var lockVersion: Int { model.lockVersion }
@Attribute(format: AccurateTimeFormat()) var createdAt: Date { model.createdAt }
@Association var children: [ChildView] { model.children.map(ChildView.init) }
@Association var category: CategoryView? { model.category.map(CategoryView.init) }
@Association(external: true) var owner: OwnerView? { model.owner.map(OwnerView.init) }
}
- The view name defaults to the type name without a
Viewsuffix, as in Ruby, and may be given with@ViewModel(name:). Member names default to the property name in snake case (lockVersionis "lock_version"), and may be given withname:. - Attributes are read only if declared so or if the property isn't settable. Values are coded with
JSONCodable, or an explicitViewModelFormat. - Associations to root viewmodels are serialized as references, and others are nested. Association values may be viewmodels, optionals or arrays of them, or a
@ViewModelVariantenum for polymorphism.
Without the macro, the same viewmodel declares its metadata and a static let members table:
struct ParentView: ViewModel {
static let viewName = "Parent"
static let schemaVersion = 2
static let isRoot = true
static let members = Members<ParentView> {
Attribute("name", \.name)
Attribute("lock_version", \.lockVersion, readOnly: true)
Association("children", \.children)
}
// ...
}
Fluent
Fluent viewmodels wrap a model. Attributes given a key path delegate to the model, and associations given a relation are loaded from and saved to it:
final class Parent: Model, @unchecked Sendable {
static let schema = "parents"
@ID(custom: .id, generatedBy: .database) var id: Int?
@Field(key: "name") var name: String
@Children(for: \.$parent) var children: [Child]
@OptionalParent(key: "label_id") var label: Label?
@OptionalParent(key: "category_id") var category: Category?
@Siblings(through: ParentTag.self, from: \.$parent, to: \.$tag) var tags: [Tag]
init() {}
}
@ViewModel(root: true)
struct ParentView: FluentViewModel {
var model: Parent
@Attribute(\Parent.name) var name: String
@Association(\Parent.$children) var children: [ChildView]
@Association(\Parent.$label) var label: LabelView?
@Association(\Parent.$category) var category: CategoryView?
@Association(\Parent.$tags, pivotPosition: ListPosition(\ParentTag.$position)) var tags: [TagView]
}
@ViewModel
struct ChildView: FluentViewModel, FluentListMember {
static let listPosition = ListPosition(\Child.$position)
var model: Child
@Attribute(\Child.name) var name: String
}
@Parent, @OptionalParent, @Children, @OptionalChild and @Siblings relations are supported. As in Ruby, associations to root viewmodels are serialized as references, and others are nested and owned by their parent. Collections of FluentListMembers are ordered by a floating point position, as with Ruby's acts_as_list, and @Siblings may be ordered by a position on the pivot.
Associations are serialized from loaded relations, so views are loaded with their trees. Each association is loaded with one query per depth, however many views are loaded:
let parent = try await ParentView.find(id, on: db)
let parents = try await ParentView.load(on: db) { $0.filter(\.$name == "p") }
try await ParentView.preload(views, on: db)
Updates are applied with deserialize, the equivalent of Ruby's deserialize_from_view, in a transaction. Children which are removed from a parent are deleted, along with their owned descendants, unless claimed by another parent in the same update. With onRelease: .detach, they're kept, and their pointer to the parent cleared. Callbacks and access control are run for each changed view, including deleted children:
app.post("parents") { request in
let update = try await request.viewModels.update(for: ParentView.self)
let parents = try await ParentView.deserialize(update, on: request.db, context: DeserializeContext(accessControl: policy))
return try update.isCollection ? request.viewModels.response(parents) : request.viewModels.response(parents[0])
}
app.delete("parents", ":id") { request in
let parent = try await ParentView.find(request.parameters.require("id", as: Int.self), on: request.db)
try await parent.destroy(on: request.db)
return HTTPStatus.noContent
}
Viewmodels without the macros declare Fluent associations in their member table, with the viewmodel type:
static let members = Members<ParentView> {
Attribute("name", \.name)
Association("children", \.$children, as: [ChildView].self)
Association("category", \.$category, as: CategoryView?.self)
}
Trees and cycles
Loaded models are shared between the relations that refer to them, so if associations could lead from a model back to its own model type, loaded models could refer to each other in a cycle and never be freed. Fluent associations therefore form a directed acyclic graph of models, with one exception: trees.
A tree is a nested association to children of the same model, which point to their parent. Each node is loaded with one query per level, and may be updated, moved between parents or deleted with its subtree like any other nested child:
@ViewModel(name: "Category", root: true)
struct CategoryView: FluentViewModel {
var model: Category
@Attribute(\Category.name) var name: String
@Association(\Category.$children) var subcategories: [SubcategoryView]
}
@ViewModel
struct SubcategoryView: FluentViewModel {
var model: Category
@Attribute(\Category.name) var name: String
@Association(\Category.$children) var subcategories: [SubcategoryView]
}
As a tree's nodes each have one parent, the data is a tree unless a node is its own ancestor. Updates which would move a node into its own subtree fail with DeserializationError.invalidStructure, and loading or deleting data in which a node is already its own ancestor fails with AssociationCycleError, rather than looping or leaking.
Other cycles aren't permitted: associations to roots of the same model (such as friends), pointers from a node to its parent, cycles through more than one model, and a model nested in trees through more than one parent pointer. The graph includes external associations, as updates and deletes follow them, and is of models rather than viewmodels, so two viewmodels of the same model can't lead back to it through each other. Each viewmodel's graph is checked when first used, which traps if it's invalid.
Database constraint violations are reported as DeserializationError.databaseConstraint, and lost connections as transientDatabaseError. On Postgres, deferred constraints are checked before the update completes.
Access control
Access policies are built from rules, combining Ruby's composed and tree access controls:
let policy = AccessPolicy {
VisibleIf("always") { _ in true }
ViewPolicy(for: DocumentView.self) {
EditableIf("owned by the user") { $0.view.ownerID == user.id }
EditValidUnless("unpublishing") { check, changes in
check.view.isPublished && changes.changedAny(attributes: ["published"])
}
RootChildrenEditableIf("owned by the user") { $0.view.ownerID == user.id }
}
sharedPolicy
}
var context = SerializeContext(accessControl: policy)
Each check permits access if at least one of its If rules is met and none of its Unless rules are. Rules in a ViewPolicy receive their view typed. Root children rules are checked on the root of each tree and apply to its descendants. Including another policy includes its rules. Custom AccessControl implementations are also supported.
Vapor
app.viewModels = ViewModelConfiguration(registry: ViewModelRegistry([ParentView.self]))
app.middleware.use(ViewModelErrorMiddleware())
app.get("parents", ":id") { request in
let parent = try await ParentView.find(request.parameters.require("id", as: Int.self), on: request.db)
return try request.viewModels.response(parent)
}
app.post("parents") { request in
// Parse, validate and migrate the update, and apply it (see Fluent)
let update = try await request.viewModels.update(for: ParentView.self)
let parents = try await ParentView.deserialize(update, on: request.db)
return try request.viewModels.response(parents)
}
Responses are migrated down to the versions in the X-ViewModel-Versions header (or versions[Name]=1 query parameters), and requests are migrated up. All errors are rendered in the viewmodel error format, {"error": {...}}.
Core API
| Ruby | Swift |
|---|---|
ViewModel::Record / ViewModel::ActiveRecord |
ViewModel protocol with Members |
ViewModel::Registry |
ViewModelRegistry, a value built from root types |
prerender_viewmodel, SerializeContext |
ViewModelResponse.render, SerializeContext |
UpdateData.parse_hashes |
ViewModelUpdate, UpdateData, CollectionUpdate, FunctionalUpdate |
DeserializationError::* |
DeserializationError enum, with the same codes, statuses and metadata |
ErrorView |
ViewModelError.serializeErrorView, ViewModelResponse.render(error:) |
Migrator, migrates from:to: |
Migrator, Migration, MigrationVersions |
Callbacks, AccessControl |
ViewModelCallbacks, AccessControl |
AccessControl::Composed, AccessControl::Tree |
AccessPolicy, ViewPolicy |
Changes |
Changes |
ViewModel::ActiveRecord |
FluentViewModel |
UpdateContext, UpdateOperation |
UpdateContext, UpdateOperation (internal) |
acts_as_list |
FluentListMember, ListPosition |
association ..., through: |
@Siblings associations |
DeepPreloader |
preload, batched by association and depth |
Differences from the Ruby implementation:
- Stable reference keys hash the view name and id, rather than the Ruby class name and id. Keys are opaque to clients.
- The registry is an explicit value rather than global state.
writeOnce: trueimplies read only, except for new models (Ruby'sread_only: true, write_once: true).- Update parsing also performs the structural checks that Ruby makes while building its update tree: duplicate updates, functional update targets, child updates of roots and collections, and unreachable references.
- Fluent views must be loaded before they are serialized, rather than loading associations lazily. Deserialization returns roots in the order they were given, and loaded for serialization.
- Released children are deleted by walking their viewmodel associations, rather than relying on
dependent:options. Fluent has no model validations, soFluentViewModel.validate()is called before saving instead.
Status
Implemented:
- JSON parsing (simdjson), reading, writing and editing
- Serialization with shared references, flattening, callbacks and access control
- Update parsing and validation, including functional collection updates
- Attribute deserialization with change tracking, read only and write once attributes, and deserialization callbacks and access control
- Error views
- Migrations, version headers, and reference garbage collection
@ViewModel,@Attribute,@Associationand@ViewModelVariantmacros- Access policies with composed and tree (root children) rules
- Vapor request parsing, response rendering, and error middleware
- Fluent viewmodels: batched loading, and applying updates to trees of models, including releasing and reclaiming children, reparenting, owned and shared references, list positions, functional collection updates,
@Siblingspivots, trees, and cascading deletes
Not yet implemented:
- Polymorphic Fluent associations. Fluent has no polymorphic relations, so
@ViewModelVariantassociations aren't backed by Fluent. - Nested controllers and association manipulation (
append_associated,replace_associated, bulk updates) pre_parsehooks, the serialization cache, and cloning- Optimistic locking, and specific errors for unique violations
Known limitations of the Fluent integration:
- Associations must be acyclic apart from trees, so graphs such as friends or follows can't be declared.
- Moves are checked within a transaction, so concurrent updates at default isolation levels could still together move nodes into each other's subtrees.
- Fluent fields can't be read before they're set, so read only attributes of new models must have initialized fields if clients provide them.
Development
Tests use SQLite in memory, and also Postgres and MySQL when their URLs are given:
docker run -d -e POSTGRES_USER=viewmodels -e POSTGRES_PASSWORD=viewmodels -p 54329:5432 postgres:17
docker run -d -e MYSQL_USER=viewmodels -e MYSQL_PASSWORD=viewmodels -e MYSQL_DATABASE=viewmodels \
-e MYSQL_ROOT_PASSWORD=viewmodels -p 33069:3306 mysql:8.4
export VIEWMODELS_POSTGRES_URL='postgres://viewmodels:viewmodels@localhost:54329/viewmodels?tlsmode=disable'
export VIEWMODELS_MYSQL_URL='mysql://viewmodels:viewmodels@localhost:33069/viewmodels?ssl-mode=DISABLED'
swift test
Benchmarks of loading and deserializing trees report cold (first) and hot iterations separately:
VIEWMODELS_BENCHMARK=1 swift test -c release -Xswiftc -enable-testing --filter Benchmarks