1
0
Fork 0
No description
  • C++ 88.3%
  • Swift 11.7%
Find a file
2026-11-03 18:07:01 +09:00
Sources Allow self-referential trees in some cases 2026-11-03 18:07:01 +09:00
Tests Allow self-referential trees in some cases 2026-11-03 18:07:01 +09:00
.gitignore Initial commit 2026-11-03 12:37:24 +09:00
.swiftformat Initial commit 2026-11-03 12:37:24 +09:00
CLAUDE.md Macros, result builder access control 2026-11-03 16:16:19 +09:00
Package.resolved Fluent integration first take 2026-11-03 17:36:52 +09:00
Package.swift Fluent integration first take 2026-11-03 17:36:52 +09:00
README.md Allow self-referential trees in some cases 2026-11-03 18:07:01 +09:00

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 View suffix, as in Ruby, and may be given with @ViewModel(name:). Member names default to the property name in snake case (lockVersion is "lock_version"), and may be given with name:.
  • Attributes are read only if declared so or if the property isn't settable. Values are coded with JSONCodable, or an explicit ViewModelFormat.
  • Associations to root viewmodels are serialized as references, and others are nested. Association values may be viewmodels, optionals or arrays of them, or a @ViewModelVariant enum 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: true implies read only, except for new models (Ruby's read_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, so FluentViewModel.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, @Association and @ViewModelVariant macros
  • 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, @Siblings pivots, trees, and cascading deletes

Not yet implemented:

  • Polymorphic Fluent associations. Fluent has no polymorphic relations, so @ViewModelVariant associations aren't backed by Fluent.
  • Nested controllers and association manipulation (append_associated, replace_associated, bulk updates)
  • pre_parse hooks, 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