Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RCOCOA-2333: Add tests for sync schema migrations #8542

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ let objectServerTestSources = [
"SwiftServerObjects.swift",
"SwiftSyncTestCase.swift",
"SwiftUIServerTests.swift",
"SyncMigrationTests.swift",
"TimeoutProxyServer.swift",
"WatchTestUtility.swift",
"certificates",
Expand Down Expand Up @@ -373,6 +374,7 @@ let package = Package(
"SwiftObjectServerPartitionTests.swift",
"SwiftObjectServerTests.swift",
"SwiftUIServerTests.swift",
"SyncMigrationTests.swift",
]
),
objectServerTestTarget(
Expand Down
97 changes: 64 additions & 33 deletions Realm/ObjectServerTests/RealmServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@
#endif

private extension ObjectSchema {
func stitchRule(_ partitionKeyType: String?, id: String? = nil, appId: String) -> [String: Json] {
func stitchRule(_ partitionKeyType: String?, id: String? = nil, appId: String, title: String?) -> [String: Json] {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is required to ensure that the title of the collection matches the name of the table whenever we override the table name using _realmObjectName.

var stitchProperties: [String: Json] = [:]
let title = title ?? className

// We only add a partition property for pbs
if let partitionKeyType = partitionKeyType {
Expand Down Expand Up @@ -179,12 +180,12 @@
// The server currently only supports non-optional collections
// but requires them to be marked as optional
"required": properties.compactMap { $0.isOptional || $0.type == .any || $0.isArray || $0.isMap || $0.isSet ? nil : $0.columnName },
"title": "\(className)"
"title": "\(title)"
],
"metadata": [
"data_source": "mongodb1",
"database": "test_data",
"collection": "\(className) \(appId)"
"collection": "\(title) \(appId)"
],
"relationships": relationships
]
Expand Down Expand Up @@ -374,7 +375,7 @@
request(httpMethod: "PUT", completionHandler: completionHandler)
}

func put(on group: DispatchGroup, data: Json? = nil,
func put(on group: DispatchGroup, data: [String: Json]? = nil,
_ completionHandler: @escaping Completion) {
request(on: group, httpMethod: "PUT", data: data, completionHandler)
}
Expand Down Expand Up @@ -490,7 +491,7 @@
}

/// Shared RealmServer. This class only needs to be initialized and torn down once per test suite run.
@objc public static let shared = RealmServer()

Check notice on line 494 in Realm/ObjectServerTests/RealmServer.swift

View check run for this annotation

Xcode Cloud / RealmSwift | swiftui-sync_15.3 | Test - macOS

Realm/ObjectServerTests/RealmServer.swift#L494

Static property 'shared' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

Check notice on line 494 in Realm/ObjectServerTests/RealmServer.swift

View check run for this annotation

Xcode Cloud / RealmSwift | sync_15.3 | Test - macOS

Realm/ObjectServerTests/RealmServer.swift#L494

Static property 'shared' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

/// Log level for the server and mongo processes.
public var logLevel = LogLevel.none
Expand Down Expand Up @@ -761,7 +762,7 @@
public typealias AppId = String

/// Create a new server app
func createApp(syncMode: SyncMode, types: [ObjectBase.Type], persistent: Bool) throws -> AppId {
func createApp(syncMode: SyncMode, types: [ObjectBase.Type], persistent: Bool, typeUpdates: [[ObjectBase.Type]] = []) throws -> AppId {
let session = try XCTUnwrap(session)

let info = try session.apps.post(["name": "test"]).get()
Expand Down Expand Up @@ -840,7 +841,7 @@
throw URLError(.badServerResponse)
}

let schema = types.map { ObjectiveCSupport.convert(object: $0.sharedSchema()!) }
let schema = Dictionary(uniqueKeysWithValues: types.map { ($0._realmObjectName() ?? $0.className(), ObjectiveCSupport.convert(object: $0.sharedSchema()!)) })

let partitionKeyType: String?
if case .pbs(let bsonType) = syncMode {
Expand All @@ -852,12 +853,12 @@
// Creating the schema is a two-step process where we first add all the
// objects with their properties to them so that we can add relationships
let lockedSchemaIds = Locked([String: String]())
for objectSchema in schema {
app.schemas.post(on: group, objectSchema.stitchRule(partitionKeyType, appId: clientAppId)) {
for (title, objectSchema) in schema {
app.schemas.post(on: group, objectSchema.stitchRule(partitionKeyType, appId: clientAppId, title: title)) {
switch $0 {
case .success(let data):
lockedSchemaIds.withLock {
$0[objectSchema.className] = ((data as! [String: Any])["_id"] as! String)
$0[title] = ((data as! [String: Any])["_id"] as! String)
}
case .failure(let error):
XCTFail(error.localizedDescription)
Expand All @@ -867,14 +868,14 @@
try group.throwingWait(timeout: .now() + 5.0)

let schemaIds = lockedSchemaIds.value
for objectSchema in schema {
let schemaId = schemaIds[objectSchema.className]!
app.schemas[schemaId].put(on: group, data: objectSchema.stitchRule(partitionKeyType, id: schemaId, appId: clientAppId), failOnError)
for (title, objectSchema) in schema {
let schemaId = schemaIds[title]!
app.schemas[schemaId].put(on: group, data: objectSchema.stitchRule(partitionKeyType, id: schemaId, appId: clientAppId, title: title), failOnError)
}
try group.throwingWait(timeout: .now() + 5.0)

let asymmetricTables = schema.compactMap {
$0.isAsymmetric ? $0.className : nil
$0.value.isAsymmetric ? $0.key : nil
}
let serviceConfig: [String: Json]
switch syncMode {
Expand All @@ -894,6 +895,21 @@
]
]
]

// We only need to create the userData rule for .pbs since for .flx we
// have a default rule that covers all collections
let userDataRule: [String: Json] = [
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current baas version will error out if we attempt to insert this rule for a qbs app because it's incompatible with qbs rules.

"database": "test_data",
"collection": "UserData",
"roles": [[
"name": "default",
"apply_when": [:],
"insert": true,
"delete": true,
"additional_fields": [:]
]]
]
_ = app.services[serviceId].rules.post(userDataRule)
case .flx(let fields):
serviceConfig = [
"flexible_sync": [
Expand Down Expand Up @@ -950,19 +966,6 @@
"""
], failOnError)

let rules = app.services[serviceId].rules
let userDataRule: [String: Json] = [
"database": "test_data",
"collection": "UserData",
"roles": [[
"name": "default",
"apply_when": [:],
"insert": true,
"delete": true,
"additional_fields": [:]
]]
]
_ = rules.post(userDataRule)
app.customUserData.patch(on: group, [
"mongo_service_id": serviceId,
"enabled": true,
Expand All @@ -988,25 +991,53 @@
"version": 1
], failOnError)

// Disable exponential backoff when the server isn't ready for us to connect
session.privateApps[appId].settings.patch(on: group, [
"sync": ["disable_client_error_backoff": true]
], failOnError)
Comment on lines -992 to -994
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're getting a 403 when we attempt to execute this request. I need to check if it's still necessary and why we're no longer authorized to make it.


try group.throwingWait(timeout: .now() + 5.0)

// Wait for initial sync to complete as connecting before that has a lot of problems
try waitForSync(appServerId: appId, expectedCount: schema.count - asymmetricTables.count)

// Schema updates - if any - need to be applied after sync has been enabled to force the creation
// of different schema versions
let schemaUpdates = typeUpdates.map {
Dictionary(uniqueKeysWithValues: $0.map { ($0._realmObjectName() ?? $0.className(), ObjectiveCSupport.convert(object: $0.sharedSchema()!)) })
}

for update in schemaUpdates {
for (title, objectSchema) in update {
let schemaId = schemaIds[title]!

app.schemas[schemaId].put(on: group, data: objectSchema.stitchRule(partitionKeyType, appId: clientAppId, title: title)) {
switch $0 {
case .failure(let error):
XCTFail(error.localizedDescription)
default:
break;
}
}
}

try group.throwingWait(timeout: .now() + 5.0)
}

let expectedSchemaVersion = schemaUpdates.count
while (true) {
let response = try app.sync.schemas.versions.get().get() as! [String: Any?]
let versions = response["versions"] as! [[String: Any?]]
let currentMajor = versions.map({ $0["version_major"] as! Int32 }).max()!
if (currentMajor >= expectedSchemaVersion) {
break
}
}

if !persistent {
appIds.append(appId)
}

return clientAppId
}

@objc public func createApp(fields: [String], types: [ObjectBase.Type], persistent: Bool = false) throws -> AppId {
return try createApp(syncMode: .flx(fields), types: types, persistent: persistent)
@objc public func createApp(fields: [String], types: [ObjectBase.Type], persistent: Bool = false, typeUpdates: [[ObjectBase.Type]] = []) throws -> AppId {
return try createApp(syncMode: .flx(fields), types: types, persistent: persistent, typeUpdates: typeUpdates)
}

@objc public func createApp(partitionKeyType: String = "string", types: [ObjectBase.Type], persistent: Bool = false) throws -> AppId {
Expand Down
76 changes: 76 additions & 0 deletions Realm/ObjectServerTests/SwiftServerObjects.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,79 @@ public class SwiftCustomColumnObject: Object {
customColumnPropertiesMapping
}
}

public class ObjectWithNullablePropsV0: Object {
@Persisted(primaryKey: true) public var _id: ObjectId
@Persisted public var boolCol: Bool?
@Persisted public var intCol: Int?
@Persisted public var doubleCol: Double?
@Persisted public var stringCol: String?
@Persisted public var binaryCol: Data?
@Persisted public var dateCol: Date?
@Persisted public var longCol: Int64?
@Persisted public var decimalCol: Decimal128?
@Persisted public var uuidCol: UUID?
@Persisted public var objectIdCol: ObjectId?

public override class func _realmIgnoreClass() -> Bool {
true
}

override public class func _realmObjectName() -> String? {
"ObjectWithNullableProps"
}

public convenience init(boolCol: Bool?, intCol: Int?, doubleCol: Double?, stringCol: String?, binaryCol: Data?, dateCol: Date?, longCol: Int64?, decimalCol: Decimal128?, uuidCol: UUID?, objectIdCol: ObjectId?) {
self.init()
self._id = ObjectId()
self.boolCol = boolCol
self.intCol = intCol
self.doubleCol = doubleCol
self.stringCol = stringCol
self.binaryCol = binaryCol
self.dateCol = dateCol
self.longCol = longCol
self.decimalCol = decimalCol
self.uuidCol = uuidCol
self.objectIdCol = objectIdCol
}
}

public class ObjectWithNullablePropsV1: Object {
@Persisted(primaryKey: true) public var _id: ObjectId
@Persisted public var boolCol: Bool
@Persisted public var intCol: Int
@Persisted public var doubleCol: Double
@Persisted public var stringCol: String
@Persisted public var binaryCol: Data
@Persisted public var dateCol: Date
@Persisted public var longCol: Int64
@Persisted public var decimalCol: Decimal128
@Persisted public var uuidCol: UUID
@Persisted public var objectIdCol: ObjectId
@Persisted public var willBeRemovedCol: String

public override class func _realmIgnoreClass() -> Bool {
true
}

override public class func _realmObjectName() -> String? {
"ObjectWithNullableProps"
}

public convenience init(boolCol: Bool, intCol: Int, doubleCol: Double, stringCol: String, binaryCol: Data, dateCol: Date, longCol: Int64, decimalCol: Decimal128, uuidCol: UUID, objectIdCol: ObjectId, willBeRemovedCol: String) {
self.init()
self._id = ObjectId()
self.boolCol = boolCol
self.intCol = intCol
self.doubleCol = doubleCol
self.stringCol = stringCol
self.binaryCol = binaryCol
self.dateCol = dateCol
self.longCol = longCol
self.decimalCol = decimalCol
self.uuidCol = uuidCol
self.objectIdCol = objectIdCol
self.willBeRemovedCol = willBeRemovedCol
}
}
101 changes: 101 additions & 0 deletions Realm/ObjectServerTests/SyncMigrationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
////////////////////////////////////////////////////////////////////////////
//
// Copyright 2024 Realm Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////

#if os(macOS)
import RealmSwift
import XCTest
import Combine

#if canImport(RealmTestSupport)
import RealmSwiftSyncTestSupport
import RealmSyncTestSupport
import RealmTestSupport
import SwiftUI
import RealmSwiftTestSupport
#endif

@available(macOS 13.0, *)
class SyncMigrationTests: SwiftSyncTestCase {
override func configuration(user: User) -> Realm.Configuration {
user.flexibleSyncConfiguration()
}

override func createApp() throws -> String {
try RealmServer.shared.createApp(fields: [], types: [ObjectWithNullablePropsV0.self], typeUpdates: [[ObjectWithNullablePropsV1.self], [ObjectWithNullablePropsV0.self]])
}

override var objectTypes: [ObjectBase.Type] {
[ObjectWithNullablePropsV0.self]
}

func openMigrationRealm(schemaVersion: UInt64, type: ObjectBase.Type) throws -> Realm {
var config = try configuration()
config.schemaVersion = schemaVersion
config.objectTypes = [type]

let realm = Realm.asyncOpen(configuration: config).await(self)
RLMRealmSubscribeToAll(ObjectiveCSupport.convert(object: realm))
waitForDownloads(for: realm)

return realm
}

func testCanMigratePropertyOptionality() throws {
let realmv0 = try openMigrationRealm(schemaVersion: 0, type: ObjectWithNullablePropsV0.self)

let oid = ObjectId()
let uuid = UUID()
let date = Date(timeIntervalSince1970: -987)

try realmv0.write {
realmv0.add(ObjectWithNullablePropsV0(boolCol: true, intCol: 42, doubleCol: 123.456, stringCol: "abc", binaryCol: "foo".data(using: String.Encoding.utf8)!, dateCol: date, longCol: 998877665544332211, decimalCol: Decimal128(1), uuidCol: uuid, objectIdCol: oid))
}

waitForUploads(for: realmv0)

let realmv1 = try openMigrationRealm(schemaVersion: 1, type: ObjectWithNullablePropsV1.self)
let objv1 = realmv1.objects(ObjectWithNullablePropsV1.self).first!

XCTAssertEqual(objv1.boolCol, true)
XCTAssertEqual(objv1.intCol, 42)
XCTAssertEqual(objv1.doubleCol, 123.456)
XCTAssertEqual(objv1.stringCol, "abc")
XCTAssertEqual(objv1.binaryCol, "foo".data(using: String.Encoding.utf8)!)
XCTAssertEqual(objv1.dateCol, date)
XCTAssertEqual(objv1.longCol, 998877665544332211)
XCTAssertEqual(objv1.decimalCol, Decimal128(1))
XCTAssertEqual(objv1.uuidCol, uuid)
XCTAssertEqual(objv1.objectIdCol, oid)

let realmv2 = try openMigrationRealm(schemaVersion: 2, type: ObjectWithNullablePropsV0.self)
let objv2 = realmv2.objects(ObjectWithNullablePropsV0.self).first!

XCTAssertEqual(objv2.boolCol, true)
XCTAssertEqual(objv2.intCol, 42)
XCTAssertEqual(objv2.doubleCol, 123.456)
XCTAssertEqual(objv2.stringCol, "abc")
XCTAssertEqual(objv2.binaryCol, "foo".data(using: String.Encoding.utf8)!)
XCTAssertEqual(objv2.dateCol, date)
XCTAssertEqual(objv2.longCol, 998877665544332211)
XCTAssertEqual(objv2.decimalCol, Decimal128(1))
XCTAssertEqual(objv2.uuidCol, uuid)
XCTAssertEqual(objv2.objectIdCol, oid)
}
}

#endif // os(macOS)
2 changes: 1 addition & 1 deletion Realm/ObjectServerTests/config_overrides.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"events": {
"database": {
"maxCoordinatorChangeStreams": 50000
"maxCoordinatorChangeStreams": "50000"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current baas expects this to be a quoted int.

},
"log_forwarder": {
"enabled": false
Expand Down
Loading
Loading