diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5c1db7d8..ec324cdc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,7 +28,7 @@ jobs: - name: Set up Swift uses: fwal/setup-swift@v1 with: - swift-version: '5.8' + swift-version: '5.9' - name: Generate Docs uses: fwcd/swift-docc-action@v1 with: diff --git a/Package.resolved b/Package.resolved index a87fbdbe..9b128141 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,34 +1,41 @@ { - "object": { - "pins": [ - { - "package": "DVR", - "repositoryURL": "https://github.com/venmo/DVR.git", - "state": { - "branch": null, - "revision": "d13f7135d1993053580efe13c9ecc43200852d09", - "version": "2.1.0" - } - }, - { - "package": "SwiftDocCPlugin", - "repositoryURL": "https://github.com/apple/swift-docc-plugin", - "state": { - "branch": null, - "revision": "3303b164430d9a7055ba484c8ead67a52f7b74f6", - "version": "1.0.0" - } - }, - { - "package": "SwiftProtobuf", - "repositoryURL": "https://github.com/apple/swift-protobuf.git", - "state": { - "branch": null, - "revision": "7e2c5f3cbbeea68e004915e3a8961e20bd11d824", - "version": "1.18.0" - } + "pins" : [ + { + "identity" : "dvr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/venmo/DVR.git", + "state" : { + "revision" : "d13f7135d1993053580efe13c9ecc43200852d09", + "version" : "2.1.0" } - ] - }, - "version": 1 + }, + { + "identity" : "sqlite.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stephencelis/SQLite.swift.git", + "state" : { + "revision" : "7a2e3cd27de56f6d396e84f63beefd0267b55ccb", + "version" : "0.14.1" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "7e2c5f3cbbeea68e004915e3a8961e20bd11d824", + "version" : "1.18.0" + } + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index 1bf01be5..be2942dd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,19 +1,23 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.9 import PackageDescription let package = Package( name: "WMATA", platforms: [ - .macOS(.v10_15), - .iOS(.v13), - .tvOS(.v13), - .watchOS(.v6) + .macOS(.v12), + .iOS(.v15), + .tvOS(.v15), + .watchOS(.v8) ], products: [ .library( name: "WMATA", targets: ["WMATA"] ), + .library( + name: "MetroGTFS", + targets: ["MetroGTFS"] + ) ], dependencies: [ .package( @@ -26,7 +30,11 @@ let package = Package( ), .package( url: "https://github.com/apple/swift-docc-plugin", - from: "1.0.0" + .upToNextMajor(from: .init(1, 0, 0)) + ), + .package( + url: "https://github.com/stephencelis/SQLite.swift.git", + .upToNextMinor(from: .init(0, 14, 1)) ) ], targets: [ @@ -41,5 +49,21 @@ let package = Package( dependencies: ["WMATA", "DVR"], resources: [.process("Fixtures")] ), + .target( + name: "MetroGTFS", + dependencies: [ + .product(name: "SQLite", package: "SQLite.swift"), + "WMATA" + ], + resources: [ + .copy("MetroGTFS.sqlite3") + ] + ), + .testTarget( + name: "MetroGTFSTests", + dependencies: [ + "MetroGTFS" + ] + ) ] ) diff --git a/README.md b/README.md index 75a68d9c..e8320829 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,12 @@ WMATA.swift is a Swift interface to the [Washington Metropolitan Area Transit Authority API](https://developer.wmata.com). -## Contents - -- [Requirements](#requirements) -- [Installation](#installation) -- [Documentation](#documentation) -- [Dependencies](#dependencies) -- [Contact](#contact) -- [Contributing](#contributing) -- [License](#license) - -## Installation +## Install ### Requirements -- Swift 5.6 -- Xcode 13.2 +- Swift 5.9 +- Xcode 15 ### Swift Package Manager @@ -31,11 +21,47 @@ dependencies: [ ] ``` +## Usage + +### Standard API + +To work with WMATA's Standard API use the `WMATA` package. + +```swift +import WMATA + +let nextTrains = Rail.NextRails( + key: YOUR_API_KEY, + station: .waterfront +) + +nextTrains.request { result in + switch result { + case let .success(response): + print(response.trains) + case let .failure(error): + print(error) + } +} +``` + +### GTFS Static + +To work with GTFS Static data use the `MetroGTFS` package. + +```swift +import MetroGTFS + +let ashburn = try GTFSStop("STN_N12") + +print(ashburn.name) // "ASHBURN METRORAIL STATION" +``` + ## OS Support WMATA.swift commits to supporting current minus 2 OS versions. -Currently, WMATA.swift is compatible with macOS 10.15, iOS 13, tvOS 13, watchOS 6 or higher. +Currently, WMATA.swift is compatible with macOS 12, iOS 15, tvOS 15, watchOS 8 or higher. ## Documentation @@ -47,6 +73,7 @@ To view documentation within Xcode, within the menu navigate to `Product > Build - [swift-protobuf](https://github.com/apple/swift-protobuf), for GTFS-RT feeds. - [DVR](https://github.com/venmo/DVR), for testing. +- [SQLite.swift](https://github.com/stephencelis/SQLite.swift), for GTFS Static data. Only used in `MetroGTFS` package. ## Contact @@ -56,9 +83,8 @@ Feel free to email questions and comments to [emma@emma.sh](mailto:emma@emma.sh) Todo: -- [ ] Build out more DVR tests. -- [ ] Automated builds. -- [ ] Convert async functions from a `Result` to `return` or `throw` behavior, the dominant async pattern in Swift. +- [ ] Support all GTFS Static data in `MetroGTFS` +- [ ] Convert async functions from a `Result` to `return` or `throw` behavior, the dominant async pattern in Swift ## Developer @@ -67,3 +93,5 @@ To generate documentation for deploying to Github Pages, run `./docs.sh`. ## License WMATA.swift is released under the MIT license. [See LICENSE](https://github.com/emma-k-alexandra/WMATA.swift/blob/main/LICENSE) for details. + +This package is not distributed by or affiliated with WMATA. diff --git a/Sources/MetroGTFS/.gitignore b/Sources/MetroGTFS/.gitignore new file mode 100644 index 00000000..57f051f9 --- /dev/null +++ b/Sources/MetroGTFS/.gitignore @@ -0,0 +1,2 @@ +gtfs-files/*.txt +gtfs-files/swift-files/*.swift diff --git a/Sources/MetroGTFS/Coordinates.swift b/Sources/MetroGTFS/Coordinates.swift new file mode 100644 index 00000000..fa9b07d2 --- /dev/null +++ b/Sources/MetroGTFS/Coordinates.swift @@ -0,0 +1,27 @@ +// +// GTFSCoordinates.swift +// +// +// Created by Emma on 11/25/23. +// + +import Foundation + +/// Location with latitude and longitude coordinates +public struct GTFSCoordinates: Equatable, Hashable, Codable { + /// Latitude in degrees, for the DMV this value is positive. + public var latitude: Double + + /// Longitude in degrees, For the DMV this value is negative. + public var longitude: Double + + /// Create a new location + /// + /// - Parameters: + /// - latitude: Latitude of location in degrees, positive for the DMV. + /// - longitude: Longitude of location in degrees, negative for the DMV. + public init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/Sources/MetroGTFS/Database.swift b/Sources/MetroGTFS/Database.swift new file mode 100644 index 00000000..340b9089 --- /dev/null +++ b/Sources/MetroGTFS/Database.swift @@ -0,0 +1,121 @@ +// +// Database.swift +// +// +// Created by Emma on 11/26/23. +// + +import Foundation +import SQLite + +/// The GTFS Static Database. Used to perform queries on the GTFS Static Database. +struct GTFSDatabase { + private let connection: Connection + + /// Create a new GTFS Database. If there is not currently an open connection to the database, create one. + init() throws { + if let connection = GTFSDatabase.shared { + self.connection = connection + + return + } + + let connection: Connection + + do { + connection = try GTFSDatabase.connection() + } catch { + throw GTFSDatabaseError.unableToConnectToDatabase + } + + GTFSDatabase.shared = connection + + self.connection = connection + } + + /// Run a database query that only returns one row + func run(query: SQLite.Table) throws -> Row? { + do { + return try connection.pluck(query) + } catch { + throw GTFSDatabaseError.unableToPerformQuery(query) + } + } + + /// Run a database query that returns multiple rows + func run(query: SQLite.Table) throws -> AnySequence { + do { + return try connection.prepare(query) + } catch { + throw GTFSDatabaseError.unableToPerformQuery(query) + } + } +} + +extension GTFSDatabase { + /// Get all GTFS Structures of the given type from the GTFS Database + func all(_ structure: Structure.Type) throws -> AnySequence { + return try run(query: structure.databaseTable.sqlTable) + } + + /// Get all GTFS Structures of the given type with the given `id` in the given `column`. Defaults to using the primary key column. + func all( + _ structure: Structure.Type, + with id: GTFSIdentifier, + in column: SQLite.Expression = Structure.databaseTable.primaryKeyColumn + ) throws -> AnySequence { + return try run(query: structure.databaseTable.sqlTable.where(column == id.rawValue)) + } + + /// Get all GTFS Structures of the given type with the given `id` in the given `column`. + func all( + _ structure: Structure.Type, + with id: GTFSIdentifier, + in column: SQLite.Expression + ) throws -> AnySequence { + return try run(query: structure.databaseTable.sqlTable.where(column == id.rawValue)) + } + + + /// Get a single structure of the given type with the given `id` in the given `column`. Defaults to using the primary key column. + func one( + _ structure: Structure.Type, + with id: GTFSIdentifier, + in column: SQLite.Expression = Structure.databaseTable.primaryKeyColumn + ) throws -> Row? { + return try run(query: structure.databaseTable.sqlTable.where(column == id.rawValue)) + } +} + +extension GTFSDatabase { + /// The global shares connection to the GTFS database + private static var shared: Connection? + + /// Create a new connection to the MetroGTFS SQLite database + private static func connection() throws -> Connection { + let path = Bundle.module.path(forResource: "MetroGTFS", ofType: "sqlite3") + + guard let path else { + throw GTFSDatabaseError.failedToLoadDatabase + } + + let connection = try Connection(path, readonly: true) + + return connection + } +} + +extension GTFSDatabase { + /// A SQLite database table and the column it's primary key is in + struct Table { + let sqlTable: SQLite.Table + let primaryKeyColumn: SQLite.Expression + } +} + +/// If a data type can be loaded from a SQLite database +protocol Queryable { + /// The actual table in SQLite to pull the data type from + static var databaseTable: GTFSDatabase.Table { get } +} + diff --git a/Sources/MetroGTFS/Error.swift b/Sources/MetroGTFS/Error.swift new file mode 100644 index 00000000..865bbe0b --- /dev/null +++ b/Sources/MetroGTFS/Error.swift @@ -0,0 +1,38 @@ +// +// Error.swift +// +// +// Created by Emma on 11/26/23. +// + +import Foundation +import SQLite + +/// Errors associated with creating or loading the SQLite database +public enum GTFSDatabaseError: Error { + /// Unable to find or load the SQLite databse file + case failedToLoadDatabase + + /// Unable to create a new connected to an already created SQLite database + case unableToConnectToDatabase + + /// Unable to run the given query against a SQLite database table + /// + /// Note that in the SQLite wrapper MetroGTFS uses, a Table is the data type used to represent a query. + case unableToPerformQuery(Table) + + /// The given rows does is not valid and could not be loaded. Usually associated with some ``MetroGTFS/GTFS/DatabaseDecodingError``. + case invalid(Row) +} + +/// Errors associated with incorrect SQLite database queries +public enum GTFSDatabaseQueryError: Error { + /// The requested row does not exist in the given SQLite database table + case notFound(GTFSIdentifier, Table) +} + +/// Errors associated with decoding SQLite table rows into Swift types +public enum GTFSDatabaseDecodingError: Error { + /// Occurs when a row in the SQLite database does not have + case invalidEntry(structureType: T.Type, key: String) +} diff --git a/Sources/MetroGTFS/GTFS.swift b/Sources/MetroGTFS/GTFS.swift new file mode 100644 index 00000000..4dada186 --- /dev/null +++ b/Sources/MetroGTFS/GTFS.swift @@ -0,0 +1,11 @@ +// +// GTFS.swift +// +// +// Created by Emma on 11/25/23. +// + +import Foundation + +/// Structures that are part of the [GTFS specification](https://gtfs.org). +public enum GTFS {} diff --git a/Sources/MetroGTFS/Identifier.swift b/Sources/MetroGTFS/Identifier.swift new file mode 100644 index 00000000..7a834e5e --- /dev/null +++ b/Sources/MetroGTFS/Identifier.swift @@ -0,0 +1,39 @@ +// +// WMATAIdentifier.swift +// +// +// Created by Emma on 11/25/23. +// + +import Foundation + +/// A generic identifier. Used to associate a specific type with some structure used within the MetroGTFS +/// +/// This type encodes to a single value in JSON. +/// +/// This type uses a Phantom Generic to differentate IDs of different data types. +public struct GTFSIdentifier: Equatable, Hashable, RawRepresentable { + /// The identifier for the current `Structure`. + public var rawValue: String + + /// Create a new Identifier with the given id. + public init(_ id: String) { + self.rawValue = id + } + + public init(rawValue id: String) { + self.rawValue = id + } +} + +extension GTFSIdentifier: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.rawValue = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.rawValue) + } +} diff --git a/Sources/MetroGTFS/Level.swift b/Sources/MetroGTFS/Level.swift new file mode 100644 index 00000000..f2db9dc9 --- /dev/null +++ b/Sources/MetroGTFS/Level.swift @@ -0,0 +1,108 @@ +// +// GTFSLevel.swift +// +// +// Created by Emma on 11/25/23. +// + +import Foundation +import SQLite + +/// A [GTFS Level](https://gtfs.org/schedule/reference/#levelstxt). Describes the different physical levels and floors in a station. Can be used with pathways to navigate stations. +/// +/// ```swift +/// let level = try GTFSLevel("B05_L1") +/// +/// level.name // "Mezzanine" +/// ``` +public struct GTFSLevel: Equatable, Hashable, Codable { + /// A unique identifer for the level. + public var id: GTFSIdentifier + + /// Numeric index of the level that indicates its relative position. + /// + /// For WMATA, these are integers between -3 and 2. + /// + /// Ground level should have index 0, with levels above ground indicated by positive indices and levels below ground by negative indices. + public var index: Int + + /// Name of the level as seen by the rider inside the building or station. + public var name: String + + /// Create a new GTFS Level by providing all of it's fields + public init(id: GTFSIdentifier, index: Int, name: String) { + self.id = id + self.index = index + self.name = name + } + + /// Create a Level from a Level ID. Performs a database query. + /// + /// - Parameters: + /// - id: A unique indentifer for a level. Typically is a station code + a floor identifier. Example: `.init("B05_L1")` + /// + /// - Throws: `GTFSDatabaseError` if the GTFS database is unavailable or the database has some other issue + /// - Throws: `GTFSDatabaseQueryError` if the given level is not in the database + /// + /// [More on Level IDs](https://developers.google.com/transit/gtfs/reference#levelstxt) + /// + /// ```swift + /// let level = try GTFSLevel(.init("B05_L1")) + /// + /// level.name // "Mezzanine" + /// ``` + public init(id: GTFSIdentifier) throws { + let database = try GTFSDatabase() + + let levelRow = try database.one(GTFSLevel.self, with: id) + + guard let levelRow else { + throw GTFSDatabaseQueryError.notFound(id, GTFSLevel.databaseTable.sqlTable) + } + + try self.init(row: levelRow) + } + + /// Create a Level from a Level ID string. Performs a database query. + /// + /// - Parameters: + /// - idString: A unique indentifer for a level. Typically is a station code + a floor identifier. Example: `B05_L1` + /// + /// - Throws: `GTFSDatabaseError` if the GTFS database is unavailable or the database has some other issue + /// - Throws: `GTFSDatabaseQueryError`, if the given level ID is not in the database + /// + /// [More on Levels](https://gtfs.org/schedule/reference/#levelstxt) + /// + /// ```swift + /// let level = try GTFSLevel("B05_L1") + /// + /// level.name // "Mezzanine" + /// ``` + public init(_ idString: @autoclosure @escaping () -> String) throws { + try self.init(id: .init(idString())) + } + + /// Create a Level from a database row from the levels table + init(row: Row) throws { + self.id = GTFSIdentifier(try row.get(TableColumn.id)) + do { + self.index = Int(try row.get(TableColumn.index)) + self.name = try row.get(TableColumn.name) + } catch { + throw GTFSDatabaseError.invalid(row) + } + } +} + +extension GTFSLevel { + /// Columns in the GTFS Static levels database table + enum TableColumn { + static let id = Expression("level_id") + static let index = Expression("level_index") + static let name = Expression("level_name") + } +} + +extension GTFSLevel: Queryable { + static let databaseTable = GTFSDatabase.Table(sqlTable: SQLite.Table("levels"), primaryKeyColumn: GTFSLevel.TableColumn.id) +} diff --git a/Sources/MetroGTFS/MetroGTFS.docc/MetroGTFS.md b/Sources/MetroGTFS/MetroGTFS.docc/MetroGTFS.md new file mode 100644 index 00000000..1672eac6 --- /dev/null +++ b/Sources/MetroGTFS/MetroGTFS.docc/MetroGTFS.md @@ -0,0 +1,44 @@ +# ``MetroGTFS`` + +A Swift interface to WMATA's GTFS Static data. + +## Install + +### Swift Package Manager + +If adding MetroGTFS to a Swift Package, add + +```swift +dependencies: [ + .package( + name: "WMATA", + url: "https://github.com/emma-k-alexandra/WMATA.swift.git", + .upToNextMajor(from: "15.0.0") + ) +] +``` + +### Xcode + +Add `https://github.com/emma-k-alexandra/WMATA.swift.git` to your project's Package Dependencies. Select `Up to Next Major Version` and set the version to `15.0.0` + +## Usage + +In your code, add + +```swift +import MetroGTFS + +let ashburn = try GTFSStop("STN_N12") + +print(ashburn.name) // "ASHBURN METRORAIL STATION" +``` + +## Structures + +- ``GTFSStop`` +- ``GTFSLevel`` + +## Utilities + +- ``GTFSIdentifier`` diff --git a/Sources/MetroGTFS/MetroGTFS.sqlite3 b/Sources/MetroGTFS/MetroGTFS.sqlite3 new file mode 100644 index 00000000..2c469806 Binary files /dev/null and b/Sources/MetroGTFS/MetroGTFS.sqlite3 differ diff --git a/Sources/MetroGTFS/README.md b/Sources/MetroGTFS/README.md new file mode 100644 index 00000000..6aeb23a2 --- /dev/null +++ b/Sources/MetroGTFS/README.md @@ -0,0 +1,35 @@ +# MetroGTFS + +Use [WMATA's Static GTFS data](https://developer.wmata.com/docs/services/gtfs/operations/bus-gtfs-static). + +## Usage + +See base [README.md](../../README.md). + +## Warning + +This package is in active development and is likely to change. + +## Support + +This package currently only supports WMATA's Rail GTFS data. Bus GTFS data will be added eventually. + +The following GTFS data types are supported + +| GTFS Files | Supported? | +| - | - | +| stops.txt | ✅ | +| agency.txt | ❌ | +| levels.txt | ✅ | +| routes.txt | ❌ | +| trips.txt | ❌ | +| stop_times.txt | ❌ | +| calendar.txt | ❌ | +| calendar_dates.txt | ❌ | +| shapes.txt | ❌ | +| pathways.txt | ❌ | +| feed_info.txt | ❌ | +| timepoints.txt | ❌ | +| timepoints_times.txt | ❌ | + +Unlisted GTFS files are not provided by WMATA. diff --git a/Sources/MetroGTFS/Stop+WMATA.swift b/Sources/MetroGTFS/Stop+WMATA.swift new file mode 100644 index 00000000..0345f583 --- /dev/null +++ b/Sources/MetroGTFS/Stop+WMATA.swift @@ -0,0 +1,19 @@ +// +// Stop+WMATA.swift +// +// +// Created by Emma on 12/7/23. +// + +#if canImport(WMATA) + +import WMATA + +public extension GTFSStop { + /// Create a GTFS Stop from a ``Station`` + init(station: Station) throws { + try self.init(id: .init("STN_\(station.rawValue)")) + } +} + +#endif diff --git a/Sources/MetroGTFS/Stop.swift b/Sources/MetroGTFS/Stop.swift new file mode 100644 index 00000000..78202fef --- /dev/null +++ b/Sources/MetroGTFS/Stop.swift @@ -0,0 +1,270 @@ +// +// GTFSStop.swift +// +// +// Created by Emma on 11/25/23. +// + +import Foundation +import SQLite + +/// A [GTFS Stop](https://gtfs.org/schedule/reference/#stopstxt). +/// +/// For MetroRail, represents a Station, Platform, Entrance, locations between one of the previous stops like an elevator, escalator, or the paid and unpaid sides of a faregate. +/// +/// ```swift +/// let stop = try GTFSStop("STN_N12") +/// +/// stop.name // "ASHBURN METRORAIL STATION" +/// ``` +public struct GTFSStop: Equatable, Hashable, Codable { + /// The unique ID for this stop + /// + /// Identifies a location: stop/platform, station, entrance/exit, generic node or boarding area (see `location_type`). + /// + /// Multiple routes may use the same `id`. + /// + /// ## Examples + /// - `STN_N12` - Ashburn station + /// - `STN_D03_F03` - L'Enfant Plaza station + /// - `PLF_B05_RD_SHADY_GROVE` - Platform at Brookland-CUA on the Red Line to Shady Grove + /// + /// ## Notes + /// - For stations, this ID is typically identical to a ``Station`` + /// - For transfer stations, both `Station` IDs are included. Example: `STN_D03_F03`. + public var id: GTFSIdentifier + + /// The human readable name of this stop. + /// + /// ## Details + /// - In WMATA GTFS data, this field is always written in all caps. + /// - May not be suitable for display to users. + /// - This field does not match the public name of the station. + /// + /// ## Example + /// `ASHBURN METRORAIL STATION` + /// + /// ## Notes + /// While this field is only conditionally required by GTFS, WMATA includes it for all stops. Therefore, it's marked as non-null here. + public var name: String + + /// A short description of the stop. + /// + /// ## Notes + /// Not present on Metrorail stations. + public var description: String? + + /// The latitude and longitude of this stop + public var location: GTFSCoordinates + + /// Identifies the fare zone for a stop. + /// + /// ## Note + /// I do not know what WMATA uses this field to represent. + public var zoneID: String + + /// The GTFS `location_type` of a stop. + public enum LocationType: Int, Hashable, Codable { + /// A location where passengers board or disembark from a transit vehicle. Is called a platform when defined within a `parent_station` + case platform = 0 + + /// A physical structure or area that contains one or more platform. + case station = 1 + + /// A location where passengers can enter or exit a station from the street. + case entrance = 2 + + /// A location within a station, not matching any other `Location`, that may be used to link together pathways. + /// + /// ## Notes + /// Unfortunately, WMATA uses this value for platforms instead of ``GTFS/Stop/Location/platform``. Also used for elevator and escalator landings. + case genericNode = 3 + + /// A specific location on a platform, where passengers can board and/or alight vehicles. + /// + /// ## Notes + /// Unused by WMATA. + case boardingArea = 4 + } + + /// If this stop is a Platform, Station, Entrance, or some other type of location. + public var locationType: LocationType + + /// If this stop is location within some other ``GTFS/Stop`` + public var parentStation: GTFSIdentifier? + + /// Indicates whether wheelchair boardings are possible from the location. + public enum WheelchairBoarding: Int, Hashable, Codable { + + // Unused by WMATA. + case noAccessibilityInformation = 0 + + // Stop is wheelchair accessible + case accessible = 1 + + // Stop is not wheelchair accessible + case notAccessible = 2 + } + + /// Indicates whether wheelchair boardings are possible from the location. + public var wheelchairBoarding: WheelchairBoarding + + /// ``GTFS/Level`` of the location. The same level may be used by multiple unlinked stations. + public var level: GTFSIdentifier? + + /// Create a new Stop by providing all of it's fields + public init( + id: GTFSIdentifier, + name: String, + description: String? = nil, + location: GTFSCoordinates, + zoneID: String, + locationType: LocationType, + parentStation: GTFSIdentifier? = nil, + wheelchairBoarding: WheelchairBoarding, + level: GTFSIdentifier? = nil + ) { + self.id = id + self.name = name + self.description = description + self.location = location + self.zoneID = zoneID + self.locationType = locationType + self.parentStation = parentStation + self.wheelchairBoarding = wheelchairBoarding + self.level = level + } + + /// Create a GTFS Stop from a Stop ID + /// + /// - Parameters: + /// - id: A unique identifier for a stop. Typically `STN` and a station code. Example: `.init("STN_N12")`. + /// + /// - Throws: `GTFSDatabaseError` if the GTFS database is unavailable or the database has some other issue + /// - Throws: `GTFSDatabaseQueryError`, if the given stop ID is not in the database + /// + /// ```swift + /// let stop = try GTFSStop(.init("STN_N12")) + /// + /// stop.name // "ASHBURN METRORAIL STATION" + /// ``` + /// + /// [More on Stops](https://gtfs.org/schedule/reference/#stopstxt) + public init(id: GTFSIdentifier) throws { + let database = try GTFSDatabase() + + let stopRow = try database.one(GTFSStop.self, with: id) + + guard let stopRow else { + throw GTFSDatabaseQueryError.notFound(id, GTFSStop.databaseTable.sqlTable) + } + + do { + try self.init(row: stopRow) + } catch { + throw GTFSDatabaseError.invalid(stopRow) + } + } + + /// Create a GTFS Stop from a Stop ID string + /// + /// - Parameters: + /// - id: A unique identifier for a stop. Typically `STN` and a station code. Example: `STN_N12`. + /// + /// - Throws: `GTFSDatabaseError` if the GTFS database is unavailable or the database has some other issue + /// - Throws: `GTFSDatabaseQueryError`, if the given stop ID is not in the database + /// + /// ```swift + /// let stop = try GTFSStop("STN_N12") + /// + /// stop.name // "ASHBURN METRORAIL STATION" + /// ``` + /// + /// [More on Stops](https://gtfs.org/schedule/reference/#stopstxt) + public init(_ idString: @autoclosure @escaping () -> String) throws { + try self.init(id: .init(idString())) + } + + /// Create a Stop from a row in the GTFS database's stops table + init(row: Row) throws { + guard let locationType = LocationType(rawValue: try row.get(TableColumn.locationType)) else { + throw GTFSDatabaseDecodingError.invalidEntry(structureType: GTFSStop.self, key: "location_type") + } + + var parentStation: GTFSIdentifier? = nil + + if let parentStationID = try row.get(TableColumn.parentStation) { + parentStation = GTFSIdentifier(parentStationID) + } + + guard let wheelchairBoarding = WheelchairBoarding(rawValue: try row.get(TableColumn.wheelchairBoarding)) else { + throw GTFSDatabaseDecodingError.invalidEntry(structureType: GTFSStop.self, key: "wheelchair_boarding") + } + + var level: GTFSIdentifier? = nil + + if let levelID = try row.get(TableColumn.levelID) { + level = GTFSIdentifier(levelID) + } + + self.id = GTFSIdentifier(try row.get(TableColumn.id)) + self.name = try row.get(TableColumn.name) + self.description = try row.get(TableColumn.description) + self.location = GTFSCoordinates( + latitude: try row.get(TableColumn.latitude), + longitude: try row.get(TableColumn.longitude) + ) + self.zoneID = try row.get(TableColumn.zoneID) + self.locationType = locationType + self.parentStation = parentStation + self.wheelchairBoarding = wheelchairBoarding + self.level = level + } + + /// Create all Stops with the given parent station + /// + /// - Parameters: + /// - id: The Stop ID of the Stop's parent Stop + /// + /// - Throws: `GTFSDatabaseError` if the GTFS database is unavailable or the database has some other issue + /// - Throws: `GTFSDatabaseQueryError`, if the given stop ID is not in the database + /// + /// [More info about parent stations](https://gtfs.org/schedule/reference/#stopstxt) + public static func all(withParentStation id: GTFSIdentifier) throws -> [GTFSStop] { + let database = try GTFSDatabase() + + let allStopRows = try database.all(GTFSStop.self, with: id, in: TableColumn.parentStation) + + return try allStopRows.map { try GTFSStop(row: $0) } + } + + /// Create all Stops with the given parent station + /// + /// See ``all(withParentStation:)-6rk0p`` + public static func all(withParentStation idString: @autoclosure @escaping () -> String) throws -> [GTFSStop] { + return try self.all(withParentStation: .init(idString())) + } +} + +extension GTFSStop { + /// Columns in the SQLite `stops` table + enum TableColumn { + static let id = Expression("stop_id") + static let name = Expression("stop_name") + static let description = Expression("stop_desc") + static let latitude = Expression("stop_lat") + static let longitude = Expression("stop_lon") + static let zoneID = Expression("zone_id") + static let locationType = Expression("location_type") + static let parentStation = Expression("parent_station") + static let wheelchairBoarding = Expression("wheelchair_boarding") + static let levelID = Expression("level_id") + } +} + +extension GTFSStop: Queryable { + static let databaseTable = GTFSDatabase.Table( + sqlTable: SQLite.Table("stops"), + primaryKeyColumn: TableColumn.id + ) +} diff --git a/Sources/MetroGTFStoSQLite/.gitignore b/Sources/MetroGTFStoSQLite/.gitignore new file mode 100644 index 00000000..9ed96414 --- /dev/null +++ b/Sources/MetroGTFStoSQLite/.gitignore @@ -0,0 +1 @@ +MetroGTFS.sqlite3 diff --git a/Sources/MetroGTFStoSQLite/README.md b/Sources/MetroGTFStoSQLite/README.md new file mode 100644 index 00000000..e4ea1666 --- /dev/null +++ b/Sources/MetroGTFStoSQLite/README.md @@ -0,0 +1,50 @@ +# Metro GTFS to SQLite + +Create a SQLite database from WMATA's Static MetroRail GTFS data. + +## Requirements + +- [Node.js](https://nodejs.org) v18 or higher +- [SQLite](https://sqlite.org/index.html), built in on all Macs + +## Usage + +This package uses the awesome [`node-gtfs`][node-gtfs] package to build the SQLite database. This means you will need Node.js installed. +If you do not regularly use Node.js I recommend using [pkgx](https://pkgx.sh) or [brew](https://formulae.brew.sh/formula/node) to use Node. + +If you use pkgx, no additional setup is required. Skip to [Create SQLite Database](#create-sqlite-database). + +If installing Node.js with brew, run + +```zsh +brew install node +``` + +This will install the current version of Node.js. You can now create the database. + +### Create SQLite database + +To use `node-gtfs` to create the SQLite database run + +```zsh +./create-sqlite.sh +``` + +You will now have a new SQLite database in this directory called `MetroGTFS.sqlite3` that contains all MetroRail GTFS static data. + +See the [`node-gtfs`][node-gtfs] project for details on each table and indexes. + +## Updating to New GTFS data + +Download the latest MetroRail GTFS data from and run `./create-sqlite.sh` again to create a new database with + +## Todo + +This project is fairly new and therefore currently has limited capabilities. In the future, +I would like to see the following implemented + +- [ ] Fetch GTFS data from WMATA's developer API +- [ ] MetroBus GTFS support +- [ ] GTFS-RT support + +[node-gtfs]: https://github.com/BlinkTagInc/node-gtfs diff --git a/Sources/MetroGTFStoSQLite/create-sqlite.sh b/Sources/MetroGTFStoSQLite/create-sqlite.sh new file mode 100755 index 00000000..ae738e15 --- /dev/null +++ b/Sources/MetroGTFStoSQLite/create-sqlite.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env zsh +# Use the [Node GTFS](https://github.com/BlinkTagInc/node-gtfs) package to build +# a SQLite database for GTFS Static data from WMATA. + +npx -p gtfs gtfs-import --configPath gtfs-config.json diff --git a/Sources/MetroGTFStoSQLite/gtfs-config.json b/Sources/MetroGTFStoSQLite/gtfs-config.json new file mode 100644 index 00000000..71c3ccc1 --- /dev/null +++ b/Sources/MetroGTFStoSQLite/gtfs-config.json @@ -0,0 +1,8 @@ +{ + "agencies": [ + { + "path": "wmata-gtfs-rail-static.zip" + } + ], + "sqlitePath": "MetroGTFS.sqlite3" +} diff --git a/Sources/MetroGTFStoSQLite/wmata-gtfs-rail-static.zip b/Sources/MetroGTFStoSQLite/wmata-gtfs-rail-static.zip new file mode 100644 index 00000000..a35560f9 Binary files /dev/null and b/Sources/MetroGTFStoSQLite/wmata-gtfs-rail-static.zip differ diff --git a/Tests/MetroGTFSTests/MetroGTFSTests.swift b/Tests/MetroGTFSTests/MetroGTFSTests.swift new file mode 100644 index 00000000..5808a39a --- /dev/null +++ b/Tests/MetroGTFSTests/MetroGTFSTests.swift @@ -0,0 +1,76 @@ +// +// GTFSTests.swift +// +// +// Created by Emma on 11/25/23. +// + +import XCTest +@testable import MetroGTFS +import WMATA + +final class MetroGTFSTests: XCTestCase { + func testCreateAllStops() throws { + let database = try GTFS.Database() + + for row in try database.all(GTFSStop.self) { + let stop = try GTFSStop(row: row) + + // Does the Stop ID from the database match one of the valid location type prefixes? + let prefix = stop.id.rawValue.prefixMatch(of: try Regex("^(ENT|NODE|PF|PLF|STN)")) + + XCTAssertNotNil(prefix) + XCTAssertGreaterThan(prefix!.count, 0) + } + } + + func testCreateAStop() throws { + let stop = try GTFSStop(id: .init("STN_N12")) + + XCTAssertEqual(stop.name, "ASHBURN METRORAIL STATION") + } + + func testCreateAStopWithShorthand() throws { + let stop = try GTFSStop("STN_N12") + + XCTAssertEqual(stop.name, "ASHBURN METRORAIL STATION") + } + + func testCreateAStopFromWMATAStation() throws { + let stop = try GTFSStop(station: .ashburn) + + XCTAssertEqual(stop.name, "ASHBURN METRORAIL STATION") + } + + func testCreateAllStopsWithParentStation() throws { + let stops = try GTFSStop.all(withParentStation: .init("STN_B01_F01")) + + for stop in stops { + XCTAssert(stop.name.contains("CHINATOWN") || stop.name.contains("GALLERY PL"), stop.name) + } + } + + func testCreateAllLevels() throws { + let database = try GTFS.Database() + + for row in try database.all(GTFSLevel.self) { + let level = try GTFSLevel(row: row) + + let stationCode = level.id.rawValue.prefix(3) + + XCTAssertNotNil(Station(rawValue: String(stationCode))) + } + } + + func testCreateALevel() throws { + let level = try GTFSLevel(id: .init("B05_L1")) + + XCTAssertEqual(level.name, "Mezzanine") + } + + func testCreateALevelWithShorthand() throws { + let level = try GTFSLevel("B05_L1") + + XCTAssertEqual(level.name, "Mezzanine") + } +}