Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Parsing speed improvements #4297

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,15 @@
B3F8418F26F3A93400E560FB /* ErrorCodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F8418E26F3A93400E560FB /* ErrorCodeTests.swift */; };
C3AD12BA2C6EA61F00A4F86F /* CompatibilityNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AD12B92C6EA61F00A4F86F /* CompatibilityNavigationStack.swift */; };
C3AD12BC2C6EA69D00A4F86F /* SubscriptionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AD12BB2C6EA69D00A4F86F /* SubscriptionDetailsView.swift */; };
C5434EBE2CA3462900FE8F15 /* base64encoded_sandboxReceipt2.txt in Resources */ = {isa = PBXBuildFile; fileRef = C5434EBA2CA3462900FE8F15 /* base64encoded_sandboxReceipt2.txt */; };
C5434EBF2CA3462900FE8F15 /* base64encoded_sandboxReceipt3.txt in Resources */ = {isa = PBXBuildFile; fileRef = C5434EBB2CA3462900FE8F15 /* base64encoded_sandboxReceipt3.txt */; };
C5434EC02CA3462900FE8F15 /* base64encoded_unsupportedReceipt1.txt in Resources */ = {isa = PBXBuildFile; fileRef = C5434EBC2CA3462900FE8F15 /* base64encoded_unsupportedReceipt1.txt */; };
C5434EC12CA3462900FE8F15 /* base64encoded_unsupportedReceipt2.txt in Resources */ = {isa = PBXBuildFile; fileRef = C5434EBD2CA3462900FE8F15 /* base64encoded_unsupportedReceipt2.txt */; };
C5434EC62CA3475800FE8F15 /* base64encoded_sandboxReceipt2.txt in CopyFiles */ = {isa = PBXBuildFile; fileRef = C5434EBA2CA3462900FE8F15 /* base64encoded_sandboxReceipt2.txt */; };
C5434EC72CA3475800FE8F15 /* base64encoded_sandboxReceipt3.txt in CopyFiles */ = {isa = PBXBuildFile; fileRef = C5434EBB2CA3462900FE8F15 /* base64encoded_sandboxReceipt3.txt */; };
C5434EC82CA3475800FE8F15 /* base64encoded_unsupportedReceipt1.txt in CopyFiles */ = {isa = PBXBuildFile; fileRef = C5434EBC2CA3462900FE8F15 /* base64encoded_unsupportedReceipt1.txt */; };
C5434EC92CA3475800FE8F15 /* base64encoded_unsupportedReceipt2.txt in CopyFiles */ = {isa = PBXBuildFile; fileRef = C5434EBD2CA3462900FE8F15 /* base64encoded_unsupportedReceipt2.txt */; };
C5434ECB2CA347BB00FE8F15 /* ISO8601DateFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5434ECA2CA347B800FE8F15 /* ISO8601DateFormatterTests.swift */; };
F516BD29282434070083480B /* StoreKit2StorefrontListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = F516BD28282434070083480B /* StoreKit2StorefrontListener.swift */; };
F52CFAFC28290AE500E8ABC5 /* StoreKit2StorefrontListenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F516BD322828FDD90083480B /* StoreKit2StorefrontListenerTests.swift */; };
F530E4FF275646EF001AF6BD /* MacDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = F530E4FE275646EF001AF6BD /* MacDevice.swift */; };
Expand Down Expand Up @@ -1123,6 +1132,10 @@
dstPath = "";
dstSubfolderSpec = 7;
files = (
C5434EC62CA3475800FE8F15 /* base64encoded_sandboxReceipt2.txt in CopyFiles */,
C5434EC72CA3475800FE8F15 /* base64encoded_sandboxReceipt3.txt in CopyFiles */,
C5434EC82CA3475800FE8F15 /* base64encoded_unsupportedReceipt1.txt in CopyFiles */,
C5434EC92CA3475800FE8F15 /* base64encoded_unsupportedReceipt2.txt in CopyFiles */,
57E6C2C92975C777001AFE98 /* verifyReceiptSample1.txt in CopyFiles */,
57E6C2CA2975C777001AFE98 /* base64encodedreceiptsample1.txt in CopyFiles */,
57E6C2CB2975C777001AFE98 /* base64encoded_sandboxReceipt.txt in CopyFiles */,
Expand Down Expand Up @@ -2082,6 +2095,11 @@
B3F8418E26F3A93400E560FB /* ErrorCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorCodeTests.swift; sourceTree = "<group>"; };
C3AD12B92C6EA61F00A4F86F /* CompatibilityNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilityNavigationStack.swift; sourceTree = "<group>"; };
C3AD12BB2C6EA69D00A4F86F /* SubscriptionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionDetailsView.swift; sourceTree = "<group>"; };
C5434EBA2CA3462900FE8F15 /* base64encoded_sandboxReceipt2.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = base64encoded_sandboxReceipt2.txt; sourceTree = "<group>"; };
C5434EBB2CA3462900FE8F15 /* base64encoded_sandboxReceipt3.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = base64encoded_sandboxReceipt3.txt; sourceTree = "<group>"; };
C5434EBC2CA3462900FE8F15 /* base64encoded_unsupportedReceipt1.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = base64encoded_unsupportedReceipt1.txt; sourceTree = "<group>"; };
C5434EBD2CA3462900FE8F15 /* base64encoded_unsupportedReceipt2.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = base64encoded_unsupportedReceipt2.txt; sourceTree = "<group>"; };
C5434ECA2CA347B800FE8F15 /* ISO8601DateFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ISO8601DateFormatterTests.swift; sourceTree = "<group>"; };
EFB3CBAA73855779FE828CE2 /* ProductsFetcherSK1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK1.swift; sourceTree = "<group>"; };
F516BD28282434070083480B /* StoreKit2StorefrontListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2StorefrontListener.swift; sourceTree = "<group>"; };
F516BD322828FDD90083480B /* StoreKit2StorefrontListenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2StorefrontListenerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2886,6 +2904,10 @@
children = (
2DDE559A24C8B5E300DCB087 /* verifyReceiptSample1.txt */,
2DDE559B24C8B5E300DCB087 /* base64encodedreceiptsample1.txt */,
C5434EBA2CA3462900FE8F15 /* base64encoded_sandboxReceipt2.txt */,
C5434EBB2CA3462900FE8F15 /* base64encoded_sandboxReceipt3.txt */,
C5434EBC2CA3462900FE8F15 /* base64encoded_unsupportedReceipt1.txt */,
C5434EBD2CA3462900FE8F15 /* base64encoded_unsupportedReceipt2.txt */,
5791CE7F273F26A000E50C4B /* base64encoded_sandboxReceipt.txt */,
B319514A26C1991E002CA9AC /* base64EncodedReceiptSampleForDataExtension.txt */,
);
Expand Down Expand Up @@ -3716,6 +3738,7 @@
5759B462296E1A3E002472D5 /* Helpers */,
4FA696A329FC43C600D228B1 /* ReceiptParserTests-Info.plist */,
5759B404296DF6C4002472D5 /* ReceiptParserFetchingTests.swift */,
C5434ECA2CA347B800FE8F15 /* ISO8601DateFormatterTests.swift */,
);
name = ReceiptParserTests;
path = Tests/ReceiptParserTests;
Expand Down Expand Up @@ -5006,6 +5029,10 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C5434EBE2CA3462900FE8F15 /* base64encoded_sandboxReceipt2.txt in Resources */,
C5434EBF2CA3462900FE8F15 /* base64encoded_sandboxReceipt3.txt in Resources */,
C5434EC02CA3462900FE8F15 /* base64encoded_unsupportedReceipt1.txt in Resources */,
C5434EC12CA3462900FE8F15 /* base64encoded_unsupportedReceipt2.txt in Resources */,
2D4D6AF624F7193700B656BE /* verifyReceiptSample1.txt in Resources */,
2D4D6AF724F7193700B656BE /* base64encodedreceiptsample1.txt in Resources */,
5774F9BE2805E71100997128 /* Fixtures in Resources */,
Expand Down Expand Up @@ -5889,6 +5916,7 @@
buildActionMask = 2147483647;
files = (
5759B406296DF8EE002472D5 /* ReceiptParserFetchingTests.swift in Sources */,
C5434ECB2CA347BB00FE8F15 /* ISO8601DateFormatterTests.swift in Sources */,
5759B465296E1A4B002472D5 /* MockBundle.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ extension ArraySlice where Element == UInt8 {
}

func toDate() -> Date? {
if let fastParsed = toDateTryFastParsing() {
// This approach is around ~60% faster than `ISO8601DateFormatter.default`
return fastParsed
}
guard let dateString = String(bytes: Array(self), encoding: .ascii) else { return nil }

return ISO8601DateFormatter.default.date(from: dateString)
Expand All @@ -53,3 +57,75 @@ extension ArraySlice where Element == UInt8 {
}

}

private extension ArraySlice where Element == UInt8 {
static let toDateCalendar: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
return calendar
}()

private func toDateTryFastParsing() -> Date? {
Copy link
Member

Choose a reason for hiding this comment

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

dumb little nitpicks that you can totally disregard:

  • private here is redundant in that the extension is already private, we try not to repeat
    We usually add line breaks after extension definitions and before they end

See examples in the style guide

Also, given that the method already returns an optional type, it feels like the try part is also redundant, I'd maybe go with toDateFastParse

Again, I don't feel particularly strongly about any of these tbh

Copy link
Author

Choose a reason for hiding this comment

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

Done.

Please let me know if there is anything else you wish me to change.

// expected format 2015-08-10T07:19:32Z
guard count == 20 else { return nil }
let asciiZero: UInt8 = 48
let asciiNine: UInt8 = 57
let asciiDash: UInt8 = 45
let asciiColon: UInt8 = 58
let asciiT: UInt8 = 84
let asciiZ: UInt8 = 90
let limits: [(min: UInt8, max: UInt8)] = [
(asciiZero, asciiNine), (asciiZero, asciiNine), (asciiZero, asciiNine), (asciiZero, asciiNine), // year
(asciiDash, asciiDash),
(asciiZero, asciiNine), (asciiZero, asciiNine), // month
(asciiDash, asciiDash),
(asciiZero, asciiNine), (asciiZero, asciiNine), // day
(asciiT, asciiT),
(asciiZero, asciiNine), (asciiZero, asciiNine), // hour
(asciiColon, asciiColon),
(asciiZero, asciiNine), (asciiZero, asciiNine), // minute
(asciiColon, asciiColon),
(asciiZero, asciiNine), (asciiZero, asciiNine), // second
(asciiZ, asciiZ)
]
for (character, limit) in zip(self, limits) {
guard limit.min <= character,
character <= limit.max else { return nil }
}

let year = toDateParseAsciiNumber(from: 0, to: 4)
let month = toDateParseAsciiNumber(from: 5, to: 7)
guard 1 <= month,
month <= 12 else { return nil }
let day = toDateParseAsciiNumber(from: 8, to: 10)
guard 1 <= day,
day <= 31 else { return nil }
let hour = toDateParseAsciiNumber(from: 11, to: 13)
guard 0 <= hour,
hour <= 23 else { return nil }
let minute = toDateParseAsciiNumber(from: 14, to: 16)
guard 0 <= minute,
minute <= 59 else { return nil }
let second = toDateParseAsciiNumber(from: 17, to: 19)
guard 0 <= second,
second <= 59 else { return nil }

let components = DateComponents(
year: year, month: month, day: day, hour: hour, minute: minute, second: second
)
return Self.toDateCalendar.date(from: components)
}

private func toDateParseAsciiNumber(from: Int, to: Int) -> Int { // swiftlint:disable:this identifier_name
let asciiZero: UInt8 = 48
var index = from + startIndex
let end = to + startIndex
var result = 0
while index < end {
let digit = self[index] - asciiZero
result = result * 10 + Int(digit)
index += 1
}
return result
}
}
20 changes: 20 additions & 0 deletions Tests/ReceiptParserTests/Helpers/MockBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class MockBundle: Bundle {
case appStoreReceipt
case emptyReceipt
case sandboxReceipt
case sandboxReceipt2
case sandboxReceipt3
case unsupportedReceipt1
case unsupportedReceipt2
case nilURL

}
Expand All @@ -38,6 +42,18 @@ final class MockBundle: Bundle {
case .sandboxReceipt:
return testBundle
.url(forResource: Self.mockSandboxReceiptFileName, withExtension: "txt")
case .sandboxReceipt2:
return testBundle
.url(forResource: Self.mockSandboxReceiptFileName2, withExtension: "txt")
case .sandboxReceipt3:
return testBundle
.url(forResource: Self.mockSandboxReceiptFileName3, withExtension: "txt")
case .unsupportedReceipt1:
return testBundle
.url(forResource: Self.mockUnsupportedReceiptFileName1, withExtension: "txt")
case .unsupportedReceipt2:
return testBundle
.url(forResource: Self.mockUnsupportedReceiptFileName2, withExtension: "txt")
case .nilURL:
return nil
}
Expand All @@ -47,5 +63,9 @@ final class MockBundle: Bundle {

private static let mockAppStoreReceiptFileName = "base64encodedreceiptsample1"
private static let mockSandboxReceiptFileName = "base64encoded_sandboxReceipt"
private static let mockSandboxReceiptFileName2 = "base64encoded_sandboxReceipt2"
private static let mockSandboxReceiptFileName3 = "base64encoded_sandboxReceipt3"
private static let mockUnsupportedReceiptFileName1 = "base64encoded_unsupportedReceipt1"
private static let mockUnsupportedReceiptFileName2 = "base64encoded_unsupportedReceipt2"

}
83 changes: 83 additions & 0 deletions Tests/ReceiptParserTests/ISO8601DateFormatterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// ISO8601DateFormatterTests.swift
//
// Created by Paweł Czerwiński on 6/28/24.

import Nimble
@testable import ReceiptParser
import XCTest

class ISO8601DateFormatterTests: XCTestCase {

private let someValidDates: [(text: String, date: Date)] = [
("2018-11-13T16:46:31Z", Date(timeIntervalSince1970: 1542127591.0)),
("2020-07-22T17:39:08Z", Date(timeIntervalSince1970: 1595439548.0)),
("2020-07-14T21:47:57Z", Date(timeIntervalSince1970: 1594763277.0)),
("2020-07-22T17:39:06Z", Date(timeIntervalSince1970: 1595439546.0)),
("2022-09-14T01:47:57Z", Date(timeIntervalSince1970: 1663120077.0)),
("2022-09-14T01:47:07Z", Date(timeIntervalSince1970: 1663120027.0))
]

private let someInvalidlyParsedDates: [(text: String, date: Date)] = [
("2022-09-31T01:47:57Z", Date(timeIntervalSince1970: 1664588877.0)), // September has only 30 days
("2022-02-29T12:47:57Z", Date(timeIntervalSince1970: 1646138877.0)), // In 2022 February had 28 days not 29
("2022-02-30T12:47:57Z", Date(timeIntervalSince1970: 1646225277.0)), // In 2022 February had 28 days not 30
("2022-02-31T01:47:57Z", Date(timeIntervalSince1970: 1646272077.0)), // In 2022 February had 28 days not 31
("2016-02-30T01:47:57Z", Date(timeIntervalSince1970: 1456796877.0)), // In 2016 February had 29 days not 30
("2016-02-31T01:47:57Z", Date(timeIntervalSince1970: 1456883277.0)), // In 2016 February had 29 days not 31
("2022-09-14T24:47:07Z", Date(timeIntervalSince1970: 1663202827.0)) // Too high hour
]

private let someInvalidDates: [String] = [
"2022-13-14T24:47:07Z", // invalid month
"2022-12-32T24:47:07Z", // invalid day
"2022-09-14T25:47:07Z", // invalid hour
"2022-09-14T23:61:07Z", // invalid minutes
"2022-09-14T23:60:07Z", // invalid minutes
"2022-09-14T12:47:60Z", // invalid seconds
"2022-09-14T12:47:72Z" // invalid seconds
]

func testParseStandardDates() {
for (dateString, expectedResult) in someValidDates {
expect(ISO8601DateFormatter.default.date(from: dateString)) == expectedResult
}
}

func testParseInvalidDatesThatForSomeReasonWorks() {
for (dateString, observedResult) in someInvalidlyParsedDates {
expect(ISO8601DateFormatter.default.date(from: dateString)) == observedResult
}
}

func testParseInvalidDates() {
for dateString in someInvalidDates {
expect(ISO8601DateFormatter.default.date(from: dateString)).to(beNil())
}
}

func testConsistencyWithRawBitsParser() {
let allDates = someValidDates.map { $0.text } +
someInvalidlyParsedDates.map { $0.text } +
someInvalidDates

for dateString in allDates {
do {
let data = try XCTUnwrap(dateString.data(using: .ascii))
let rawBits = ArraySlice(data)
XCTAssertEqual(ISO8601DateFormatter.default.date(from: dateString), rawBits.toDate())
} catch {
fail("Unexpected error for \(dateString), error: \(error)")
}
}
}

}
Loading