diff --git a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..de26646 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj @@ -0,0 +1,710 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + BE1EABD42E7B0C0600D0A0A7 /* GRDBQuery in Frameworks */ = {isa = PBXBuildFile; productRef = BE1EABD32E7B0C0600D0A0A7 /* GRDBQuery */; }; + BE1EAC0A2E7C290E00D0A0A7 /* PowerSyncGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = BE1EAC092E7C290E00D0A0A7 /* PowerSyncGRDB */; }; + BE1EAC4C2E7C45F300D0A0A7 /* Auth in Frameworks */ = {isa = PBXBuildFile; productRef = BE1EAC4B2E7C45F300D0A0A7 /* Auth */; }; + BE1EAC4F2E7C461800D0A0A7 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = BE1EAC4E2E7C461800D0A0A7 /* Supabase */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + BE1EABB32E7B075E00D0A0A7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BE1EAB9D2E7B075D00D0A0A7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BE1EABA42E7B075D00D0A0A7; + remoteInfo = "GRDB Demo"; + }; + BE1EABBD2E7B075E00D0A0A7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BE1EAB9D2E7B075D00D0A0A7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BE1EABA42E7B075D00D0A0A7; + remoteInfo = "GRDB Demo"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + BE1EABEE2E7C26DD00D0A0A7 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + BE1EABA52E7B075D00D0A0A7 /* GRDB Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "GRDB Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + BE1EABB22E7B075E00D0A0A7 /* GRDB DemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "GRDB DemoTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + BE1EABBC2E7B075E00D0A0A7 /* GRDB DemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "GRDB DemoUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + BE1EABA72E7B075D00D0A0A7 /* GRDB Demo */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "GRDB Demo"; + sourceTree = ""; + }; + BE1EABB52E7B075E00D0A0A7 /* GRDB DemoTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "GRDB DemoTests"; + sourceTree = ""; + }; + BE1EABBF2E7B075E00D0A0A7 /* GRDB DemoUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "GRDB DemoUITests"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + BE1EABA22E7B075D00D0A0A7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BE1EAC4F2E7C461800D0A0A7 /* Supabase in Frameworks */, + BE1EAC0A2E7C290E00D0A0A7 /* PowerSyncGRDB in Frameworks */, + BE1EABD42E7B0C0600D0A0A7 /* GRDBQuery in Frameworks */, + BE1EAC4C2E7C45F300D0A0A7 /* Auth in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABAF2E7B075E00D0A0A7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABB92E7B075E00D0A0A7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + BE1EAB9C2E7B075D00D0A0A7 = { + isa = PBXGroup; + children = ( + BE1EABA72E7B075D00D0A0A7 /* GRDB Demo */, + BE1EABB52E7B075E00D0A0A7 /* GRDB DemoTests */, + BE1EABBF2E7B075E00D0A0A7 /* GRDB DemoUITests */, + BE1EAC4D2E7C461800D0A0A7 /* Frameworks */, + BE1EABA62E7B075D00D0A0A7 /* Products */, + ); + sourceTree = ""; + }; + BE1EABA62E7B075D00D0A0A7 /* Products */ = { + isa = PBXGroup; + children = ( + BE1EABA52E7B075D00D0A0A7 /* GRDB Demo.app */, + BE1EABB22E7B075E00D0A0A7 /* GRDB DemoTests.xctest */, + BE1EABBC2E7B075E00D0A0A7 /* GRDB DemoUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + BE1EAC4D2E7C461800D0A0A7 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + BE1EABA42E7B075D00D0A0A7 /* GRDB Demo */ = { + isa = PBXNativeTarget; + buildConfigurationList = BE1EABC62E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB Demo" */; + buildPhases = ( + BE1EABA12E7B075D00D0A0A7 /* Sources */, + BE1EABA22E7B075D00D0A0A7 /* Frameworks */, + BE1EABA32E7B075D00D0A0A7 /* Resources */, + BE1EABEE2E7C26DD00D0A0A7 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + BE1EABA72E7B075D00D0A0A7 /* GRDB Demo */, + ); + name = "GRDB Demo"; + packageProductDependencies = ( + BE1EABD32E7B0C0600D0A0A7 /* GRDBQuery */, + BE1EAC092E7C290E00D0A0A7 /* PowerSyncGRDB */, + BE1EAC4B2E7C45F300D0A0A7 /* Auth */, + BE1EAC4E2E7C461800D0A0A7 /* Supabase */, + ); + productName = "GRDB Demo"; + productReference = BE1EABA52E7B075D00D0A0A7 /* GRDB Demo.app */; + productType = "com.apple.product-type.application"; + }; + BE1EABB12E7B075E00D0A0A7 /* GRDB DemoTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = BE1EABC92E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB DemoTests" */; + buildPhases = ( + BE1EABAE2E7B075E00D0A0A7 /* Sources */, + BE1EABAF2E7B075E00D0A0A7 /* Frameworks */, + BE1EABB02E7B075E00D0A0A7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BE1EABB42E7B075E00D0A0A7 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + BE1EABB52E7B075E00D0A0A7 /* GRDB DemoTests */, + ); + name = "GRDB DemoTests"; + packageProductDependencies = ( + ); + productName = "GRDB DemoTests"; + productReference = BE1EABB22E7B075E00D0A0A7 /* GRDB DemoTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + BE1EABBB2E7B075E00D0A0A7 /* GRDB DemoUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = BE1EABCC2E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB DemoUITests" */; + buildPhases = ( + BE1EABB82E7B075E00D0A0A7 /* Sources */, + BE1EABB92E7B075E00D0A0A7 /* Frameworks */, + BE1EABBA2E7B075E00D0A0A7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BE1EABBE2E7B075E00D0A0A7 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + BE1EABBF2E7B075E00D0A0A7 /* GRDB DemoUITests */, + ); + name = "GRDB DemoUITests"; + packageProductDependencies = ( + ); + productName = "GRDB DemoUITests"; + productReference = BE1EABBC2E7B075E00D0A0A7 /* GRDB DemoUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + BE1EAB9D2E7B075D00D0A0A7 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + BE1EABA42E7B075D00D0A0A7 = { + CreatedOnToolsVersion = 26.0; + }; + BE1EABB12E7B075E00D0A0A7 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = BE1EABA42E7B075D00D0A0A7; + }; + BE1EABBB2E7B075E00D0A0A7 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = BE1EABA42E7B075D00D0A0A7; + }; + }; + }; + buildConfigurationList = BE1EABA02E7B075D00D0A0A7 /* Build configuration list for PBXProject "GRDB Demo" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = BE1EAB9C2E7B075D00D0A0A7; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + BE1EABCF2E7B07DE00D0A0A7 /* XCLocalSwiftPackageReference "../../../powersync-swift" */, + BE1EABD22E7B0C0600D0A0A7 /* XCRemoteSwiftPackageReference "GRDBQuery" */, + BE1EAC4A2E7C45F300D0A0A7 /* XCRemoteSwiftPackageReference "supabase-swift" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = BE1EABA62E7B075D00D0A0A7 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + BE1EABA42E7B075D00D0A0A7 /* GRDB Demo */, + BE1EABB12E7B075E00D0A0A7 /* GRDB DemoTests */, + BE1EABBB2E7B075E00D0A0A7 /* GRDB DemoUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + BE1EABA32E7B075D00D0A0A7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABB02E7B075E00D0A0A7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABBA2E7B075E00D0A0A7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + BE1EABA12E7B075D00D0A0A7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABAE2E7B075E00D0A0A7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABB82E7B075E00D0A0A7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + BE1EABB42E7B075E00D0A0A7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BE1EABA42E7B075D00D0A0A7 /* GRDB Demo */; + targetProxy = BE1EABB32E7B075E00D0A0A7 /* PBXContainerItemProxy */; + }; + BE1EABBE2E7B075E00D0A0A7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BE1EABA42E7B075D00D0A0A7 /* GRDB Demo */; + targetProxy = BE1EABBD2E7B075E00D0A0A7 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + BE1EABC42E7B075E00D0A0A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ZGT7463CVJ; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + BE1EABC52E7B075E00D0A0A7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ZGT7463CVJ; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + BE1EABC72E7B075E00D0A0A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-Demo"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + BE1EABC82E7B075E00D0A0A7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-Demo"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + BE1EABCA2E7B075E00D0A0A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-DemoTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDB Demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/GRDB Demo"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + BE1EABCB2E7B075E00D0A0A7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-DemoTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDB Demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/GRDB Demo"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + BE1EABCD2E7B075E00D0A0A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-DemoUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = "GRDB Demo"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + BE1EABCE2E7B075E00D0A0A7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-DemoUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = "GRDB Demo"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + BE1EABA02E7B075D00D0A0A7 /* Build configuration list for PBXProject "GRDB Demo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BE1EABC42E7B075E00D0A0A7 /* Debug */, + BE1EABC52E7B075E00D0A0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BE1EABC62E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB Demo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BE1EABC72E7B075E00D0A0A7 /* Debug */, + BE1EABC82E7B075E00D0A0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BE1EABC92E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB DemoTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BE1EABCA2E7B075E00D0A0A7 /* Debug */, + BE1EABCB2E7B075E00D0A0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BE1EABCC2E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB DemoUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BE1EABCD2E7B075E00D0A0A7 /* Debug */, + BE1EABCE2E7B075E00D0A0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + BE1EABCF2E7B07DE00D0A0A7 /* XCLocalSwiftPackageReference "../../../powersync-swift" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../powersync-swift"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + BE1EABD22E7B0C0600D0A0A7 /* XCRemoteSwiftPackageReference "GRDBQuery" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/GRDBQuery"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.11.0; + }; + }; + BE1EAC4A2E7C45F300D0A0A7 /* XCRemoteSwiftPackageReference "supabase-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/supabase-community/supabase-swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + BE1EABD32E7B0C0600D0A0A7 /* GRDBQuery */ = { + isa = XCSwiftPackageProductDependency; + package = BE1EABD22E7B0C0600D0A0A7 /* XCRemoteSwiftPackageReference "GRDBQuery" */; + productName = GRDBQuery; + }; + BE1EAC092E7C290E00D0A0A7 /* PowerSyncGRDB */ = { + isa = XCSwiftPackageProductDependency; + productName = PowerSyncGRDB; + }; + BE1EAC4B2E7C45F300D0A0A7 /* Auth */ = { + isa = XCSwiftPackageProductDependency; + package = BE1EAC4A2E7C45F300D0A0A7 /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Auth; + }; + BE1EAC4E2E7C461800D0A0A7 /* Supabase */ = { + isa = XCSwiftPackageProductDependency; + package = BE1EAC4A2E7C45F300D0A0A7 /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Supabase; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = BE1EAB9D2E7B075D00D0A0A7 /* Project object */; +} diff --git a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..53d7464 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,96 @@ +{ + "originHash" : "84d25347b5249e7ab78894935a203b13d9d55e5a01b7fe00745bdf028062df42", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "branch" : "dev/persistable-views", + "revision" : "3e1a711d3fedfcab2af0e52ddae03497b665e5fb" + } + }, + { + "identity" : "grdbquery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDBQuery", + "state" : { + "revision" : "540dc48e86af2972b4f1815616aa1ed8ac97845a", + "version" : "0.11.0" + } + }, + { + "identity" : "powersync-sqlite-core-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", + "state" : { + "revision" : "00776db5157c8648671b00e6673603144fafbfeb", + "version" : "0.4.5" + } + }, + { + "identity" : "supabase-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase-community/supabase-swift.git", + "state" : { + "revision" : "ec607e021e6adace332eddd9e1e90ea6d4af5068", + "version" : "2.32.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "d1c6b70f7c5f19fb0b8750cb8dcdf2ea6e2d8c34", + "version" : "3.15.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", + "version" : "1.4.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", + "version" : "1.6.1" + } + } + ], + "version" : 3 +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AccentColor.colorset/Contents.json b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ffdfe15 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,85 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/Contents.json b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/Contents.json b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000..11f1a66 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "pslogo.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/pslogo.svg b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/pslogo.svg new file mode 100644 index 0000000..441e712 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/pslogo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Demo/GRDB Demo/GRDB Demo/Data/List.swift b/Demo/GRDB Demo/GRDB Demo/Data/List.swift new file mode 100644 index 0000000..68b252b --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Data/List.swift @@ -0,0 +1,44 @@ +import GRDB +import PowerSync + +/// PowerSync client side schema +let listsTable = Table( + name: "lists", + columns: [ + .text("name"), + .text("owner_id") + ] +) + +struct List: Codable, Equatable, Identifiable, FetchableRecord, PersistableRecord { + var id: String + var name: String + var ownerId: String + + static var databaseTableName = "lists" + + + enum CodingKeys: String, CodingKey { + case id + case name + case ownerId = "owner_id" + } + + enum Columns { + static let id = Column(CodingKeys.id) + static let name = Column(CodingKeys.name) + static let ownerId = Column(CodingKeys.ownerId) + } + + static let todos = hasMany( + Todo.self, key: "todos", + using: ForeignKey([Todo.Columns.listId], to: [Columns.id]) + ) +} + +/// Result for displaying lists in the main view +struct ListWithTodoCounts: Decodable, Hashable, Identifiable, FetchableRecord { + var id: String + var name: String + var pendingCount: Int +} diff --git a/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift b/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift new file mode 100644 index 0000000..f0dc12d --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift @@ -0,0 +1,41 @@ +import Foundation +import GRDB +import PowerSync + +/// PowerSync client side schema +let todosTable = Table( + name: "todos", + columns: [ + .text("name"), + .text("list_id"), + // Conversion should automatically be handled by GRDB + .integer("completed"), + .text("completed_at") + ] +) + +struct Todo: Codable, Equatable, Identifiable, FetchableRecord, PersistableRecord { + var id: String + var name: String + var listId: String + var isCompleted: Bool + var completedAt: Date? + + static var databaseTableName = "todos" + + enum CodingKeys: String, CodingKey { + case id + case name + case listId = "list_id" + case isCompleted = "completed" + case completedAt = "completed_at" + } + + enum Columns { + static let id = Column(CodingKeys.id) + static let name = Column(CodingKeys.name) + static let listId = Column(CodingKeys.listId) + static let isCompleted = Column(CodingKeys.isCompleted) + static let completedAt = Column(CodingKeys.completedAt) + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift b/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift new file mode 100644 index 0000000..0d8dd28 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift @@ -0,0 +1,85 @@ +import GRDB +import GRDBQuery +import PowerSync +import PowerSyncGRDB +import SwiftUI + +@Observable +class Databases { + let grdb: DatabasePool + let powerSync: PowerSyncDatabaseProtocol + + init(grdb: DatabasePool, powerSync: PowerSyncDatabaseProtocol) { + self.grdb = grdb + self.powerSync = powerSync + } +} + +func openDatabase() + -> Databases +{ + let schema = Schema( + tables: [ + listsTable, + todosTable + ]) + + let dbUrl = FileManager + .default + .urls( + for: .documentDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("test.sqlite") + + var config = Configuration() + + configurePowerSync( + config: &config, + schema: schema + ) + + guard let grdb = try? DatabasePool( + path: dbUrl.path, + configuration: config + ) else { + fatalError("Could not open database") + } + + let powerSync = OpenedPowerSyncDatabase( + schema: schema, + pool: GRDBConnectionPool( + pool: grdb + ), + identifier: "test" + ) + + return Databases( + grdb: grdb, + powerSync: powerSync + ) +} + +@main +struct GRDB_DemoApp: App { + let viewModels: ViewModels + + init() { + viewModels = ViewModels( + databases: openDatabase() + ) + } + + var body: some Scene { + WindowGroup { + ErrorAlertView { + RootScreen( + supabaseViewModel: viewModels.supabaseViewModel + ) + } + .environment(viewModels) + // Used by GRDB observed queries + .databaseContext(.readWrite { viewModels.databases.grdb }) + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/ErrorViewModel.swift b/Demo/GRDB Demo/GRDB Demo/Models/ErrorViewModel.swift new file mode 100644 index 0000000..a7bdd22 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/ErrorViewModel.swift @@ -0,0 +1,52 @@ +import GRDB +import SwiftUI + +func presentError(_ error: Error) -> String { + if let grdbError = error as? DatabaseError { + return grdbError.message ?? "Unknown GRDB error" + } else { + return error.localizedDescription + } +} + +/// A small view model which allows reporting errors to an observable state. +/// This state can be used by a shared view as an alert service. +@Observable +class ErrorViewModel { + var errorMessage: String? + + func report(_ message: String) { + errorMessage = message + } + + /// Runs a callback and presents ant error if thrown + @discardableResult + func withReporting( + _ message: String? = nil, + _ callback: () throws -> R + ) rethrows -> R { + do { + return try callback() + } catch { + errorMessage = message ?? ": " + presentError(error) + throw error + } + } + + @discardableResult + func withReportingAsync( + _ message: String? = nil, + _ callback: @escaping () async throws -> R + ) async throws -> R { + do { + return try await callback() + } catch { + errorMessage = message ?? ": " + presentError(error) + throw error + } + } + + func clear() { + errorMessage = nil + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/ListViewModel.swift b/Demo/GRDB Demo/GRDB Demo/Models/ListViewModel.swift new file mode 100644 index 0000000..dde900e --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/ListViewModel.swift @@ -0,0 +1,67 @@ +import Auth +import Combine +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +struct ListsWithTodoCountsRequest: ValueObservationQueryable { + static var defaultValue: [ListWithTodoCounts] { [] } + + func fetch(_ database: Database) throws -> [ListWithTodoCounts] { + // Association for completed todos + let pendingTodos = List.todos.filter(Todo.Columns.isCompleted == false) + + // It's tricky to annotate with opposing checks for isCompleted at once + // So we just check the pending todos count + let request = List + .annotated(with: [ + pendingTodos.count.forKey("pendingCount") + ]).order(sql: "pendingCount DESC") + + return try ListWithTodoCounts.fetchAll(database, request) + } +} + +class ListViewModel { + let grdb: DatabasePool + let errorModel: ErrorViewModel + let supabaseModel: SupabaseViewModel + + init( + grdb: DatabasePool, + errorModel: ErrorViewModel, + supabaseModel: SupabaseViewModel + ) { + self.grdb = grdb + self.errorModel = errorModel + self.supabaseModel = supabaseModel + } + + func createList(name: String) throws { + try errorModel.withReporting("Could not create list") { + guard let userId = supabaseModel.session?.user.id.uuidString else { + throw NSError(domain: "AppError", code: 1, userInfo: [NSLocalizedDescriptionKey: "No userId or session found"]) + } + try grdb.write { database in + try List( + id: UUID().uuidString, + name: name, + ownerId: userId + ).insert(database) + } + } + } + + func deleteList(id: String) throws { + try errorModel.withReporting("Could not delete list") { + try grdb.write { database in + /// This should automatically delete all the todos due to the hasMany relationship + try List.deleteOne( + database, + key: id + ) + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/SupabaseViewModel.swift b/Demo/GRDB Demo/GRDB Demo/Models/SupabaseViewModel.swift new file mode 100644 index 0000000..666aabb --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/SupabaseViewModel.swift @@ -0,0 +1,111 @@ +import Combine +import Supabase +import SwiftUI + +class SupabaseViewModel: ObservableObject { + let client: SupabaseClient + @Published var session: Session? + + private var authTask: Task? + + init( + url: URL = Secrets.supabaseURL, + anonKey: String = Secrets.supabaseAnonKey + ) { + client = SupabaseClient( + supabaseURL: url, + supabaseKey: anonKey + ) + + // Start observing auth state changes + authTask = Task { [weak self] in + guard let self = self else { + fatalError("Could not watch Supabase") + } + for await change in self.client.auth.authStateChanges { + await MainActor.run { + self.session = change.session + } + } + } + // Set initial session + session = client.auth.currentSession + } + + deinit { + authTask?.cancel() + } + + func signIn( + email: String, + password: String, + completion: @escaping (Result) -> Void + ) { + Task { + do { + let session = try await client.auth.signIn(email: email, password: password) + await MainActor.run { + self.session = session + completion(.success(session)) + } + } catch { + await MainActor.run { + completion(.failure(error)) + } + } + } + } + + + func signOut( + hook: @escaping () async throws -> Void, + completion: @escaping (Result) -> Void + ) { + Task { + do { + try await client.auth.signOut() + try await hook() + await MainActor.run { + self.session = nil + completion(.success(())) + } + } catch { + await MainActor.run { + completion(.failure(error)) + } + } + } + } + + + + func register( + email: String, + password: String, + completion: @escaping (Result) -> Void + ) { + Task { + do { + let response = try await client.auth.signUp(email: email, password: password) + await MainActor.run { + guard let session = response.session else { + completion(.failure( + NSError( + domain: "SupabaseModel", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "No session returned. Please check your email for confirmation."] + ) + )) + return + } + self.session = session + completion(.success(session)) + } + } catch { + await MainActor.run { + completion(.failure(error)) + } + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift b/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift new file mode 100644 index 0000000..bbbf2b5 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift @@ -0,0 +1,73 @@ +import Combine +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +struct ListsTodosRequest: ValueObservationQueryable { + let list: ListWithTodoCounts + + static var defaultValue: [Todo] { [] } + + func fetch(_ database: Database) throws -> [Todo] { + try Todo + .filter(Todo.Columns.listId == list.id) + .order(Todo.Columns.name) + .order(Todo.Columns.isCompleted) + .fetchAll(database) + } +} + +@Observable +class TodoViewModel { + let grdb: DatabasePool + let errorModel: ErrorViewModel + + init( + grdb: DatabasePool, + errorModel: ErrorViewModel + ) { + self.grdb = grdb + self.errorModel = errorModel + } + + func createTodo(name: String, listId: String) throws { + try errorModel.withReporting("Could not create todo") { + try grdb.write { database in + try Todo( + id: UUID().uuidString, + name: name, + listId: listId, + isCompleted: false + ).insert(database) + } + } + } + + func toggleCompleted(todo: Todo) throws { + try errorModel.withReporting("Could not update completed at") { + var updatedTodo = todo + try grdb.write { database in + if todo.isCompleted { + updatedTodo.isCompleted = false + updatedTodo.completedAt = nil + } else { + updatedTodo.completedAt = Date() + updatedTodo.isCompleted = true + } + _ = try updatedTodo.update(database) + } + } + } + + func deleteTodo(_ id: String) throws { + try errorModel.withReporting("Could not delete todo") { + try grdb.write { database in + _ = try Todo.deleteOne( + database, + key: id + ) + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/ViewModels.swift b/Demo/GRDB Demo/GRDB Demo/Models/ViewModels.swift new file mode 100644 index 0000000..2e605a9 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/ViewModels.swift @@ -0,0 +1,33 @@ +import Combine +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +@Observable +class ViewModels { + let errorViewModel: ErrorViewModel + let listViewModel: ListViewModel + let todoViewModel: TodoViewModel + let supabaseViewModel: SupabaseViewModel + + let databases: Databases + + init( + databases: Databases, + ) { + self.databases = databases + errorViewModel = ErrorViewModel() + supabaseViewModel = SupabaseViewModel() + listViewModel = ListViewModel( + grdb: databases.grdb, + errorModel: errorViewModel, + supabaseModel: supabaseViewModel + ) + todoViewModel = TodoViewModel( + grdb: databases.grdb, + errorModel: errorViewModel + ) + } + +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/ErrorAlertView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/ErrorAlertView.swift new file mode 100644 index 0000000..7ba2d96 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/ErrorAlertView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// A Simple View which presents the latest error state from the `ErrorViewModel` +struct ErrorAlertView: View { + @Environment(ViewModels.self) var viewModels + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .alert(isPresented: Binding( + get: { viewModels.errorViewModel.errorMessage != nil }, + set: { newValue in + if !newValue { viewModels.errorViewModel.clear() } + } + )) { + Alert( + title: Text("Error"), + message: Text(viewModels.errorViewModel.errorMessage ?? ""), + dismissButton: .default(Text("OK")) { + viewModels.errorViewModel.clear() + } + ) + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/RootScreen.swift b/Demo/GRDB Demo/GRDB Demo/Screens/RootScreen.swift new file mode 100644 index 0000000..d6ba771 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/RootScreen.swift @@ -0,0 +1,14 @@ +import Auth +import SwiftUI + +struct RootScreen: View { + @ObservedObject var supabaseViewModel: SupabaseViewModel + + var body: some View { + if supabaseViewModel.session != nil { + ListsScreen() + } else { + SigninScreen() + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift new file mode 100644 index 0000000..16cd449 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift @@ -0,0 +1,71 @@ +import PowerSync +import SwiftUI + +struct StatusIndicatorView: View { + @Environment(ViewModels.self) var viewModels + + var powerSync: PowerSyncDatabaseProtocol { + viewModels.databases.powerSync + } + + @State var statusImageName: String = "wifi.slash" + @State private var showErrorAlert = false + + let content: () -> Content + + var body: some View { + content() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + if powerSync.currentStatus.anyError != nil { + showErrorAlert = true + } + } label: { + Image(systemName: statusImageName) + } + .contextMenu { + if powerSync.currentStatus.connected || powerSync.currentStatus.connecting { + Button("Disconnect") { + Task { + try await powerSync.disconnect() + } + } + } else { + Button("Connect") { + Task { + try await powerSync.connect( + connector: SupabaseConnector(supabase: viewModels.supabaseViewModel) + ) + } + } + } + } + } + } + .alert(isPresented: $showErrorAlert) { + Alert( + title: Text("Error"), + message: Text(String("\(powerSync.currentStatus.anyError ?? "Unknown error")")), + dismissButton: .default(Text("OK")) + ) + } + .task { + do { + for try await status in powerSync.currentStatus.asFlow() { + if powerSync.currentStatus.anyError != nil { + statusImageName = "exclamationmark.triangle.fill" + } else if status.connected { + statusImageName = "wifi" + } else if status.connecting { + statusImageName = "wifi.exclamationmark" + } else { + statusImageName = "wifi.slash" + } + } + } catch { + print("Could not monitor status") + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/lists/AddListSheet.swift b/Demo/GRDB Demo/GRDB Demo/Screens/lists/AddListSheet.swift new file mode 100644 index 0000000..857822c --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/lists/AddListSheet.swift @@ -0,0 +1,55 @@ +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +/// View which allows creating a new List +struct AddListSheet: View { + @Environment(ViewModels.self) var viewModels + + @Binding var isPresented: Bool + @State var newListName: String = "" + @FocusState private var isTextFieldFocused: Bool + + var body: some View { + VStack(spacing: 16) { + Text("New List") + .font(.headline) + TextField("List name", text: $newListName) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .focused($isTextFieldFocused) + .padding() + HStack { + Button("Cancel") { + isPresented = false + newListName = "" + } + Spacer() + Button("Add") { + do { + try viewModels.listViewModel.createList(name: newListName) + isPresented = false + newListName = "" + } catch { + // Don't close the dialog + print("Error adding list: \(error)") + } + } + .disabled(newListName.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(.horizontal) + } + .padding() + .onAppear { + isTextFieldFocused = true + } + .frame(width: 300) + .background( + RoundedRectangle(cornerRadius: 16) + .fill( + modalBackgroundColor + ) + .shadow(radius: 8) + ) + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListItemView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListItemView.swift new file mode 100644 index 0000000..69c187a --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListItemView.swift @@ -0,0 +1,51 @@ +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +/// Main view for viewing and editing Lists +struct ListItemView: View { + @Environment(ViewModels.self) var viewModels + + var list: ListWithTodoCounts + let onOpen: () -> Void + + var body: some View { + VStack { + HStack { + Text(list.name).font(.title) + Spacer() + Button { + onOpen() + } label: { + Image(systemName: "arrow.right.circle") + } + .buttonStyle(BorderlessButtonStyle()) + #if os(macOS) + Button { + try? viewModels.listViewModel.deleteList(id: list.id) + } label: { + Image(systemName: "trash") + } + .buttonStyle(BorderlessButtonStyle()) + .foregroundColor(.red) + #endif + } + HStack { + if list.pendingCount > 0 { + Text("\(list.pendingCount) Pending") + .font(.subheadline) + } + Spacer() + } + } + .padding() + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + try? viewModels.listViewModel.deleteList(id: list.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListsScreen.swift b/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListsScreen.swift new file mode 100644 index 0000000..6d80e4a --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListsScreen.swift @@ -0,0 +1,99 @@ +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +struct ListsScreen: View { + @Query(ListsWithTodoCountsRequest()) + var lists: [ListWithTodoCounts] + + @Environment(ViewModels.self) var viewModels + + @State private var showingAddSheet = false + @State private var selectedList: ListWithTodoCounts? + + var body: some View { + NavigationStack { + StatusIndicatorView { + ZStack { + SwiftUI.List(lists) { list in + ListItemView( + list: list + ) { + selectedList = list + } + } + + // Floating Action Button + VStack { + Spacer() + HStack { + Spacer() + Button { + showingAddSheet = true + } label: { + Image(systemName: "plus") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .padding() + .background(Circle().fill(Color.accentColor)) + .shadow(radius: 4) + } + .buttonStyle(BorderlessButtonStyle()) + .padding() + .accessibilityLabel("Create New List") + } + } + // Modal overlay + if showingAddSheet { + Color.black.opacity(0.3) // Dimmed background + .ignoresSafeArea() + AddListSheet(isPresented: $showingAddSheet) + .frame(width: 300) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(modalBackgroundColor) + .shadow(radius: 8) + ) + .transition(.scale) + } + } + } + .toolbar { + ToolbarItem(placement: .automatic) { + Button { + viewModels.supabaseViewModel.signOut { + try await viewModels.databases.powerSync.disconnectAndClear() + } completion: { _ in } + } label: { + Image(systemName: "rectangle.portrait.and.arrow.right") + } + } + } + .navigationTitle("Todo Lists") + // Navigation to TodosView + .navigationDestination(item: $selectedList) { list in + TodosView(list: list) + } + } + .task { + // Automatically connect on startup + try? await viewModels.errorViewModel.withReportingAsync { + try await viewModels.databases.powerSync.connect( + connector: SupabaseConnector( + supabase: viewModels.supabaseViewModel + ) + ) + } + } + } +} + +#Preview { + ListsScreen() + .environment( + ViewModels( + databases: openDatabase() + ) + ) +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift b/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift new file mode 100644 index 0000000..1e38256 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct SigninScreen: View { + @State private var email = "" + @State private var password = "" + @State private var isRegistering = false + @State private var errorMessage: String? + @State private var busy = false + + @FocusState private var emailFieldFocused: Bool + + @Environment(ViewModels.self) var viewModels + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0 / 255, green: 33 / 255, blue: 98 / 255), // #002162 + Color(red: 10 / 255, green: 43 / 255, blue: 120 / 255) // Slightly lighter blue + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 24) { + Image("logo") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .padding(.top, 40) + + Text(isRegistering ? "Register" : "Sign In") + .font(.largeTitle) + .bold() + .foregroundColor(.white) + + VStack(spacing: 16) { + TextField("Email", text: $email) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocapitalization(.none) + .keyboardType(.emailAddress) + .focused($emailFieldFocused) + + SecureField("Password", text: $password) + .textFieldStyle(RoundedBorderTextFieldStyle()) + } + .padding(.horizontal, 32) + + if let errorMessage = errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + } + + Button(isRegistering ? "Register" : "Sign In") { + if email.isEmpty || password.isEmpty { + errorMessage = "Please enter both email and password." + return + } + errorMessage = nil + busy = true + if isRegistering { + viewModels.supabaseViewModel.register( + email: email, + password: password + ) { result in + switch result { + case .success: + break + // Don't need to do anything, will be automatically navigated + case let .failure(error): + errorMessage = "Could not register: \(error)" + } + busy = false + } + } else { + viewModels.supabaseViewModel.signIn( + email: email, + password: password + ) { result in + switch result { + case .success: + // Don't need to do anything, will be automatically navigated + break + case let .failure(error): + errorMessage = "Could not login: \(error)" + } + busy = false + } + } + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .foregroundColor(.white) + .padding(.horizontal, 32) + + Button(isRegistering ? "Already have an account? Sign In" : "Don't have an account? Register") { + isRegistering.toggle() + errorMessage = nil + } + .font(.footnote) + .padding(.top, 8) + .foregroundColor(.white) + } + .padding() + .onAppear { + emailFieldFocused = true + } + } + } +} + +#Preview { + SigninScreen() + .environment( + ViewModels(databases: openDatabase() + ) + ) +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/todos/AddTodoSheet.swift b/Demo/GRDB Demo/GRDB Demo/Screens/todos/AddTodoSheet.swift new file mode 100644 index 0000000..6ecbaa7 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/todos/AddTodoSheet.swift @@ -0,0 +1,54 @@ +import GRDB +import GRDBQuery +import SwiftUI + +/// View which allows creating a new Todo +struct AddTodoSheet: View { + @Binding var isPresented: Bool + @State var newTodoName: String = "" + + @FocusState private var isTextFieldFocused: Bool + + var onAdd: (String) throws -> Void + + var body: some View { + VStack(spacing: 16) { + Text("New Todo") + .font(.headline) + TextField("Todo name", text: $newTodoName) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .focused($isTextFieldFocused) + .padding() + HStack { + Button("Cancel") { + isPresented = false + newTodoName = "" + } + Spacer() + Button("Add") { + do { + try onAdd(newTodoName) + // Close the sheet + isPresented = false + newTodoName = "" + } catch { + // Don't close the sheet + print("Error adding todo: \(error)") + } + } + .disabled(newTodoName.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(.horizontal) + } + .padding() + .onAppear { + isTextFieldFocused = true + } + .frame(width: 300) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(modalBackgroundColor) + .shadow(radius: 8) + ) + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift new file mode 100644 index 0000000..e1ef54e --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift @@ -0,0 +1,41 @@ +import GRDB +import GRDBQuery +import SwiftUI + +struct TodoItemView: View { + var todo: Todo + + @Environment(ViewModels.self) var viewModels + + static let completedAtFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm yyyy/MM/dd" + return formatter + }() + + var body: some View { + VStack { + HStack { + Text(todo.name).font(.title) + Spacer() + Button { + try? viewModels.todoViewModel.toggleCompleted(todo: todo) + } label: { + if todo.isCompleted { + Image(systemName: "checkmark.circle.fill").foregroundColor(.green) + } else { + // make the icon empty circle when not completed + Image(systemName: "circle").foregroundColor(.green) + } + } + } + HStack { + if let completedAt = todo.completedAt { + Text("Completed at \(Self.completedAtFormatter.string(from: completedAt))") + } + Spacer() + } + } + .padding() + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodosScreen.swift b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodosScreen.swift new file mode 100644 index 0000000..0365154 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodosScreen.swift @@ -0,0 +1,67 @@ +import GRDB +import GRDBQuery +import SwiftUI + +struct TodosView: View { + let list: ListWithTodoCounts + + @Environment(ViewModels.self) var viewModels + + @Query + var todos: [Todo] + + @State var showingAddSheet: Bool = false + + init(list: ListWithTodoCounts) { + self.list = list + _todos = Query(ListsTodosRequest(list: list)) + } + + var body: some View { + StatusIndicatorView { + ZStack { + SwiftUI.List(todos) { todo in + TodoItemView(todo: todo) + } + // Floating Action Button + VStack { + Spacer() + HStack { + Spacer() + Button { + showingAddSheet = true + } label: { + Image(systemName: "plus") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .padding() + .background(Circle().fill(Color.accentColor)) + .shadow(radius: 4) + } + .buttonStyle(BorderlessButtonStyle()) + .padding() + .accessibilityLabel("Create New Todo") + } + } + // Modal overlay + if showingAddSheet { + Color.black.opacity(0.4) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + showingAddSheet = false + } + AddTodoSheet( + isPresented: $showingAddSheet + ) { name in + try viewModels.todoViewModel.createTodo( + name: name, + listId: list.id + ) + } + } + } + + .navigationTitle(list.name) + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/view-helpers.swift b/Demo/GRDB Demo/GRDB Demo/Screens/view-helpers.swift new file mode 100644 index 0000000..faea651 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/view-helpers.swift @@ -0,0 +1,9 @@ +import SwiftUI + +var modalBackgroundColor: Color { + #if os(iOS) + return Color(.systemGray6) + #else + return Color(nsColor: .windowBackgroundColor) + #endif +} diff --git a/Demo/GRDB Demo/GRDB Demo/SupabaseConnector.swift b/Demo/GRDB Demo/GRDB Demo/SupabaseConnector.swift new file mode 100644 index 0000000..6c15991 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/SupabaseConnector.swift @@ -0,0 +1,107 @@ +import Auth +import PowerSync +import Supabase +import SwiftUI + +@MainActor // _session is mutable, limiting to the MainActor satisfies Sendable constraints +final class SupabaseConnector: PowerSyncBackendConnectorProtocol { + let supabase: SupabaseViewModel + + init( + supabase: SupabaseViewModel + ) { + self.supabase = supabase + } + + func fetchCredentials() async throws -> PowerSyncCredentials? { + guard let session = supabase.session else { + return nil + } + + return PowerSyncCredentials( + endpoint: Secrets.powerSyncEndpoint, + token: session.accessToken + ) + } + + func uploadData(database: PowerSyncDatabaseProtocol) async throws { + guard let transaction = try await database.getNextCrudTransaction() else { return } + + var lastEntry: CrudEntry? + do { + for entry in transaction.crud { + lastEntry = entry + let tableName = entry.table + + let table = supabase.client.from(tableName) + + switch entry.op { + case .put: + var data = entry.opData ?? [:] + data["id"] = entry.id + try await table.upsert(data).execute() + case .patch: + guard let opData = entry.opData else { continue } + try await table.update(opData).eq("id", value: entry.id).execute() + case .delete: + try await table.delete().eq("id", value: entry.id).execute() + } + } + + try await transaction.complete() + + } catch { + if let errorCode = PostgresFatalCodes.extractErrorCode(from: error), + PostgresFatalCodes.isFatalError(errorCode) + { + /// Instead of blocking the queue with these errors, + /// discard the (rest of the) transaction. + /// + /// Note that these errors typically indicate a bug in the application. + /// If protecting against data loss is important, save the failing records + /// elsewhere instead of discarding, and/or notify the user. + print("Data upload error: \(error)") + print("Discarding entry: \(lastEntry!)") + try await transaction.complete() + return + } + + print("Data upload error - retrying last entry: \(lastEntry!), \(error)") + throw error + } + } +} + + +private enum PostgresFatalCodes { + /// Postgres Response codes that we cannot recover from by retrying. + static let fatalResponseCodes: [String] = [ + // Class 22 — Data Exception + // Examples include data type mismatch. + "22...", + // Class 23 — Integrity Constraint Violation. + // Examples include NOT NULL, FOREIGN KEY and UNIQUE violations. + "23...", + // INSUFFICIENT PRIVILEGE - typically a row-level security violation + "42501", + ] + + static func isFatalError(_ code: String) -> Bool { + return fatalResponseCodes.contains { pattern in + code.range(of: pattern, options: [.regularExpression]) != nil + } + } + + static func extractErrorCode(from error: any Error) -> String? { + // Look for code: Optional("XXXXX") pattern + let errorString = String(describing: error) + if let range = errorString.range(of: "code: Optional\\(\"([^\"]+)\"\\)", options: .regularExpression), + let codeRange = errorString[range].range(of: "\"([^\"]+)\"", options: .regularExpression) + { + // Extract just the code from within the quotes + let code = errorString[codeRange].dropFirst().dropLast() + return String(code) + } + return nil + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/_Secrets.swift b/Demo/GRDB Demo/GRDB Demo/_Secrets.swift new file mode 100644 index 0000000..7f735cf --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/_Secrets.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A protocol which specified the base structure for secrets +protocol SecretsProvider { + static var powerSyncEndpoint: String { get } + static var supabaseURL: URL { get } + static var supabaseAnonKey: String { get } +} + +// Default conforming type +enum Secrets: SecretsProvider {} diff --git a/Demo/GRDB Demo/GRDB DemoTests/GRDB_DemoTests.swift b/Demo/GRDB Demo/GRDB DemoTests/GRDB_DemoTests.swift new file mode 100644 index 0000000..e91661b --- /dev/null +++ b/Demo/GRDB Demo/GRDB DemoTests/GRDB_DemoTests.swift @@ -0,0 +1,7 @@ +import Testing + +struct GRDB_DemoTests { + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } +} diff --git a/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITests.swift b/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITests.swift new file mode 100644 index 0000000..bf31784 --- /dev/null +++ b/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITests.swift @@ -0,0 +1,33 @@ +import XCTest + +final class GRDB_DemoUITests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITestsLaunchTests.swift b/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITestsLaunchTests.swift new file mode 100644 index 0000000..26d4c34 --- /dev/null +++ b/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITestsLaunchTests.swift @@ -0,0 +1,25 @@ +import XCTest + +final class GRDB_DemoUITestsLaunchTests: XCTestCase { + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Package.resolved b/Package.resolved index 8926049..89dbb6f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "branch" : "dev/persistable-views", + "revision" : "63d92eab609bf230feb6f8d2c695a7e510519530" + } + }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index aac1063..8e04a35 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let packageName = "PowerSync" // Set this to the absolute path of your Kotlin SDK checkout if you want to use a local Kotlin // build. Also see docs/LocalBuild.md for details -let localKotlinSdkOverride: String? = nil +let localKotlinSdkOverride: String? = "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin" // Set this to the absolute path of your powersync-sqlite-core checkout if you want to use a // local build of the core extension. @@ -71,9 +71,15 @@ let package = Package( // Dynamic linking is particularly important for XCode previews. type: .dynamic, targets: ["PowerSync"] + ), + .library( + name: "PowerSyncGRDB", + targets: ["PowerSyncGRDB"] ) ], - dependencies: conditionalDependencies, + dependencies: conditionalDependencies + [ + .package(url: "https://github.com/groue/GRDB.swift.git", branch: "dev/persistable-views") + ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. @@ -84,9 +90,20 @@ let package = Package( .product(name: "PowerSyncSQLiteCore", package: corePackageName) ] ), + .target( + name: "PowerSyncGRDB", + dependencies: [ + .target(name: "PowerSync"), + .product(name: "GRDB", package: "GRDB.swift") + ] + ), .testTarget( name: "PowerSyncTests", dependencies: ["PowerSync"] + ), + .testTarget( + name: "PowerSyncGRDBTests", + dependencies: ["PowerSync", "PowerSyncGRDB"] ) ] + conditionalTargets ) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 83cdf6c..0943d42 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -11,18 +11,11 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, let currentStatus: SyncStatus init( - schema: Schema, - dbFilename: String, + kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase, logger: DatabaseLogger ) { - let factory = PowerSyncKotlin.DatabaseDriverFactory() - kotlinDatabase = PowerSyncDatabase( - factory: factory, - schema: KotlinAdapter.Schema.toKotlin(schema), - dbFilename: dbFilename, - logger: logger.kLogger - ) self.logger = logger + self.kotlinDatabase = kotlinDatabase currentStatus = KotlinSyncStatus( baseStatus: kotlinDatabase.currentStatus ) @@ -401,6 +394,39 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, } } +func openKotlinDBWithFactory( + schema: Schema, + dbFilename: String, + logger: DatabaseLogger +) -> PowerSyncDatabaseProtocol { + return KotlinPowerSyncDatabaseImpl( + kotlinDatabase: PowerSyncDatabase( + factory: PowerSyncKotlin.DatabaseDriverFactory(), + schema: KotlinAdapter.Schema.toKotlin(schema), + dbFilename: dbFilename, + logger: logger.kLogger + ), + logger: logger + ) +} + +func openKotlinDBWithPool( + schema: Schema, + pool: SQLiteConnectionPoolProtocol, + identifier: String, + logger: DatabaseLogger +) -> PowerSyncDatabaseProtocol { + return KotlinPowerSyncDatabaseImpl( + kotlinDatabase: openPowerSyncWithPool( + pool: pool.toKotlin(), + identifier: identifier, + schema: KotlinAdapter.Schema.toKotlin(schema), + logger: logger.kLogger + ), + logger: logger + ) +} + private struct ExplainQueryResult { let addr: String let opcode: String diff --git a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift new file mode 100644 index 0000000..5dd503e --- /dev/null +++ b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift @@ -0,0 +1,82 @@ +import PowerSyncKotlin + +final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { + let pool: SQLiteConnectionPoolProtocol + + init( + pool: SQLiteConnectionPoolProtocol + ) { + self.pool = pool + } + + func getPendingUpdates() -> Set { + return pool.getPendingUpdates() + } + + func __closePool() async throws { + do { + try pool.close() + } catch { + try? PowerSyncKotlin.throwPowerSyncException( + exception: PowerSyncException( + message: error.localizedDescription, + cause: nil + ) + ) + } + } + + func __leaseRead(callback: @escaping (Any) -> Void) async throws { + do { + try await pool.read { pointer in + callback(UInt(bitPattern: pointer)) + } + } catch { + try? PowerSyncKotlin.throwPowerSyncException( + exception: PowerSyncException( + message: error.localizedDescription, + cause: nil + ) + ) + } + } + + func __leaseWrite(callback: @escaping (Any) -> Void) async throws { + do { + try await pool.write { pointer in + callback(UInt(bitPattern: pointer)) + } + } catch { + try? PowerSyncKotlin.throwPowerSyncException( + exception: PowerSyncException( + message: error.localizedDescription, + cause: nil + ) + ) + } + } + + func __leaseAll(callback: @escaping (Any, [Any]) -> Void) async throws { + // TODO, actually use all connections + do { + try await pool.write { pointer in + callback(UInt(bitPattern: pointer), []) + } + } catch { + try? PowerSyncKotlin.throwPowerSyncException( + exception: PowerSyncException( + message: error.localizedDescription, + cause: nil + ) + ) + } + } +} + +extension SQLiteConnectionPoolProtocol { + func toKotlin() -> PowerSyncKotlin.SwiftSQLiteConnectionPool { + return PowerSyncKotlin.SwiftSQLiteConnectionPool( + adapter: SwiftSQLiteConnectionPoolAdapter(pool: self) + ) + } +} diff --git a/Sources/PowerSync/PowerSyncDatabase.swift b/Sources/PowerSync/PowerSyncDatabase.swift index dbabf92..0cc9995 100644 --- a/Sources/PowerSync/PowerSyncDatabase.swift +++ b/Sources/PowerSync/PowerSyncDatabase.swift @@ -14,10 +14,23 @@ public func PowerSyncDatabase( dbFilename: String = DEFAULT_DB_FILENAME, logger: (any LoggerProtocol) = DefaultLogger() ) -> PowerSyncDatabaseProtocol { - - return KotlinPowerSyncDatabaseImpl( + return openKotlinDBWithFactory( schema: schema, dbFilename: dbFilename, logger: DatabaseLogger(logger) ) } + +public func OpenedPowerSyncDatabase( + schema: Schema, + pool: any SQLiteConnectionPoolProtocol, + identifier: String, + logger: (any LoggerProtocol) = DefaultLogger() +) -> PowerSyncDatabaseProtocol { + return openKotlinDBWithPool( + schema: schema, + pool: pool, + identifier: identifier, + logger: DatabaseLogger(logger) + ) +} diff --git a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift new file mode 100644 index 0000000..fb2be95 --- /dev/null +++ b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift @@ -0,0 +1,28 @@ +import Foundation + +/// An implementation of a connection pool providing asynchronous access to a single writer and multiple readers. +/// This is the underlying pool implementation on which the higher-level PowerSync Swift SDK is built on. +public protocol SQLiteConnectionPoolProtocol { + func getPendingUpdates() -> Set + + /// Calls the callback with a read-only connection temporarily leased from the pool. + func read( + onConnection: @Sendable @escaping (OpaquePointer) -> Void, + ) async throws + + /// Calls the callback with a read-write connection temporarily leased from the pool. + func write( + onConnection: @Sendable @escaping (OpaquePointer) -> Void, + ) async throws + + /// Invokes the callback with all connections leased from the pool. + func withAllConnections( + onConnection: @Sendable @escaping ( + _ writer: OpaquePointer, + _ readers: [OpaquePointer] + ) -> Void, + ) async throws + + /// Closes the connection pool and associated resources. + func close() throws +} diff --git a/Sources/PowerSyncGRDB/GRDBPool.swift b/Sources/PowerSyncGRDB/GRDBPool.swift new file mode 100644 index 0000000..048a6c5 --- /dev/null +++ b/Sources/PowerSyncGRDB/GRDBPool.swift @@ -0,0 +1,171 @@ +import Foundation +import GRDB +import PowerSync +import SQLite3 + +// The system SQLite does not expose this, +// linking PowerSync provides them +// Declare the missing function manually +@_silgen_name("sqlite3_enable_load_extension") +func sqlite3_enable_load_extension( + _ db: OpaquePointer?, + _ onoff: Int32 +) -> Int32 + +// Similarly for sqlite3_load_extension if needed: +@_silgen_name("sqlite3_load_extension") +func sqlite3_load_extension( + _ db: OpaquePointer?, + _ fileName: UnsafePointer?, + _ procName: UnsafePointer?, + _ errMsg: UnsafeMutablePointer?>? +) -> Int32 + +enum PowerSyncGRDBError: Error { + case coreBundleNotFound + case extensionLoadFailed(String) + case unknownExtensionLoadError + case connectionUnavailable +} + +struct PowerSyncSchemaSource: DatabaseSchemaSource { + let schema: Schema + + func columnsForPrimaryKey(_: Database, inView view: DatabaseObjectID) throws -> [String]? { + if schema.tables.first(where: { table in + table.viewName == view.name + }) != nil { + return ["id"] + } + return nil + } +} + +public func configurePowerSync( + config: inout Configuration, + schema: Schema +) { + // Register the PowerSync core extension + config.prepareDatabase { database in + guard let bundle = Bundle(identifier: "co.powersync.sqlitecore") else { + throw PowerSyncGRDBError.coreBundleNotFound + } + + // Construct the full path to the shared library inside the bundle + let fullPath = bundle.bundlePath + "/powersync-sqlite-core" + + let extensionLoadResult = sqlite3_enable_load_extension(database.sqliteConnection, 1) + if extensionLoadResult != SQLITE_OK { + throw PowerSyncGRDBError.extensionLoadFailed("Could not enable extension loading") + } + var errorMsg: UnsafeMutablePointer? + let loadResult = sqlite3_load_extension(database.sqliteConnection, fullPath, "sqlite3_powersync_init", &errorMsg) + if loadResult != SQLITE_OK { + if let errorMsg = errorMsg { + let message = String(cString: errorMsg) + sqlite3_free(errorMsg) + throw PowerSyncGRDBError.extensionLoadFailed(message) + } else { + throw PowerSyncGRDBError.unknownExtensionLoadError + } + } + } + + // Supply the PowerSync views as a SchemaSource + let powerSyncSchemaSource = PowerSyncSchemaSource( + schema: schema + ) + if let schemaSource = config.schemaSource { + config.schemaSource = schemaSource.then(powerSyncSchemaSource) + } else { + config.schemaSource = powerSyncSchemaSource + } +} + +final class PowerSyncTransactionObserver: TransactionObserver { + let onChange: (_ tableName: String) -> Void + + init( + onChange: @escaping (_ tableName: String) -> Void + ) { + self.onChange = onChange + } + + func observes(eventsOfKind _: DatabaseEventKind) -> Bool { + // We want all the events for the PowerSync SDK + return true + } + + func databaseDidChange(with event: DatabaseEvent) { + onChange(event.tableName) + } + + func databaseDidCommit(_: GRDB.Database) {} + + func databaseDidRollback(_: GRDB.Database) {} +} + +public final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { + let pool: DatabasePool + var pendingUpdates: Set + private let pendingUpdatesQueue = DispatchQueue( + label: "co.powersync.pendingUpdatesQueue" + ) + + public init( + pool: DatabasePool + ) { + self.pool = pool + self.pendingUpdates = Set() + pool.add( + transactionObserver: PowerSyncTransactionObserver { tableName in + // push the update + self.pendingUpdatesQueue.sync { + self.pendingUpdates.insert(tableName) + } + }, + extent: .databaseLifetime + ) + } + + public func getPendingUpdates() -> Set { + self.pendingUpdatesQueue.sync { + let copy = self.pendingUpdates + self.pendingUpdates.removeAll() + return copy + } + } + + public func read( + onConnection: @Sendable @escaping (OpaquePointer) -> Void + ) async throws { + try await pool.read { database in + guard let connection = database.sqliteConnection else { + throw PowerSyncGRDBError.connectionUnavailable + } + onConnection(connection) + } + } + + public func write( + onConnection: @Sendable @escaping (OpaquePointer) -> Void + ) async throws { + // Don't start an explicit transaction + try await pool.writeWithoutTransaction { database in + guard let connection = database.sqliteConnection else { + throw PowerSyncGRDBError.connectionUnavailable + } + onConnection(connection) + } + } + + public func withAllConnections( + onConnection _: @escaping (OpaquePointer, [OpaquePointer]) -> Void + ) async throws { + // TODO: + } + + public func close() throws { + try pool.close() + } +} diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift new file mode 100644 index 0000000..af63ced --- /dev/null +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -0,0 +1,377 @@ +@testable import GRDB +@testable import PowerSync +@testable import PowerSyncGRDB + +import XCTest + +struct User: Codable, Identifiable, FetchableRecord, PersistableRecord { + var id: String + var name: String + + static var databaseTableName = "users" + + enum Columns { + static let id = Column(CodingKeys.id) + static let name = Column(CodingKeys.name) + } +} + +struct Pet: Codable, Identifiable, FetchableRecord, PersistableRecord { + var id: String + var name: String + var ownerId: String + + static var databaseTableName = "pets" + + enum CodingKeys: String, CodingKey { + case id + case name + case ownerId = "owner_id" + } + + enum Columns { + static let ownerId = Column(CodingKeys.ownerId) + } + + static let user = belongsTo( + User.self, + key: "user", + using: ForeignKey([Columns.ownerId], to: [User.Columns.id]) + ) +} + +final class GRDBTests: XCTestCase { + private var database: PowerSyncDatabaseProtocol! + private var schema: Schema! + private var pool: DatabasePool! + + override func setUp() async throws { + try await super.setUp() + schema = Schema(tables: [ + Table(name: "users", columns: [ + .text("name") + ]), + Table(name: "pets", columns: [ + .text("name"), + .text("owner_id") + ]) + ]) + + var config = Configuration() + configurePowerSync( + config: &config, + schema: schema + ) + + let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let dbURL = documentsDir.appendingPathComponent("test.sqlite") + pool = try DatabasePool( + path: dbURL.path, + configuration: config + ) + + database = OpenedPowerSyncDatabase( + schema: schema, + pool: GRDBConnectionPool( + pool: pool + ), + identifier: "test" + ) + + try await database.disconnectAndClear() + } + + override func tearDown() async throws { + try await database.disconnectAndClear() + database = nil + try await super.tearDown() + } + + func testBasicOperations() async throws { + // Create users with the PowerSync SDK + let initialUserName = "Bob" + + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: [initialUserName] + ) + + // Fetch those users + let initialUserNames = try await database.getAll( + "SELECT * FROM users" + ) { cursor in + try cursor.getString(name: "name") + } + + XCTAssertTrue(initialUserNames.first == initialUserName) + + // Now define a GRDB struct for query purposes + // Query the Users with GRDB, this should have the same result as with PowerSync + let grdbUserNames = try await pool.read { database in + try User.fetchAll(database) + } + + XCTAssertTrue(grdbUserNames.first?.name == initialUserName) + + // Insert a user with GRDB + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "another", + ).insert(database) + } + + let grdbUserNames2 = try await pool.read { database in + try User.order(User.Columns.name.asc).fetchAll(database) + } + XCTAssert(grdbUserNames2.count == 2) + XCTAssert(grdbUserNames2[1].name == "another") + } + + func testJoins() async throws { + // Create users with the PowerSync SDK + try await pool.write { database in + let userId = UUID().uuidString + try User( + id: userId, + name: "Bob" + ).insert(database) + + try Pet( + id: UUID().uuidString, + name: "Fido", + ownerId: userId + ).insert(database) + } + + struct PetWithUser: Decodable, FetchableRecord { + struct PartialUser: Decodable { + var name: String + } + + var pet: Pet // The base record + var user: PartialUser // The partial associated record + } + + let petsWithUsers = try await pool.read { db in + try Pet + .including(required: Pet.user) + .asRequest(of: PetWithUser.self) + .fetchAll(db) + } + + XCTAssert(petsWithUsers.count == 1) + XCTAssert(petsWithUsers[0].pet.name == "Fido") + XCTAssert(petsWithUsers[0].user.name == "Bob") + } + + func testPowerSyncUpdates() async throws { + let expectation = XCTestExpectation(description: "Watch changes") + + // Create an actor to handle concurrent mutations + actor ResultsStore { + private var results: Set = [] + + func append(_ names: [String]) { + results.formUnion(names) + } + + func getResults() -> Set { + results + } + + func count() -> Int { + results.count + } + } + + let resultsStore = ResultsStore() + + let watchTask = Task { + let stream = try database.watch( + options: WatchOptions( + sql: "SELECT name FROM users ORDER BY id", + mapper: { cursor in + try cursor.getString(index: 0) + } + )) + for try await names in stream { + await resultsStore.append(names) + if await resultsStore.count() == 2 { + expectation.fulfill() + } + } + } + + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: ["one"] + ) + + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: ["two"] + ) + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + } + + func testPowerSyncUpdatesFromGRDB() async throws { + let expectation = XCTestExpectation(description: "Watch changes") + + // Create an actor to handle concurrent mutations + actor ResultsStore { + private var results: Set = [] + + func append(_ names: [String]) { + results.formUnion(names) + } + + func getResults() -> Set { + results + } + + func count() -> Int { + results.count + } + } + + let resultsStore = ResultsStore() + + let watchTask = Task { + let stream = try database.watch( + options: WatchOptions( + sql: "SELECT name FROM users ORDER BY id", + mapper: { cursor in + try cursor.getString(index: 0) + } + )) + for try await names in stream { + await resultsStore.append(names) + if await resultsStore.count() == 2 { + expectation.fulfill() + } + } + } + + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "one", + ).insert(database) + } + + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "two", + ).insert(database) + } + + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + } + + func testGRDBUpdatesFromPowerSync() async throws { + let expectation = XCTestExpectation(description: "Watch changes") + + // Create an actor to handle concurrent mutations + actor ResultsStore { + private var results: Set = [] + + func append(_ names: [String]) { + results.formUnion(names) + } + + func getResults() -> Set { + results + } + + func count() -> Int { + results.count + } + } + + let resultsStore = ResultsStore() + + let watchTask = Task { + let observation = ValueObservation.tracking { + try User.order(User.Columns.name.asc).fetchAll($0) + } + + for try await users in observation.values(in: pool) { + print("users \(users)") + await resultsStore.append(users.map { $0.name }) + if await resultsStore.count() == 2 { + expectation.fulfill() + } + } + } + + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: ["one"] + ) + + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: ["two"] + ) + + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + } + + func testGRDBUpdatesFromGRDB() async throws { + let expectation = XCTestExpectation(description: "Watch changes") + + // Create an actor to handle concurrent mutations + actor ResultsStore { + private var results: Set = [] + + func append(_ names: [String]) { + results.formUnion(names) + } + + func getResults() -> Set { + results + } + + func count() -> Int { + results.count + } + } + + let resultsStore = ResultsStore() + + let watchTask = Task { + let observation = ValueObservation.tracking { + try User.order(User.Columns.name.asc).fetchAll($0) + } + + for try await users in observation.values(in: pool) { + await resultsStore.append(users.map { $0.name }) + if await resultsStore.count() == 2 { + expectation.fulfill() + } + } + } + + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "one", + ).insert(database) + } + + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "two", + ).insert(database) + } + + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + } +} diff --git a/Tests/PowerSyncTests/ConnectTests.swift b/Tests/PowerSyncTests/ConnectTests.swift index 9473d21..d4cbb92 100644 --- a/Tests/PowerSyncTests/ConnectTests.swift +++ b/Tests/PowerSyncTests/ConnectTests.swift @@ -18,7 +18,7 @@ final class ConnectTests: XCTestCase { ), ]) - database = KotlinPowerSyncDatabaseImpl( + database = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(DefaultLogger()) diff --git a/Tests/PowerSyncTests/CrudTests.swift b/Tests/PowerSyncTests/CrudTests.swift index 5b303d6..dda805a 100644 --- a/Tests/PowerSyncTests/CrudTests.swift +++ b/Tests/PowerSyncTests/CrudTests.swift @@ -19,7 +19,7 @@ final class CrudTests: XCTestCase { ), ]) - database = KotlinPowerSyncDatabaseImpl( + database = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(DefaultLogger()) @@ -33,7 +33,7 @@ final class CrudTests: XCTestCase { database = nil try await super.tearDown() } - + func testTrackMetadata() async throws { try await database.updateSchema(schema: Schema(tables: [ Table(name: "lists", columns: [.text("name")], trackMetadata: true) @@ -43,10 +43,10 @@ final class CrudTests: XCTestCase { guard let batch = try await database.getNextCrudTransaction() else { return XCTFail("Should have batch after insert") } - + XCTAssertEqual(batch.crud[0].metadata, "so meta") } - + func testTrackPreviousValues() async throws { try await database.updateSchema(schema: Schema(tables: [ Table( @@ -55,18 +55,18 @@ final class CrudTests: XCTestCase { trackPreviousValues: TrackPreviousValuesOptions() ) ])) - + try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')") try await database.execute("DELETE FROM ps_crud") try await database.execute("UPDATE lists SET name = 'new name'") - + guard let batch = try await database.getNextCrudTransaction() else { return XCTFail("Should have batch after update") } - + XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry", "content": "content"]) } - + func testTrackPreviousValuesWithFilter() async throws { try await database.updateSchema(schema: Schema(tables: [ Table( @@ -77,18 +77,18 @@ final class CrudTests: XCTestCase { ) ) ])) - + try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')") try await database.execute("DELETE FROM ps_crud") try await database.execute("UPDATE lists SET name = 'new name'") - + guard let batch = try await database.getNextCrudTransaction() else { return XCTFail("Should have batch after update") } - + XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry"]) } - + func testTrackPreviousValuesOnlyWhenChanged() async throws { try await database.updateSchema(schema: Schema(tables: [ Table( @@ -99,18 +99,18 @@ final class CrudTests: XCTestCase { ) ) ])) - + try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')") try await database.execute("DELETE FROM ps_crud") try await database.execute("UPDATE lists SET name = 'new name'") - + guard let batch = try await database.getNextCrudTransaction() else { return XCTFail("Should have batch after update") } - + XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry"]) } - + func testIgnoreEmptyUpdate() async throws { try await database.updateSchema(schema: Schema(tables: [ Table(name: "lists", columns: [.text("name")], ignoreEmptyUpdates: true) @@ -118,7 +118,7 @@ final class CrudTests: XCTestCase { try await database.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'test')") try await database.execute("DELETE FROM ps_crud") try await database.execute("UPDATE lists SET name = 'test'") // Same value! - + let batch = try await database.getNextCrudTransaction() XCTAssertNil(batch) } @@ -138,11 +138,11 @@ final class CrudTests: XCTestCase { guard let limitedBatch = try await database.getCrudBatch(limit: 50) else { return XCTFail("Failed to get crud batch") } - + guard let crudItem = limitedBatch.crud.first else { return XCTFail("Crud batch should contain crud entries") } - + // This should show as a string even though it's a number // This is what the typing conveys let opData = crudItem.opData?["favorite_number"] @@ -150,35 +150,35 @@ final class CrudTests: XCTestCase { XCTAssert(limitedBatch.hasMore == true) XCTAssert(limitedBatch.crud.count == 50) - + guard let fullBatch = try await database.getCrudBatch() else { return XCTFail("Failed to get crud batch") } - + XCTAssert(fullBatch.hasMore == false) XCTAssert(fullBatch.crud.count == 100) - + guard let nextTx = try await database.getNextCrudTransaction() else { return XCTFail("Failed to get transaction crud batch") } - + XCTAssert(nextTx.crud.count == 100) - + for r in nextTx.crud { print(r) } - + // Completing the transaction should clear the items try await nextTx.complete() - + let afterCompleteBatch = try await database.getNextCrudTransaction() - + for r in afterCompleteBatch?.crud ?? [] { print(r) } - + XCTAssertNil(afterCompleteBatch) - + try await database.writeTransaction { tx in for i in 0 ..< 100 { try tx.execute( @@ -187,7 +187,7 @@ final class CrudTests: XCTestCase { ) } } - + guard let finalBatch = try await database.getCrudBatch(limit: 100) else { return XCTFail("Failed to get crud batch") } @@ -195,7 +195,7 @@ final class CrudTests: XCTestCase { XCTAssert(finalBatch.hasMore == false) // Calling complete without a writeCheckpoint param should be possible try await finalBatch.complete() - + let finalValidationBatch = try await database.getCrudBatch(limit: 100) XCTAssertNil(finalValidationBatch) } diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index ebecd44..235910a 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -513,7 +513,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let testWriter = TestLogWriterAdapter() let logger = DefaultLogger(minSeverity: LogSeverity.debug, writers: [testWriter]) - let db2 = KotlinPowerSyncDatabaseImpl( + let db2 = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(logger) @@ -534,7 +534,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let testWriter = TestLogWriterAdapter() let logger = DefaultLogger(minSeverity: LogSeverity.error, writers: [testWriter]) - let db2 = KotlinPowerSyncDatabaseImpl( + let db2 = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(logger) diff --git a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift index 6fa5cf5..9eb83a9 100644 --- a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift +++ b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift @@ -14,7 +14,7 @@ struct UserOptional { let isActive: Bool? let weight: Double? let description: String? - + init( id: String, count: Int? = nil, @@ -51,9 +51,9 @@ func createTestUser( } final class SqlCursorTests: XCTestCase { - private var database: KotlinPowerSyncDatabaseImpl! + private var database: PowerSyncDatabaseProtocol! private var schema: Schema! - + override func setUp() async throws { try await super.setUp() schema = Schema(tables: [ @@ -64,26 +64,26 @@ final class SqlCursorTests: XCTestCase { .text("description") ]) ]) - - database = KotlinPowerSyncDatabaseImpl( + + database = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(DefaultLogger()) ) try await database.disconnectAndClear() } - + override func tearDown() async throws { try await database.disconnectAndClear() database = nil try await super.tearDown() } - + func testValidValues() async throws { try await createTestUser( db: database ) - + let user: User = try await database.get( sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", parameters: ["1"] @@ -95,19 +95,19 @@ final class SqlCursorTests: XCTestCase { weight: cursor.getDouble(name: "weight") ) } - + XCTAssertEqual(user.id, "1") XCTAssertEqual(user.count, 110) XCTAssertEqual(user.isActive, false) XCTAssertEqual(user.weight, 1.1111) } - + /// Uses the indexed based cursor methods to obtain a required column value func testValidValuesWithIndex() async throws { try await createTestUser( db: database ) - + let user = try await database.get( sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", parameters: ["1"] @@ -119,37 +119,37 @@ final class SqlCursorTests: XCTestCase { weight: cursor.getDoubleOptional(index: 3) ) } - + XCTAssertEqual(user.id, "1") XCTAssertEqual(user.count, 110) XCTAssertEqual(user.isActive, false) XCTAssertEqual(user.weight, 1.1111) } - + /// Uses index based cursor methods which are optional and don't throw func testIndexNoThrow() async throws { try await createTestUser( db: database ) - + let user = try await database.get( sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", parameters: ["1"] ) { cursor in - UserOptional( + UserOptional( id: cursor.getStringOptional(index: 0) ?? "1", count: cursor.getIntOptional(index: 1), isActive: cursor.getBooleanOptional(index: 2), weight: cursor.getDoubleOptional(index: 3) ) } - + XCTAssertEqual(user.id, "1") XCTAssertEqual(user.count, 110) XCTAssertEqual(user.isActive, false) XCTAssertEqual(user.weight, 1.1111) } - + func testOptionalValues() async throws { try await createTestUser( db: database, @@ -161,7 +161,7 @@ final class SqlCursorTests: XCTestCase { description: nil ) ) - + let user: UserOptional = try await database.get( sql: "SELECT id, count, is_active, weight, description FROM users WHERE id = ?", parameters: ["1"] @@ -174,20 +174,20 @@ final class SqlCursorTests: XCTestCase { description: cursor.getStringOptional(name: "description") ) } - + XCTAssertEqual(user.id, "1") XCTAssertNil(user.count) XCTAssertNil(user.isActive) XCTAssertNil(user.weight) XCTAssertNil(user.description) } - + /// Tests that a `mapper` which does not throw is accepted by the protocol func testNoThrow() async throws { try await createTestUser( db: database ) - + let user = try await database.get( sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", parameters: ["1"] @@ -200,18 +200,18 @@ final class SqlCursorTests: XCTestCase { description: nil ) } - + XCTAssertEqual(user.id, "1") XCTAssertEqual(user.count, 110) XCTAssertEqual(user.isActive, false) XCTAssertEqual(user.weight, 1.1111) } - + func testThrowsForMissingColumn() async throws { try await createTestUser( db: database ) - + do { _ = try await database.get( sql: "SELECT id FROM users", @@ -227,7 +227,7 @@ final class SqlCursorTests: XCTestCase { XCTFail("Unexpected error type: \(error)") } } - + func testThrowsForNullValuedRequiredColumn() async throws { /// Create a test user with nil stored in columns try await createTestUser( @@ -240,7 +240,7 @@ final class SqlCursorTests: XCTestCase { description: nil ) ) - + do { _ = try await database.get( sql: "SELECT description FROM users", @@ -257,7 +257,7 @@ final class SqlCursorTests: XCTestCase { XCTFail("Unexpected error type: \(error)") } } - + /// Index based cursor methods should throw if null is returned for required values func testThrowsForNullValuedRequiredColumnIndex() async throws { /// Create a test user with nil stored in columns @@ -271,7 +271,7 @@ final class SqlCursorTests: XCTestCase { description: nil ) ) - + do { _ = try await database.get( sql: "SELECT description FROM users",