├── .gitignore ├── Makefile ├── .swiftformat ├── .github └── workflows │ └── ci.yml ├── Sources └── FluentPostGIS │ ├── Support │ └── EnablePostGISMigration.swift │ ├── Geometry │ ├── GeometricPoint2D.swift │ ├── GeometricLineString2D.swift │ ├── GeometricMultiPoint2D.swift │ ├── GeometricMultiPolygon2D.swift │ ├── GeometricMultiLineString2D.swift │ ├── DatabaseSchemaDataType+Geometric.swift │ ├── GeometryConvertible.swift │ ├── GeometricPolygon2D.swift │ └── GeometricGeometryCollection2D.swift │ ├── Geography │ ├── GeographicLineString2D.swift │ ├── GeographicMultiPoint2D.swift │ ├── GeographicPoint2D.swift │ ├── GeographicMultiPolygon2D.swift │ ├── GeographicMultiLineString2D.swift │ ├── DatabaseSchemaDataType+Geographic.swift │ ├── GeographicPolygon2D.swift │ └── GeographicGeometryCollection2D.swift │ └── Queries │ ├── QueryBuilder+Distance.swift │ ├── QueryBuilder+Sort.swift │ ├── QueryBuilder+Helpers.swift │ ├── QueryBuilder+Equals.swift │ ├── QueryBuilder+DistanceWithin.swift │ ├── QueryBuilder+Within.swift │ ├── QueryBuilder+Crosses.swift │ ├── QueryBuilder+Touches.swift │ ├── QueryBuilder+Disjoint.swift │ ├── QueryBuilder+Overlaps.swift │ ├── QueryBuilder+Contains.swift │ └── QueryBuilder+Intersects.swift ├── LICENSE ├── Tests └── FluentPostGISTests │ ├── FluentPostGISTestCase.swift │ ├── GeometryTests.swift │ ├── TestModels.swift │ └── QueryTests.swift ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .DS_Store 4 | *.xcodeproj 5 | Package.pins 6 | Package.resolved 7 | DerivedData/ 8 | .swiftpm 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test-db: 2 | docker run --rm -e POSTGRES_PASSWORD=fluentpostgis -e POSTGRES_USER=fluentpostgis -e POSTGRES_DB=postgis_tests -p 5432:5432 odidev/postgis:11-2.5-alpine 3 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --extensionacl on-declarations 2 | --header strip 3 | --maxwidth 140 4 | --self insert 5 | --swiftversion 5.10 6 | --wraparguments before-first 7 | --wrapcollections before-first 8 | --wrapparameters before-first 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Run tests 8 | runs-on: ubuntu-latest 9 | services: 10 | postgres: 11 | image: postgis/postgis 12 | ports: 13 | - "5432:5432" 14 | env: 15 | POSTGRES_USER: fluentpostgis 16 | POSTGRES_PASSWORD: fluentpostgis 17 | POSTGRES_DB: postgis_tests 18 | steps: 19 | - uses: actions/checkout@v3 20 | - run: swift test 21 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Support/EnablePostGISMigration.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import SQLKit 3 | 4 | public struct EnablePostGISMigration: AsyncMigration, Sendable { 5 | public init() {} 6 | 7 | public enum EnablePostGISMigrationError: Error, Sendable { 8 | case notSqlDatabase 9 | } 10 | 11 | public func prepare(on database: any Database) async throws { 12 | guard let db = database as? any SQLDatabase else { 13 | throw EnablePostGISMigrationError.notSqlDatabase 14 | } 15 | try await db.raw("CREATE EXTENSION IF NOT EXISTS \"postgis\"").run() 16 | } 17 | 18 | public func revert(on database: any Database) async throws { 19 | guard let db = database as? any SQLDatabase else { 20 | throw EnablePostGISMigrationError.notSqlDatabase 21 | } 22 | try await db.raw("DROP EXTENSION IF EXISTS \"postgis\"").run() 23 | } 24 | } 25 | 26 | public let FluentPostGISSrid: UInt = 4326 27 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geometry/GeometricPoint2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeometricPoint2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The point's x coordinate. 6 | public var x: Double 7 | 8 | /// The point's y coordinate. 9 | public var y: Double 10 | 11 | /// Create a new `GISGeometricPoint2D` 12 | public init(x: Double, y: Double) { 13 | self.x = x 14 | self.y = y 15 | } 16 | } 17 | 18 | extension GeometricPoint2D: GeometryConvertible, GeometryCollectable { 19 | /// Convertible type 20 | public typealias GeometryType = Point 21 | 22 | public init(geometry point: GeometryType) { 23 | self.init(x: point.x, y: point.y) 24 | } 25 | 26 | public var geometry: GeometryType { 27 | .init(vector: [self.x, self.y], srid: FluentPostGISSrid) 28 | } 29 | 30 | public var baseGeometry: any Geometry { 31 | self.geometry 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geometry/GeometricLineString2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeometricLineString2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public var points: [GeometricPoint2D] 7 | 8 | /// Create a new `GISGeometricLineString2D` 9 | public init(points: [GeometricPoint2D]) { 10 | self.points = points 11 | } 12 | } 13 | 14 | extension GeometricLineString2D: GeometryConvertible, GeometryCollectable { 15 | /// Convertible type 16 | public typealias GeometryType = LineString 17 | 18 | public init(geometry lineString: GeometryType) { 19 | let points = lineString.points.map { GeometricPoint2D(geometry: $0) } 20 | self.init(points: points) 21 | } 22 | 23 | public var geometry: GeometryType { 24 | .init(points: self.points.map(\.geometry), srid: FluentPostGISSrid) 25 | } 26 | 27 | public var baseGeometry: any Geometry { 28 | self.geometry 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geography/GeographicLineString2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeographicLineString2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public var points: [GeographicPoint2D] 7 | 8 | /// Create a new `GISGeographicLineString2D` 9 | public init(points: [GeographicPoint2D]) { 10 | self.points = points 11 | } 12 | } 13 | 14 | extension GeographicLineString2D: GeometryConvertible, GeometryCollectable { 15 | /// Convertible type 16 | public typealias GeometryType = LineString 17 | 18 | public init(geometry lineString: GeometryType) { 19 | let points = lineString.points.map { GeographicPoint2D(geometry: $0) } 20 | self.init(points: points) 21 | } 22 | 23 | public var geometry: GeometryType { 24 | .init(points: self.points.map(\.geometry), srid: FluentPostGISSrid) 25 | } 26 | 27 | public var baseGeometry: any Geometry { 28 | self.geometry 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geography/GeographicMultiPoint2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeographicMultiPoint2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public var points: [GeographicPoint2D] 7 | 8 | /// Create a new `GISGeographicLineString2D` 9 | public init(points: [GeographicPoint2D]) { 10 | self.points = points 11 | } 12 | } 13 | 14 | extension GeographicMultiPoint2D: GeometryConvertible, GeometryCollectable { 15 | /// Convertible type 16 | public typealias GeometryType = MultiPoint 17 | 18 | public init(geometry lineString: GeometryType) { 19 | let points = lineString.points.map { GeographicPoint2D(geometry: $0) } 20 | self.init(points: points) 21 | } 22 | 23 | public var geometry: GeometryType { 24 | .init(points: self.points.map(\.geometry), srid: FluentPostGISSrid) 25 | } 26 | 27 | public var baseGeometry: any Geometry { 28 | self.geometry 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geometry/GeometricMultiPoint2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeometricMultiPoint2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public var points: [GeometricPoint2D] 7 | 8 | /// Create a new `GISGeometricLineString2D` 9 | public init(points: [GeometricPoint2D]) { 10 | self.points = points 11 | } 12 | } 13 | 14 | extension GeometricMultiPoint2D: GeometryConvertible, GeometryCollectable { 15 | /// Convertible type 16 | public typealias GeometryType = MultiPoint 17 | 18 | public init(geometry lineString: GeometryType) { 19 | let points = lineString.points.map { GeometricPoint2D(geometry: $0) } 20 | self.init(points: points) 21 | } 22 | 23 | public var geometry: GeometryType { 24 | MultiPoint(points: self.points.map(\.geometry), srid: FluentPostGISSrid) 25 | } 26 | 27 | public var baseGeometry: any Geometry { 28 | self.geometry 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Distance.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | @discardableResult 6 | public func filterGeometryDistance( 7 | _ field: KeyPath, 8 | _ filter: V, 9 | _ method: SQLBinaryOperator, 10 | _ value: Double 11 | ) -> Self 12 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 13 | { 14 | self.filterGeometryDistance( 15 | QueryBuilder.path(field), 16 | QueryBuilder.queryExpressionGeometry(filter), 17 | method, 18 | SQLLiteral.numeric(String(value)) 19 | ) 20 | } 21 | } 22 | 23 | extension QueryBuilder { 24 | public func filterGeometryDistance( 25 | _ path: any SQLExpression, 26 | _ filter: any SQLExpression, 27 | _ method: SQLBinaryOperator, 28 | _ value: any SQLExpression 29 | ) -> Self { 30 | self.filter(.sql(SQLFunction("ST_Distance", args: [path, filter]), method, value)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geography/GeographicPoint2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeographicPoint2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The point's x coordinate. 6 | public var longitude: Double 7 | 8 | /// The point's y coordinate. 9 | public var latitude: Double 10 | 11 | /// Create a new `GISGeographicPoint2D` 12 | public init(longitude: Double, latitude: Double) { 13 | self.longitude = longitude 14 | self.latitude = latitude 15 | } 16 | } 17 | 18 | extension GeographicPoint2D: GeometryConvertible, GeometryCollectable { 19 | /// Convertible type 20 | public typealias GeometryType = Point 21 | 22 | public init(geometry point: GeometryType) { 23 | self.init(longitude: point.x, latitude: point.y) 24 | } 25 | 26 | public var geometry: GeometryType { 27 | .init(vector: [self.longitude, self.latitude], srid: FluentPostGISSrid) 28 | } 29 | 30 | public var baseGeometry: any Geometry { 31 | self.geometry 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geometry/GeometricMultiPolygon2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeometricMultiPolygon2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public let polygons: [GeometricPolygon2D] 7 | 8 | /// Create a new `GISGeometricMultiPolygon2D` 9 | public init(polygons: [GeometricPolygon2D]) { 10 | self.polygons = polygons 11 | } 12 | } 13 | 14 | extension GeometricMultiPolygon2D: GeometryConvertible, GeometryCollectable { 15 | /// Convertible type 16 | public typealias GeometryType = MultiPolygon 17 | 18 | public init(geometry polygon: GeometryType) { 19 | let polygons = polygon.polygons.map { GeometricPolygon2D(geometry: $0) } 20 | self.init(polygons: polygons) 21 | } 22 | 23 | public var geometry: GeometryType { 24 | let polygons = polygons.map(\.geometry) 25 | return .init(polygons: polygons, srid: FluentPostGISSrid) 26 | } 27 | 28 | public var baseGeometry: any Geometry { 29 | self.geometry 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geography/GeographicMultiPolygon2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeographicMultiPolygon2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public let polygons: [GeographicPolygon2D] 7 | 8 | /// Create a new `GISGeographicMultiPolygon2D` 9 | public init(polygons: [GeographicPolygon2D]) { 10 | self.polygons = polygons 11 | } 12 | } 13 | 14 | extension GeographicMultiPolygon2D: GeometryConvertible, GeometryCollectable { 15 | /// Convertible type 16 | public typealias GeometryType = MultiPolygon 17 | 18 | public init(geometry polygon: GeometryType) { 19 | let polygons = polygon.polygons.map { GeographicPolygon2D(geometry: $0) } 20 | self.init(polygons: polygons) 21 | } 22 | 23 | public var geometry: GeometryType { 24 | let polygons = polygons.map(\.geometry) 25 | return .init(polygons: polygons, srid: FluentPostGISSrid) 26 | } 27 | 28 | public var baseGeometry: any Geometry { 29 | self.geometry 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geometry/GeometricMultiLineString2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeometricMultiLineString2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public let lineStrings: [GeometricLineString2D] 7 | 8 | /// Create a new `GISGeometricMultiLineString2D` 9 | public init(lineStrings: [GeometricLineString2D]) { 10 | self.lineStrings = lineStrings 11 | } 12 | } 13 | 14 | extension GeometricMultiLineString2D: GeometryConvertible, GeometryCollectable { 15 | /// Convertible type 16 | public typealias GeometryType = MultiLineString 17 | 18 | public init(geometry polygon: GeometryType) { 19 | let lineStrings = polygon.lineStrings.map { GeometricLineString2D(geometry: $0) } 20 | self.init(lineStrings: lineStrings) 21 | } 22 | 23 | public var geometry: GeometryType { 24 | let lineStrings = lineStrings.map(\.geometry) 25 | return .init(lineStrings: lineStrings, srid: FluentPostGISSrid) 26 | } 27 | 28 | public var baseGeometry: any Geometry { 29 | self.geometry 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Phil Larson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geography/GeographicMultiLineString2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeographicMultiLineString2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public let lineStrings: [GeographicLineString2D] 7 | 8 | /// Create a new `GISGeographicMultiLineString2D` 9 | public init(lineStrings: [GeographicLineString2D]) { 10 | self.lineStrings = lineStrings 11 | } 12 | } 13 | 14 | extension GeographicMultiLineString2D: GeometryConvertible, GeometryCollectable { 15 | /// Convertible type 16 | public typealias GeometryType = MultiLineString 17 | 18 | public init(geometry polygon: GeometryType) { 19 | let lineStrings = polygon.lineStrings.map { GeographicLineString2D(geometry: $0) } 20 | self.init(lineStrings: lineStrings) 21 | } 22 | 23 | public var geometry: GeometryType { 24 | let lineStrings = lineStrings.map(\.geometry) 25 | return .init(lineStrings: lineStrings, srid: FluentPostGISSrid) 26 | } 27 | 28 | public var baseGeometry: any Geometry { 29 | self.geometry 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Sort.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import FluentSQL 3 | 4 | extension QueryBuilder { 5 | /// Applies an `ST_Distance` sort to this query. Usually you will use the filter operators to do this. 6 | /// 7 | /// let users = try User.query(on: conn) 8 | /// .sortByDistance(\.$position, targetPosition) 9 | /// .all() 10 | /// 11 | /// - parameters: 12 | /// - key: Swift `KeyPath` to a field on the model to filter. 13 | /// - value: Geometry value to filter by. 14 | /// - returns: Query builder for chaining. 15 | @discardableResult 16 | public func sortByDistance(between field: KeyPath, _ value: V) -> Self 17 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 18 | { 19 | self.sortByDistance( 20 | QueryBuilder.path(field), 21 | QueryBuilder.queryExpressionGeometry(value) 22 | ) 23 | } 24 | } 25 | 26 | extension QueryBuilder { 27 | public func sortByDistance(_ args: any SQLExpression...) -> Self { 28 | self.sort(function: "ST_Distance", args: args) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geometry/DatabaseSchemaDataType+Geometric.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import SQLKit 3 | 4 | extension DatabaseSchema.DataType { 5 | public static var geometricPoint2D: DatabaseSchema.DataType { 6 | .custom(SQLRaw("geometry(Point, \(FluentPostGISSrid))")) 7 | } 8 | 9 | public static var geometricLineString2D: DatabaseSchema.DataType { 10 | .custom(SQLRaw("geometry(LineString, \(FluentPostGISSrid))")) 11 | } 12 | 13 | public static var geometricPolygon2D: DatabaseSchema.DataType { 14 | .custom(SQLRaw("geometry(Polygon, \(FluentPostGISSrid))")) 15 | } 16 | 17 | public static var geometricMultiPoint2D: DatabaseSchema.DataType { 18 | .custom(SQLRaw("geometry(MultiPoint, \(FluentPostGISSrid))")) 19 | } 20 | 21 | public static var geometricMultiLineString2D: DatabaseSchema.DataType { 22 | .custom(SQLRaw("geometry(MultiLineString, \(FluentPostGISSrid))")) 23 | } 24 | 25 | public static var geometricMultiPolygon2D: DatabaseSchema.DataType { 26 | .custom(SQLRaw("geometry(MultiPolygon, \(FluentPostGISSrid))")) 27 | } 28 | 29 | public static var geometricGeometryCollection2D: DatabaseSchema.DataType { 30 | .custom(SQLRaw("geometry(GeometryCollection, \(FluentPostGISSrid))")) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Helpers.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | static func queryExpressionGeometry(_ geometry: T) -> any SQLExpression { 6 | let geometryText = WKTEncoder().encode(geometry.geometry) 7 | return SQLFunction("ST_GeomFromEWKT", args: [SQLLiteral.string(geometryText)]) 8 | } 9 | 10 | static func queryExpressionGeography(_ geometry: T) -> any SQLExpression { 11 | let geometryText = WKTEncoder().encode(geometry.geometry) 12 | return SQLFunction("ST_GeogFromText", args: [SQLLiteral.string(geometryText)]) 13 | } 14 | 15 | static func path(_ field: KeyPath) -> any SQLExpression 16 | where M: Schema, F: QueryableProperty, F.Model == M 17 | { 18 | let path = M.path(for: field).map(\.description).joined(separator: "_") 19 | return SQLColumn(path) 20 | } 21 | } 22 | 23 | extension QueryBuilder { 24 | func filter(function: String, args: [any SQLExpression]) -> Self { 25 | self.filter(.sql(SQLFunction(function, args: args))) 26 | } 27 | 28 | func sort(function: String, args: [any SQLExpression]) -> Self { 29 | self.sort(.sql(SQLFunction(function, args: args))) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geography/DatabaseSchemaDataType+Geographic.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import SQLKit 3 | 4 | extension DatabaseSchema.DataType { 5 | public static var geographicPoint2D: DatabaseSchema.DataType { 6 | .custom(SQLRaw("geography(Point, \(FluentPostGISSrid))")) 7 | } 8 | 9 | public static var geographicLineString2D: DatabaseSchema.DataType { 10 | .custom(SQLRaw("geography(LineString, \(FluentPostGISSrid))")) 11 | } 12 | 13 | public static var geographicPolygon2D: DatabaseSchema.DataType { 14 | .custom(SQLRaw("geography(Polygon, \(FluentPostGISSrid))")) 15 | } 16 | 17 | public static var geographicMultiPoint2D: DatabaseSchema.DataType { 18 | .custom(SQLRaw("geography(MultiPoint, \(FluentPostGISSrid))")) 19 | } 20 | 21 | public static var geographicMultiLineString2D: DatabaseSchema.DataType { 22 | .custom(SQLRaw("geography(MultiLineString, \(FluentPostGISSrid))")) 23 | } 24 | 25 | public static var geographicMultiPolygon2D: DatabaseSchema.DataType { 26 | .custom(SQLRaw("geography(MultiPolygon, \(FluentPostGISSrid))")) 27 | } 28 | 29 | public static var geographicGeometryCollection2D: DatabaseSchema.DataType { 30 | .custom(SQLRaw("geography(GeometryCollection, \(FluentPostGISSrid))")) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Equals.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | /// Applies an ST_Equals filter to this query. Usually you will use the filter operators to do this. 6 | /// 7 | /// let users = try User.query(on: conn) 8 | /// .filterGeometryEquals(\.area, path) 9 | /// .all() 10 | /// 11 | /// - parameters: 12 | /// - key: Swift `KeyPath` to a field on the model to filter. 13 | /// - value: Geometry value to filter by. 14 | /// - returns: Query builder for chaining. 15 | @discardableResult 16 | public func filterGeometryEquals(_ field: KeyPath, _ value: V) -> Self 17 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 18 | { 19 | self.filterGeometryEquals( 20 | QueryBuilder.path(field), 21 | QueryBuilder.queryExpressionGeometry(value) 22 | ) 23 | } 24 | } 25 | 26 | extension QueryBuilder { 27 | /// Creates an instance of `QueryFilter` for ST_Equals from a field and value. 28 | /// 29 | /// - parameters: 30 | /// - field: Field to filter. 31 | /// - value: Value type. 32 | public func filterGeometryEquals(_ args: any SQLExpression...) -> Self { 33 | self.filter(function: "ST_Equals", args: args) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geometry/GeometryConvertible.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import WKCodable 4 | 5 | public protocol GeometryCollectable: Sendable { 6 | var baseGeometry: any Geometry { get } 7 | func isEqual(to other: Any?) -> Bool 8 | } 9 | 10 | public protocol GeometryConvertible: Sendable { 11 | associatedtype GeometryType: Geometry 12 | init(geometry: GeometryType) 13 | var geometry: GeometryType { get } 14 | } 15 | 16 | extension GeometryCollectable where Self: Equatable { 17 | public func isEqual(to other: Any?) -> Bool { 18 | guard let other = other as? Self else { return false } 19 | return self == other 20 | } 21 | } 22 | 23 | extension GeometryConvertible where Self: CustomStringConvertible { 24 | public var description: String { 25 | WKTEncoder().encode(geometry) 26 | } 27 | } 28 | 29 | extension GeometryConvertible { 30 | public init(from decoder: any Decoder) throws { 31 | let value = try decoder.singleValueContainer().decode(Data.self) 32 | let decoder = WKBDecoder() 33 | let geometry: GeometryType = try decoder.decode(from: value) 34 | self.init(geometry: geometry) 35 | } 36 | 37 | public func encode(to encoder: any Encoder) throws { 38 | let wkEncoder = WKBEncoder(byteOrder: .littleEndian) 39 | let data = wkEncoder.encode(geometry) 40 | 41 | var container = encoder.singleValueContainer() 42 | try container.encode(data) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/FluentPostGISTests/FluentPostGISTestCase.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import FluentPostgresDriver 3 | import PostgresKit 4 | import XCTest 5 | 6 | class FluentPostGISTestCase: XCTestCase { 7 | var dbs: Databases! 8 | var db: any Database { 9 | self.dbs.database( 10 | logger: .init(label: "lib.fluent.postgis"), 11 | on: self.dbs.eventLoopGroup.next() 12 | )! 13 | } 14 | 15 | override func setUp() async throws { 16 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 17 | let threadPool = NIOThreadPool(numberOfThreads: 1) 18 | self.dbs = Databases(threadPool: threadPool, on: eventLoopGroup) 19 | let configuration = SQLPostgresConfiguration( 20 | hostname: "localhost", 21 | username: "fluentpostgis", 22 | password: "fluentpostgis", 23 | database: "postgis_tests", 24 | tls: .disable 25 | ) 26 | self.dbs.use(.postgres(configuration: configuration), as: .psql) 27 | 28 | for migration in self.migrations { 29 | try await migration.prepare(on: self.db) 30 | } 31 | } 32 | 33 | override func tearDown() async throws { 34 | for migration in self.migrations { 35 | try await migration.revert(on: self.db) 36 | } 37 | } 38 | 39 | private let migrations: [any AsyncMigration] = [ 40 | UserLocationMigration(), 41 | CityMigration(), 42 | UserPathMigration(), 43 | UserAreaMigration(), 44 | UserCollectionMigration(), 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geometry/GeometricPolygon2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeometricPolygon2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public let exteriorRing: GeometricLineString2D 7 | public let interiorRings: [GeometricLineString2D] 8 | 9 | public init(exteriorRing: GeometricLineString2D) { 10 | self.init(exteriorRing: exteriorRing, interiorRings: []) 11 | } 12 | 13 | /// Create a new `GISGeometricPolygon2D` 14 | public init(exteriorRing: GeometricLineString2D, interiorRings: [GeometricLineString2D]) { 15 | self.exteriorRing = exteriorRing 16 | self.interiorRings = interiorRings 17 | } 18 | } 19 | 20 | extension GeometricPolygon2D: GeometryConvertible, GeometryCollectable { 21 | /// Convertible type 22 | public typealias GeometryType = WKCodable.Polygon 23 | 24 | public init(geometry polygon: GeometryType) { 25 | let exteriorRing = GeometricLineString2D(geometry: polygon.exteriorRing) 26 | let interiorRings = polygon.interiorRings.map { GeometricLineString2D(geometry: $0) } 27 | self.init(exteriorRing: exteriorRing, interiorRings: interiorRings) 28 | } 29 | 30 | public var geometry: GeometryType { 31 | let exteriorRing = exteriorRing.geometry 32 | let interiorRings = interiorRings.map(\.geometry) 33 | return .init( 34 | exteriorRing: exteriorRing, 35 | interiorRings: interiorRings, 36 | srid: FluentPostGISSrid 37 | ) 38 | } 39 | 40 | public var baseGeometry: any Geometry { 41 | self.geometry 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geography/GeographicPolygon2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeographicPolygon2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public let exteriorRing: GeographicLineString2D 7 | public let interiorRings: [GeographicLineString2D] 8 | 9 | public init(exteriorRing: GeographicLineString2D) { 10 | self.init(exteriorRing: exteriorRing, interiorRings: []) 11 | } 12 | 13 | /// Create a new `GISGeographicPolygon2D` 14 | public init(exteriorRing: GeographicLineString2D, interiorRings: [GeographicLineString2D]) { 15 | self.exteriorRing = exteriorRing 16 | self.interiorRings = interiorRings 17 | } 18 | } 19 | 20 | extension GeographicPolygon2D: GeometryConvertible, GeometryCollectable { 21 | /// Convertible type 22 | public typealias GeometryType = WKCodable.Polygon 23 | 24 | public init(geometry polygon: GeometryType) { 25 | let exteriorRing = GeographicLineString2D(geometry: polygon.exteriorRing) 26 | let interiorRings = polygon.interiorRings.map { GeographicLineString2D(geometry: $0) } 27 | self.init(exteriorRing: exteriorRing, interiorRings: interiorRings) 28 | } 29 | 30 | public var geometry: GeometryType { 31 | let exteriorRing = exteriorRing.geometry 32 | let interiorRings = interiorRings.map(\.geometry) 33 | return .init( 34 | exteriorRing: exteriorRing, 35 | interiorRings: interiorRings, 36 | srid: FluentPostGISSrid 37 | ) 38 | } 39 | 40 | public var baseGeometry: any Geometry { 41 | self.geometry 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "fluent-postgis", 6 | platforms: [ 7 | .macOS(.v12), 8 | ], 9 | products: [ 10 | .library(name: "FluentPostGIS", targets: ["FluentPostGIS"]), 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.52.2"), 14 | .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.10.1"), 15 | .package(url: "https://github.com/rabc/WKCodable.git", from: "0.1.2"), 16 | ], 17 | targets: [ 18 | .target( 19 | name: "FluentPostGIS", 20 | dependencies: [ 21 | .product(name: "FluentKit", package: "fluent-kit"), 22 | .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), 23 | .product(name: "WKCodable", package: "WKCodable"), 24 | ], 25 | swiftSettings: swiftSettings 26 | ), 27 | .testTarget( 28 | name: "FluentPostGISTests", 29 | dependencies: [ 30 | .target(name: "FluentPostGIS"), 31 | .product(name: "FluentBenchmark", package: "fluent-kit"), 32 | ], 33 | swiftSettings: swiftSettings 34 | ), 35 | ] 36 | ) 37 | 38 | var swiftSettings: [SwiftSetting] { [ 39 | .enableUpcomingFeature("ExistentialAny"), 40 | .enableUpcomingFeature("ConciseMagicFile"), 41 | .enableUpcomingFeature("ForwardTrailingClosures"), 42 | .enableUpcomingFeature("DisableOutwardActorInference"), 43 | .enableUpcomingFeature("StrictConcurrency"), 44 | .enableExperimentalFeature("StrictConcurrency=complete"), 45 | ] } 46 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+DistanceWithin.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | @discardableResult 6 | public func filterGeometryDistanceWithin( 7 | _ field: KeyPath, 8 | _ value: Field.Value, 9 | _ distance: Double 10 | ) -> Self where Field: QueryableProperty, Field.Model == Model, 11 | Field.Value: GeometryConvertible 12 | { 13 | self.filterDistanceWithin( 14 | QueryBuilder.path(field), 15 | QueryBuilder.queryExpressionGeometry(value), 16 | SQLLiteral.numeric(String(distance)) 17 | ) 18 | } 19 | 20 | @discardableResult 21 | public func filterGeographyDistanceWithin( 22 | _ field: KeyPath, 23 | _ value: Field.Value, 24 | _ distance: Double 25 | ) -> Self where Field: QueryableProperty, Field.Model == Model, 26 | Field.Value: GeometryConvertible 27 | { 28 | self.filterDistanceWithin( 29 | QueryBuilder.path(field), 30 | QueryBuilder.queryExpressionGeography(value), 31 | SQLLiteral.numeric(String(distance)) 32 | ) 33 | } 34 | 35 | @discardableResult 36 | public func filterGeographyDistanceWithin( 37 | _ field: KeyPath, 38 | _ value: Field.Value, 39 | _ distance: KeyPath 40 | ) -> Self 41 | where Field: QueryableProperty, 42 | Field.Model == Model, 43 | Field.Value: GeometryConvertible, 44 | OtherModel: Schema, 45 | OtherField: QueryableProperty, 46 | OtherField.Model == OtherModel, 47 | OtherField.Value == Double 48 | { 49 | self.filterDistanceWithin( 50 | QueryBuilder.path(field), 51 | QueryBuilder.queryExpressionGeography(value), 52 | SQLColumn(QueryBuilder.path(distance)) 53 | ) 54 | } 55 | } 56 | 57 | extension QueryBuilder { 58 | func filterDistanceWithin(_ args: any SQLExpression...) -> Self { 59 | self.filter(function: "ST_DWithin", args: args) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Within.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | /// Applies an ST_Within filter to this query. Usually you will use the filter operators to do this. 6 | /// 7 | /// let users = try User.query(on: conn) 8 | /// .filterGeometryWithin(\.area, path) 9 | /// .all() 10 | /// 11 | /// - parameters: 12 | /// - key: Swift `KeyPath` to a field on the model to filter. 13 | /// - value: Geometry value to filter by. 14 | /// - returns: Query builder for chaining. 15 | @discardableResult 16 | public func filterGeometryWithin(_ field: KeyPath, _ value: V) -> Self 17 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 18 | { 19 | self.filterGeometryWithin( 20 | QueryBuilder.path(field), 21 | QueryBuilder.queryExpressionGeometry(value) 22 | ) 23 | } 24 | 25 | /// Applies an ST_Within filter to this query. Usually you will use the filter operators to do this. 26 | /// 27 | /// let users = try User.query(on: conn) 28 | /// .filterGeometryWithin(area, \.path) 29 | /// .all() 30 | /// 31 | /// - parameters: 32 | /// - value: Geometry value to filter by. 33 | /// - key: Swift `KeyPath` to a field on the model to filter. 34 | /// - returns: Query builder for chaining. 35 | @discardableResult 36 | public func filterGeometryWithin(_ value: V, _ field: KeyPath) -> Self 37 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 38 | { 39 | self.filterGeometryWithin( 40 | QueryBuilder.queryExpressionGeometry(value), 41 | QueryBuilder.path(field) 42 | ) 43 | } 44 | } 45 | 46 | extension QueryBuilder { 47 | /// Creates an instance of `QueryFilter` for ST_Within from a field and value. 48 | /// 49 | /// - parameters: 50 | /// - field: Field to filter. 51 | /// - value: Value type. 52 | public func filterGeometryWithin(_ args: any SQLExpression...) -> Self { 53 | self.filter(function: "ST_Within", args: args) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Crosses.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | /// Applies an ST_Crosses filter to this query. Usually you will use the filter operators to do this. 6 | /// 7 | /// let users = try User.query(on: conn) 8 | /// .filterGeometryCrosses(\.area, path) 9 | /// .all() 10 | /// 11 | /// - parameters: 12 | /// - key: Swift `KeyPath` to a field on the model to filter. 13 | /// - value: Geometry value to filter by. 14 | /// - returns: Query builder for chaining. 15 | @discardableResult 16 | public func filterGeometryCrosses(_ field: KeyPath, _ value: V) -> Self 17 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 18 | { 19 | self.filterGeometryCrosses( 20 | QueryBuilder.path(field), 21 | QueryBuilder.queryExpressionGeometry(value) 22 | ) 23 | } 24 | 25 | /// Applies an ST_Crosses filter to this query. Usually you will use the filter operators to do this. 26 | /// 27 | /// let users = try User.query(on: conn) 28 | /// .filterGeometryCrosses(area, \.path) 29 | /// .all() 30 | /// 31 | /// - parameters: 32 | /// - value: Geometry value to filter by. 33 | /// - key: Swift `KeyPath` to a field on the model to filter. 34 | /// - returns: Query builder for chaining. 35 | @discardableResult 36 | public func filterGeometryCrosses(_ value: V, _ field: KeyPath) -> Self 37 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 38 | { 39 | self.filterGeometryCrosses( 40 | QueryBuilder.queryExpressionGeometry(value), 41 | QueryBuilder.path(field) 42 | ) 43 | } 44 | } 45 | 46 | extension QueryBuilder { 47 | /// Creates an instance of `QueryFilter` for ST_Crosses from a field and value. 48 | /// 49 | /// - parameters: 50 | /// - field: Field to filter. 51 | /// - value: Value type. 52 | public func filterGeometryCrosses(_ args: any SQLExpression...) -> Self { 53 | self.filter(function: "ST_Crosses", args: args) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Touches.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | /// Applies an ST_Touches filter to this query. Usually you will use the filter operators to do this. 6 | /// 7 | /// let users = try User.query(on: conn) 8 | /// .filterGeometryTouches(\.area, path) 9 | /// .all() 10 | /// 11 | /// - parameters: 12 | /// - key: Swift `KeyPath` to a field on the model to filter. 13 | /// - value: Geometry value to filter by. 14 | /// - returns: Query builder for chaining. 15 | @discardableResult 16 | public func filterGeometryTouches(_ field: KeyPath, _ value: V) -> Self 17 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 18 | { 19 | self.filterGeometryTouches( 20 | QueryBuilder.path(field), 21 | QueryBuilder.queryExpressionGeometry(value) 22 | ) 23 | } 24 | 25 | /// Applies an ST_Touches filter to this query. Usually you will use the filter operators to do this. 26 | /// 27 | /// let users = try User.query(on: conn) 28 | /// .filterGeometryTouches(area, \.path) 29 | /// .all() 30 | /// 31 | /// - parameters: 32 | /// - value: Geometry value to filter by. 33 | /// - key: Swift `KeyPath` to a field on the model to filter. 34 | /// - returns: Query builder for chaining. 35 | @discardableResult 36 | public func filterGeometryTouches(_ value: V, _ field: KeyPath) -> Self 37 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 38 | { 39 | self.filterGeometryTouches( 40 | QueryBuilder.queryExpressionGeometry(value), 41 | QueryBuilder.path(field) 42 | ) 43 | } 44 | } 45 | 46 | extension QueryBuilder { 47 | /// Creates an instance of `QueryFilter` for ST_Touches from a field and value. 48 | /// 49 | /// - parameters: 50 | /// - field: Field to filter. 51 | /// - value: Value type. 52 | public func filterGeometryTouches(_ args: any SQLExpression...) -> Self { 53 | self.filter(function: "ST_Touches", args: args) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Disjoint.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | /// Applies an ST_Disjoint filter to this query. Usually you will use the filter operators to do this. 6 | /// 7 | /// let users = try User.query(on: conn) 8 | /// .filterGeometryDisjoint(\.area, path) 9 | /// .all() 10 | /// 11 | /// - parameters: 12 | /// - key: Swift `KeyPath` to a field on the model to filter. 13 | /// - value: Geometry value to filter by. 14 | /// - returns: Query builder for chaining. 15 | @discardableResult 16 | public func filterGeometryDisjoint(_ field: KeyPath, _ value: V) -> Self 17 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 18 | { 19 | self.filterGeometryDisjoint( 20 | QueryBuilder.path(field), 21 | QueryBuilder.queryExpressionGeometry(value) 22 | ) 23 | } 24 | 25 | /// Applies an ST_Disjoint filter to this query. Usually you will use the filter operators to do this. 26 | /// 27 | /// let users = try User.query(on: conn) 28 | /// .filterGeometryDisjoint(area, \.path) 29 | /// .all() 30 | /// 31 | /// - parameters: 32 | /// - value: Geometry value to filter by. 33 | /// - key: Swift `KeyPath` to a field on the model to filter. 34 | /// - returns: Query builder for chaining. 35 | @discardableResult 36 | public func filterGeometryDisjoint(_ value: V, _ field: KeyPath) -> Self 37 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 38 | { 39 | self.filterGeometryDisjoint( 40 | QueryBuilder.queryExpressionGeometry(value), 41 | QueryBuilder.path(field) 42 | ) 43 | } 44 | } 45 | 46 | extension QueryBuilder { 47 | /// Creates an instance of `QueryFilter` for ST_Disjoint from a field and value. 48 | /// 49 | /// - parameters: 50 | /// - field: Field to filter. 51 | /// - value: Value type. 52 | public func filterGeometryDisjoint(_ args: any SQLExpression...) -> Self { 53 | self.filter(function: "ST_Disjoint", args: args) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Overlaps.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | /// Applies an ST_Overlaps filter to this query. Usually you will use the filter operators to do this. 6 | /// 7 | /// let users = try User.query(on: conn) 8 | /// .filterGeometryOverlaps(\.area, path) 9 | /// .all() 10 | /// 11 | /// - parameters: 12 | /// - key: Swift `KeyPath` to a field on the model to filter. 13 | /// - value: Geometry value to filter by. 14 | /// - returns: Query builder for chaining. 15 | @discardableResult 16 | public func filterGeometryOverlaps(_ field: KeyPath, _ value: V) -> Self 17 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 18 | { 19 | self.filterGeometryOverlaps( 20 | QueryBuilder.path(field), 21 | QueryBuilder.queryExpressionGeometry(value) 22 | ) 23 | } 24 | 25 | /// Applies an ST_Overlaps filter to this query. Usually you will use the filter operators to do this. 26 | /// 27 | /// let users = try User.query(on: conn) 28 | /// .filterGeometryOverlaps(area, \.path) 29 | /// .all() 30 | /// 31 | /// - parameters: 32 | /// - value: Geometry value to filter by. 33 | /// - key: Swift `KeyPath` to a field on the model to filter. 34 | /// - returns: Query builder for chaining. 35 | @discardableResult 36 | public func filterGeometryOverlaps(_ value: V, _ field: KeyPath) -> Self 37 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 38 | { 39 | self.filterGeometryOverlaps( 40 | QueryBuilder.queryExpressionGeometry(value), 41 | QueryBuilder.path(field) 42 | ) 43 | } 44 | } 45 | 46 | extension QueryBuilder { 47 | /// Creates an instance of `QueryFilter` for ST_Overlaps from a field and value. 48 | /// 49 | /// - parameters: 50 | /// - field: Field to filter. 51 | /// - value: Value type. 52 | public func filterGeometryOverlaps(_ args: any SQLExpression...) -> Self { 53 | self.filter(function: "ST_Overlaps", args: args) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Contains.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | /// Applies an ST_Contains filter to this query. Usually you will use the filter operators to do this. 6 | /// 7 | /// let users = try User.query(on: conn) 8 | /// .filterGeometryContains(\.area, point) 9 | /// .all() 10 | /// 11 | /// - parameters: 12 | /// - key: Swift `KeyPath` to a field on the model to filter. 13 | /// - value: Geometry value to filter by. 14 | /// - returns: Query builder for chaining. 15 | @discardableResult 16 | public func filterGeometryContains(_ field: KeyPath, _ value: V) -> Self 17 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 18 | { 19 | self.filterGeometryContains( 20 | QueryBuilder.path(field), 21 | QueryBuilder.queryExpressionGeometry(value) 22 | ) 23 | } 24 | 25 | /// Applies an ST_Contains filter to this query. Usually you will use the filter operators to do this. 26 | /// 27 | /// let users = try User.query(on: conn) 28 | /// .filterGeometryContains(area, \.location) 29 | /// .all() 30 | /// 31 | /// - parameters: 32 | /// - value: Geometry value to filter by. 33 | /// - key: Swift `KeyPath` to a field on the model to filter. 34 | /// - returns: Query builder for chaining. 35 | @discardableResult 36 | public func filterGeometryContains(_ value: V, _ field: KeyPath) -> Self 37 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 38 | { 39 | self.filterGeometryContains( 40 | QueryBuilder.queryExpressionGeometry(value), 41 | QueryBuilder.path(field) 42 | ) 43 | } 44 | } 45 | 46 | extension QueryBuilder { 47 | /// Creates an instance of `QueryFilter` for ST_Contains from a field and value. 48 | /// 49 | /// - parameters: 50 | /// - field: Field to filter. 51 | /// - value: Value type. 52 | public func filterGeometryContains(_ args: any SQLExpression...) -> Self { 53 | self.filter(function: "ST_Contains", args: args) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Queries/QueryBuilder+Intersects.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import WKCodable 3 | 4 | extension QueryBuilder { 5 | /// Applies an ST_Intersects filter to this query. Usually you will use the filter operators to do this. 6 | /// 7 | /// let users = try User.query(on: conn) 8 | /// .filterGeometryIntersects(\.area, path) 9 | /// .all() 10 | /// 11 | /// - parameters: 12 | /// - key: Swift `KeyPath` to a field on the model to filter. 13 | /// - value: Geometry value to filter by. 14 | /// - returns: Query builder for chaining. 15 | @discardableResult 16 | public func filterGeometryIntersects(_ field: KeyPath, _ value: V) -> Self 17 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 18 | { 19 | self.filterGeometryIntersects( 20 | QueryBuilder.path(field), 21 | QueryBuilder.queryExpressionGeometry(value) 22 | ) 23 | } 24 | 25 | /// Applies an ST_Intersects filter to this query. Usually you will use the filter operators to do this. 26 | /// 27 | /// let users = try User.query(on: conn) 28 | /// .filterGeometryIntersects(area, \.path) 29 | /// .all() 30 | /// 31 | /// - parameters: 32 | /// - value: Geometry value to filter by. 33 | /// - key: Swift `KeyPath` to a field on the model to filter. 34 | /// - returns: Query builder for chaining. 35 | @discardableResult 36 | public func filterGeometryIntersects(_ value: V, _ field: KeyPath) -> Self 37 | where F: QueryableProperty, F.Model == Model, V: GeometryConvertible 38 | { 39 | self.filterGeometryIntersects( 40 | QueryBuilder.queryExpressionGeometry(value), 41 | QueryBuilder.path(field) 42 | ) 43 | } 44 | } 45 | 46 | extension QueryBuilder { 47 | /// Creates an instance of `QueryFilter` for ST_Intersects from a field and value. 48 | /// 49 | /// - parameters: 50 | /// - field: Field to filter. 51 | /// - value: Value type. 52 | public func filterGeometryIntersects(_ args: any SQLExpression...) -> Self { 53 | self.filter(function: "ST_Intersects", args: args) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geometry/GeometricGeometryCollection2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeometricGeometryCollection2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public let geometries: [any GeometryCollectable] 7 | 8 | /// Create a new `GISGeometricGeometryCollection2D` 9 | public init(geometries: [any GeometryCollectable]) { 10 | self.geometries = geometries 11 | } 12 | } 13 | 14 | extension GeometricGeometryCollection2D: GeometryConvertible, GeometryCollectable { 15 | public typealias GeometryType = GeometryCollection 16 | 17 | public init(geometry: GeometryType) { 18 | self.geometries = geometry.geometries.map { 19 | if let value = $0 as? Point { 20 | return GeometricPoint2D(geometry: value) 21 | } else if let value = $0 as? LineString { 22 | return GeometricLineString2D(geometry: value) 23 | } else if let value = $0 as? WKCodable.Polygon { 24 | return GeometricPolygon2D(geometry: value) 25 | } else if let value = $0 as? MultiPoint { 26 | return GeometricMultiPoint2D(geometry: value) 27 | } else if let value = $0 as? MultiLineString { 28 | return GeometricMultiLineString2D(geometry: value) 29 | } else if let value = $0 as? MultiPolygon { 30 | return GeometricMultiPolygon2D(geometry: value) 31 | } else if let value = $0 as? GeometryCollection { 32 | return GeometricGeometryCollection2D(geometry: value) 33 | } else { 34 | assertionFailure() 35 | return GeometricPoint2D(x: 0, y: 0) 36 | } 37 | } 38 | } 39 | 40 | public var geometry: GeometryType { 41 | let geometries = geometries.map(\.baseGeometry) 42 | return .init(geometries: geometries, srid: FluentPostGISSrid) 43 | } 44 | 45 | public var baseGeometry: any Geometry { 46 | self.geometry 47 | } 48 | 49 | public static func == ( 50 | lhs: GeometricGeometryCollection2D, 51 | rhs: GeometricGeometryCollection2D 52 | ) -> Bool { 53 | guard lhs.geometries.count == rhs.geometries.count else { 54 | return false 55 | } 56 | for i in 0 ..< lhs.geometries.count { 57 | guard lhs.geometries[i].isEqual(to: rhs.geometries[i]) else { 58 | return false 59 | } 60 | } 61 | return true 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/FluentPostGISTests/GeometryTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FluentPostGIS 2 | import XCTest 3 | 4 | final class GeometryTests: FluentPostGISTestCase { 5 | func testPoint() async throws { 6 | let point = GeometricPoint2D(x: 1, y: 2) 7 | 8 | let user = UserLocation(location: point) 9 | try await user.save(on: self.db) 10 | 11 | let fetched = try await UserLocation.find(1, on: self.db) 12 | XCTAssertEqual(fetched?.location, point) 13 | 14 | let all = try await UserLocation.query(on: self.db) 15 | .filterGeometryDistanceWithin(\.$location, user.location, 1000) 16 | .all() 17 | XCTAssertEqual(all.count, 1) 18 | } 19 | 20 | func testLineString() async throws { 21 | let point = GeometricPoint2D(x: 1, y: 2) 22 | let point2 = GeometricPoint2D(x: 2, y: 3) 23 | let point3 = GeometricPoint2D(x: 3, y: 2) 24 | let lineString = GeometricLineString2D(points: [point, point2, point3, point]) 25 | 26 | let user = UserPath(path: lineString) 27 | try await user.save(on: self.db) 28 | 29 | let fetched = try await UserPath.find(1, on: self.db) 30 | XCTAssertEqual(fetched?.path, lineString) 31 | } 32 | 33 | func testPolygon() async throws { 34 | let point = GeometricPoint2D(x: 1, y: 2) 35 | let point2 = GeometricPoint2D(x: 2, y: 3) 36 | let point3 = GeometricPoint2D(x: 3, y: 2) 37 | let lineString = GeometricLineString2D(points: [point, point2, point3, point]) 38 | let polygon = GeometricPolygon2D( 39 | exteriorRing: lineString, 40 | interiorRings: [lineString, lineString] 41 | ) 42 | 43 | let user = UserArea(area: polygon) 44 | try await user.save(on: self.db) 45 | 46 | let fetched = try await UserArea.find(1, on: self.db) 47 | XCTAssertEqual(fetched?.area, polygon) 48 | } 49 | 50 | func testGeometryCollection() async throws { 51 | let point = GeometricPoint2D(x: 1, y: 2) 52 | let point2 = GeometricPoint2D(x: 2, y: 3) 53 | let point3 = GeometricPoint2D(x: 3, y: 2) 54 | let lineString = GeometricLineString2D(points: [point, point2, point3, point]) 55 | let polygon = GeometricPolygon2D( 56 | exteriorRing: lineString, 57 | interiorRings: [lineString, lineString] 58 | ) 59 | let geometries: [any GeometryCollectable] = [point, point2, point3, lineString, polygon] 60 | let geometryCollection = GeometricGeometryCollection2D(geometries: geometries) 61 | 62 | let user = UserCollection(collection: geometryCollection) 63 | try await user.save(on: self.db) 64 | 65 | let fetched = try await UserCollection.find(1, on: self.db) 66 | XCTAssertEqual(fetched?.collection, geometryCollection) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/FluentPostGIS/Geography/GeographicGeometryCollection2D.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import WKCodable 3 | 4 | public struct GeographicGeometryCollection2D: Codable, Equatable, CustomStringConvertible, Sendable { 5 | /// The points 6 | public let geometries: [any GeometryCollectable] 7 | 8 | /// Create a new `GISGeographicGeometryCollection2D` 9 | public init(geometries: [any GeometryCollectable]) { 10 | self.geometries = geometries 11 | } 12 | } 13 | 14 | extension GeographicGeometryCollection2D: GeometryConvertible, GeometryCollectable { 15 | /// Convertible type 16 | public typealias GeometryType = GeometryCollection 17 | 18 | public init(geometry: GeometryCollection) { 19 | self.geometries = geometry.geometries.map { 20 | if let value = $0 as? Point { 21 | return GeographicPoint2D(geometry: value) 22 | } else if let value = $0 as? LineString { 23 | return GeographicLineString2D(geometry: value) 24 | } else if let value = $0 as? WKCodable.Polygon { 25 | return GeographicPolygon2D(geometry: value) 26 | } else if let value = $0 as? MultiPoint { 27 | return GeographicMultiPoint2D(geometry: value) 28 | } else if let value = $0 as? MultiLineString { 29 | return GeographicMultiLineString2D(geometry: value) 30 | } else if let value = $0 as? MultiPolygon { 31 | return GeographicMultiPolygon2D(geometry: value) 32 | } else if let value = $0 as? GeometryCollection { 33 | return GeographicGeometryCollection2D(geometry: value) 34 | } else { 35 | assertionFailure() 36 | return GeographicPoint2D(longitude: 0, latitude: 0) 37 | } 38 | } 39 | } 40 | 41 | public var geometry: GeometryCollection { 42 | let geometries = geometries.map(\.baseGeometry) 43 | return .init(geometries: geometries, srid: FluentPostGISSrid) 44 | } 45 | 46 | public var baseGeometry: any Geometry { 47 | self.geometry 48 | } 49 | 50 | public init(from decoder: any Decoder) throws { 51 | let value = try decoder.singleValueContainer().decode(String.self) 52 | let wkbGeometry: GeometryCollection = try WKTDecoder().decode(from: value) 53 | self.init(geometry: wkbGeometry) 54 | } 55 | 56 | public func encode(to encoder: any Encoder) throws { 57 | let wktEncoder = WKTEncoder() 58 | let value = wktEncoder.encode(self.geometry) 59 | var container = encoder.singleValueContainer() 60 | try container.encode(value) 61 | } 62 | 63 | public static func == ( 64 | lhs: GeographicGeometryCollection2D, 65 | rhs: GeographicGeometryCollection2D 66 | ) -> Bool { 67 | guard lhs.geometries.count == rhs.geometries.count else { 68 | return false 69 | } 70 | for i in 0 ..< lhs.geometries.count { 71 | guard lhs.geometries[i].isEqual(to: rhs.geometries[i]) else { 72 | return false 73 | } 74 | } 75 | return true 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FluentPostGIS 2 | 3 | ![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20OS%20X-blue.svg) 4 | ![Package Managers](https://img.shields.io/badge/package%20managers-SwiftPM-yellow.svg) 5 | 6 | A fork of the [FluentPostGIS](https://github.com/plarson/fluent-postgis) package which adds support for geographic queries. FluentPostGIS provides PostGIS support for [fluent-postgres-driver](https://github.com/vapor/fluent-postgres-driver) and [Vapor 4](https://github.com/vapor/vapor). 7 | 8 | # Installation 9 | 10 | ## Swift Package Manager 11 | 12 | Add this line to your dependencies in `Package.swift`: 13 | 14 | ```swift 15 | .package(url: "https://github.com/brokenhandsio/fluent-postgis.git", from: "0.3.0") 16 | ``` 17 | 18 | Then add this line to a target's dependencies: 19 | 20 | ```swift 21 | .product(name: "FluentPostGIS", package: "fluent-postgis"), 22 | ``` 23 | 24 | # Setup 25 | 26 | Import module 27 | 28 | ```swift 29 | import FluentPostGIS 30 | ``` 31 | 32 | Optionally, you can add a `Migration` to enable PostGIS: 33 | 34 | ```swift 35 | app.migrations.add(EnablePostGISMigration()) 36 | 37 | ``` 38 | 39 | # Models 40 | 41 | Add a type to your model 42 | 43 | ```swift 44 | final class User: Model, @unchecked Sendable { 45 | static let schema = "user" 46 | 47 | @ID(key: .id) 48 | var id: UUID? 49 | 50 | @Field(key: "location") 51 | var location: GeometricPoint2D 52 | } 53 | ``` 54 | 55 | Then use its data type in the `Migration`: 56 | 57 | ```swift 58 | struct UserMigration: AsyncMigration { 59 | func prepare(on database: Database) async throws -> { 60 | try await database.schema(User.schema) 61 | .id() 62 | .field("location", .geometricPoint2D) 63 | .create() 64 | } 65 | func revert(on database: Database) async throws -> { 66 | try await database.schema(User.schema).delete() 67 | } 68 | } 69 | ``` 70 | 71 | | Geometric Types | Geographic Types | 72 | |---|---| 73 | |GeometricPoint2D|GeographicPoint2D| 74 | |GeometricLineString2D|GeographicLineString2D| 75 | |GeometricPolygon2D|GeographicPolygon2D| 76 | |GeometricMultiPoint2D|GeographicMultiPoint2D| 77 | |GeometricMultiLineString2D|GeographicMultiLineString2D| 78 | |GeometricMultiPolygon2D|GeographicMultiPolygon2D| 79 | |GeometricGeometryCollection2D|GeographicGeometryCollection2D| 80 | 81 | # Queries 82 | 83 | Query using any of the filter functions: 84 | 85 | ```swift 86 | let eiffelTower = GeographicPoint2D(longitude: 2.2945, latitude: 48.858222) 87 | try await User.query(on: database) 88 | .filterGeographyDistanceWithin(\.$location, eiffelTower, 1000) 89 | .all() 90 | ``` 91 | 92 | | Queries | 93 | |---| 94 | |filterGeometryContains| 95 | |filterGeometryCrosses| 96 | |filterGeometryDisjoint| 97 | |filterGeometryDistance| 98 | |filterGeometryDistanceWithin| 99 | |filterGeographyDistanceWithin| 100 | |filterGeometryEquals| 101 | |filterGeometryIntersects| 102 | |filterGeometryOverlaps| 103 | |filterGeometryTouches| 104 | |filterGeometryWithin| 105 | |sortByDistance| 106 | 107 | :gift_heart: Contributing 108 | ------------ 109 | Please create an issue with a description of your problem or open a pull request with a fix. 110 | 111 | :v: License 112 | ------- 113 | MIT 114 | 115 | :alien: Author 116 | ------ 117 | BrokenHands, Tim Condon, Nikolai Guyot - https://www.brokenhands.io/ 118 | Ricardo Carvalho - https://rabc.github.io/ 119 | Phil Larson - http://dizm.com 120 | -------------------------------------------------------------------------------- /Tests/FluentPostGISTests/TestModels.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import FluentPostGIS 3 | 4 | final class UserLocation: Model, @unchecked Sendable { 5 | static let schema = "user_location" 6 | 7 | @ID(custom: .id, generatedBy: .database) 8 | var id: Int? 9 | 10 | @Field(key: "location") 11 | var location: GeometricPoint2D 12 | 13 | init() {} 14 | 15 | init(location: GeometricPoint2D) { 16 | self.location = location 17 | } 18 | } 19 | 20 | struct UserLocationMigration: AsyncMigration { 21 | func prepare(on database: any Database) async throws { 22 | try await database.schema(UserLocation.schema) 23 | .field(.id, .int, .identifier(auto: true)) 24 | .field("location", .geometricPoint2D) 25 | .create() 26 | } 27 | 28 | func revert(on database: any Database) async throws { 29 | try await database.schema(UserLocation.schema).delete() 30 | } 31 | } 32 | 33 | /// A model for testing `GeographicPoint2D`-related functionality 34 | final class City: Model, @unchecked Sendable { 35 | static let schema = "city_location" 36 | 37 | @ID(custom: .id, generatedBy: .database) 38 | var id: Int? 39 | 40 | @Field(key: "location") 41 | var location: GeographicPoint2D 42 | 43 | init() {} 44 | 45 | init(location: GeographicPoint2D) { 46 | self.location = location 47 | } 48 | } 49 | 50 | struct CityMigration: AsyncMigration { 51 | func prepare(on database: any Database) async throws { 52 | try await database.schema(City.schema) 53 | .field(.id, .int, .identifier(auto: true)) 54 | .field("location", .geographicPoint2D) 55 | .create() 56 | } 57 | 58 | func revert(on database: any Database) async throws { 59 | try await database.schema(City.schema).delete() 60 | } 61 | } 62 | 63 | final class UserPath: Model, @unchecked Sendable { 64 | static let schema: String = "user_path" 65 | 66 | @ID(custom: .id, generatedBy: .database) 67 | var id: Int? 68 | 69 | @Field(key: "path") 70 | var path: GeometricLineString2D 71 | 72 | init() {} 73 | 74 | init(path: GeometricLineString2D) { 75 | self.path = path 76 | } 77 | } 78 | 79 | struct UserPathMigration: AsyncMigration { 80 | func prepare(on database: any Database) async throws { 81 | try await database.schema(UserPath.schema) 82 | .field(.id, .int, .identifier(auto: true)) 83 | .field("path", .geometricLineString2D) 84 | .create() 85 | } 86 | 87 | func revert(on database: any Database) async throws { 88 | try await database.schema(UserPath.schema).delete() 89 | } 90 | } 91 | 92 | final class UserArea: Model, @unchecked Sendable { 93 | static let schema: String = "user_area" 94 | 95 | @ID(custom: .id, generatedBy: .database) 96 | var id: Int? 97 | 98 | @Field(key: "area") 99 | var area: GeometricPolygon2D 100 | 101 | init() {} 102 | 103 | init(area: GeometricPolygon2D) { 104 | self.area = area 105 | } 106 | } 107 | 108 | struct UserAreaMigration: AsyncMigration { 109 | func prepare(on database: any Database) async throws { 110 | try await database.schema(UserArea.schema) 111 | .field(.id, .int, .identifier(auto: true)) 112 | .field("area", .geometricPolygon2D) 113 | .create() 114 | } 115 | 116 | func revert(on database: any Database) async throws { 117 | try await database.schema(UserArea.schema).delete() 118 | } 119 | } 120 | 121 | final class UserCollection: Model, @unchecked Sendable { 122 | static let schema: String = "user_collection" 123 | 124 | @ID(custom: .id, generatedBy: .database) 125 | var id: Int? 126 | 127 | @Field(key: "collection") 128 | var collection: GeometricGeometryCollection2D 129 | 130 | init() {} 131 | 132 | init(collection: GeometricGeometryCollection2D) { 133 | self.collection = collection 134 | } 135 | } 136 | 137 | struct UserCollectionMigration: AsyncMigration { 138 | func prepare(on database: any Database) async throws { 139 | try await database.schema(UserCollection.schema) 140 | .field(.id, .int, .identifier(auto: true)) 141 | .field("collection", .geometricGeometryCollection2D) 142 | .create() 143 | } 144 | 145 | func revert(on database: any Database) async throws { 146 | try await database.schema(UserCollection.schema).delete() 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Tests/FluentPostGISTests/QueryTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FluentPostGIS 2 | import XCTest 3 | 4 | final class QueryTests: FluentPostGISTestCase { 5 | func testContains() async throws { 6 | let exteriorRing = GeometricLineString2D(points: [ 7 | GeometricPoint2D(x: 0, y: 0), 8 | GeometricPoint2D(x: 10, y: 0), 9 | GeometricPoint2D(x: 10, y: 10), 10 | GeometricPoint2D(x: 0, y: 10), 11 | GeometricPoint2D(x: 0, y: 0), 12 | ]) 13 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 14 | 15 | let user = UserArea(area: polygon) 16 | try await user.save(on: self.db) 17 | 18 | let testPoint = GeometricPoint2D(x: 5, y: 5) 19 | let all = try await UserArea.query(on: self.db) 20 | .filterGeometryContains(\.$area, testPoint) 21 | .all() 22 | XCTAssertEqual(all.count, 1) 23 | } 24 | 25 | func testContainsReversed() async throws { 26 | let exteriorRing = GeometricLineString2D(points: [ 27 | GeometricPoint2D(x: 0, y: 0), 28 | GeometricPoint2D(x: 10, y: 0), 29 | GeometricPoint2D(x: 10, y: 10), 30 | GeometricPoint2D(x: 0, y: 10), 31 | GeometricPoint2D(x: 0, y: 0), 32 | ]) 33 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 34 | 35 | let testPoint = GeometricPoint2D(x: 5, y: 5) 36 | let user = UserLocation(location: testPoint) 37 | try await user.save(on: self.db) 38 | 39 | let all = try await UserLocation.query(on: self.db) 40 | .filterGeometryContains(polygon, \.$location) 41 | .all() 42 | XCTAssertEqual(all.count, 1) 43 | } 44 | 45 | func testContainsWithHole() async throws { 46 | let exteriorRing = GeometricLineString2D(points: [ 47 | GeometricPoint2D(x: 0, y: 0), 48 | GeometricPoint2D(x: 10, y: 0), 49 | GeometricPoint2D(x: 10, y: 10), 50 | GeometricPoint2D(x: 0, y: 10), 51 | GeometricPoint2D(x: 0, y: 0), 52 | ]) 53 | let hole = GeometricLineString2D(points: [ 54 | GeometricPoint2D(x: 2.5, y: 2.5), 55 | GeometricPoint2D(x: 7.5, y: 2.5), 56 | GeometricPoint2D(x: 7.5, y: 7.5), 57 | GeometricPoint2D(x: 2.5, y: 7.5), 58 | GeometricPoint2D(x: 2.5, y: 2.5), 59 | ]) 60 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing, interiorRings: [hole]) 61 | 62 | let user = UserArea(area: polygon) 63 | try await user.save(on: self.db) 64 | 65 | let testPoint = GeometricPoint2D(x: 5, y: 5) 66 | let all = try await UserArea.query(on: self.db) 67 | .filterGeometryContains(\.$area, testPoint) 68 | .all() 69 | XCTAssertEqual(all.count, 0) 70 | 71 | let testPoint2 = GeometricPoint2D(x: 1, y: 5) 72 | let all2 = try await UserArea.query(on: self.db) 73 | .filterGeometryContains(\.$area, testPoint2) 74 | .all() 75 | XCTAssertEqual(all2.count, 1) 76 | } 77 | 78 | func testCrosses() async throws { 79 | let exteriorRing = GeometricLineString2D(points: [ 80 | GeometricPoint2D(x: 0, y: 0), 81 | GeometricPoint2D(x: 10, y: 0), 82 | GeometricPoint2D(x: 10, y: 10), 83 | GeometricPoint2D(x: 0, y: 10), 84 | GeometricPoint2D(x: 0, y: 0), 85 | ]) 86 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 87 | 88 | let testPath = GeometricLineString2D(points: [ 89 | GeometricPoint2D(x: 15, y: 0), 90 | GeometricPoint2D(x: 5, y: 5), 91 | ]) 92 | 93 | let user = UserArea(area: polygon) 94 | try await user.save(on: self.db) 95 | 96 | let all = try await UserArea.query(on: self.db) 97 | .filterGeometryCrosses(\.$area, testPath) 98 | .all() 99 | XCTAssertEqual(all.count, 1) 100 | } 101 | 102 | func testCrossesReversed() async throws { 103 | let exteriorRing = GeometricLineString2D(points: [ 104 | GeometricPoint2D(x: 0, y: 0), 105 | GeometricPoint2D(x: 10, y: 0), 106 | GeometricPoint2D(x: 10, y: 10), 107 | GeometricPoint2D(x: 0, y: 10), 108 | GeometricPoint2D(x: 0, y: 0), 109 | ]) 110 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 111 | 112 | let testPath = GeometricLineString2D(points: [ 113 | GeometricPoint2D(x: 15, y: 0), 114 | GeometricPoint2D(x: 5, y: 5), 115 | ]) 116 | 117 | let user = UserPath(path: testPath) 118 | try await user.save(on: self.db) 119 | 120 | let all = try await UserPath.query(on: self.db) 121 | .filterGeometryCrosses(polygon, \.$path) 122 | .all() 123 | XCTAssertEqual(all.count, 1) 124 | } 125 | 126 | func testDisjoint() async throws { 127 | let exteriorRing = GeometricLineString2D(points: [ 128 | GeometricPoint2D(x: 0, y: 0), 129 | GeometricPoint2D(x: 10, y: 0), 130 | GeometricPoint2D(x: 10, y: 10), 131 | GeometricPoint2D(x: 0, y: 10), 132 | GeometricPoint2D(x: 0, y: 0), 133 | ]) 134 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 135 | 136 | let testPath = GeometricLineString2D(points: [ 137 | GeometricPoint2D(x: 15, y: 0), 138 | GeometricPoint2D(x: 11, y: 5), 139 | ]) 140 | 141 | let user = UserArea(area: polygon) 142 | try await user.save(on: self.db) 143 | 144 | let all = try await UserArea.query(on: self.db) 145 | .filterGeometryDisjoint(\.$area, testPath) 146 | .all() 147 | XCTAssertEqual(all.count, 1) 148 | } 149 | 150 | func testDisjointReversed() async throws { 151 | let exteriorRing = GeometricLineString2D(points: [ 152 | GeometricPoint2D(x: 0, y: 0), 153 | GeometricPoint2D(x: 10, y: 0), 154 | GeometricPoint2D(x: 10, y: 10), 155 | GeometricPoint2D(x: 0, y: 10), 156 | GeometricPoint2D(x: 0, y: 0), 157 | ]) 158 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 159 | 160 | let testPath = GeometricLineString2D(points: [ 161 | GeometricPoint2D(x: 15, y: 0), 162 | GeometricPoint2D(x: 11, y: 5), 163 | ]) 164 | 165 | let user = UserPath(path: testPath) 166 | try await user.save(on: self.db) 167 | 168 | let all = try await UserPath.query(on: self.db) 169 | .filterGeometryDisjoint(polygon, \.$path) 170 | .all() 171 | XCTAssertEqual(all.count, 1) 172 | } 173 | 174 | func testEquals() async throws { 175 | let exteriorRing = GeometricLineString2D(points: [ 176 | GeometricPoint2D(x: 0, y: 0), 177 | GeometricPoint2D(x: 10, y: 0), 178 | GeometricPoint2D(x: 10, y: 10), 179 | GeometricPoint2D(x: 0, y: 10), 180 | GeometricPoint2D(x: 0, y: 0), 181 | ]) 182 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 183 | 184 | let user = UserArea(area: polygon) 185 | try await user.save(on: self.db) 186 | 187 | let all = try await UserArea.query(on: self.db) 188 | .filterGeometryEquals(\.$area, polygon) 189 | .all() 190 | XCTAssertEqual(all.count, 1) 191 | } 192 | 193 | func testIntersects() async throws { 194 | let exteriorRing = GeometricLineString2D(points: [ 195 | GeometricPoint2D(x: 0, y: 0), 196 | GeometricPoint2D(x: 10, y: 0), 197 | GeometricPoint2D(x: 10, y: 10), 198 | GeometricPoint2D(x: 0, y: 10), 199 | GeometricPoint2D(x: 0, y: 0), 200 | ]) 201 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 202 | 203 | let testPath = GeometricLineString2D(points: [ 204 | GeometricPoint2D(x: 15, y: 0), 205 | GeometricPoint2D(x: 5, y: 5), 206 | ]) 207 | 208 | let user = UserArea(area: polygon) 209 | try await user.save(on: self.db) 210 | 211 | let all = try await UserArea.query(on: self.db) 212 | .filterGeometryIntersects(\.$area, testPath) 213 | .all() 214 | XCTAssertEqual(all.count, 1) 215 | } 216 | 217 | func testIntersectsReversed() async throws { 218 | let exteriorRing = GeometricLineString2D(points: [ 219 | GeometricPoint2D(x: 0, y: 0), 220 | GeometricPoint2D(x: 10, y: 0), 221 | GeometricPoint2D(x: 10, y: 10), 222 | GeometricPoint2D(x: 0, y: 10), 223 | GeometricPoint2D(x: 0, y: 0), 224 | ]) 225 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 226 | 227 | let testPath = GeometricLineString2D(points: [ 228 | GeometricPoint2D(x: 15, y: 0), 229 | GeometricPoint2D(x: 5, y: 5), 230 | ]) 231 | 232 | let user = UserPath(path: testPath) 233 | try await user.save(on: self.db) 234 | 235 | let all = try await UserPath.query(on: self.db) 236 | .filterGeometryIntersects(polygon, \.$path) 237 | .all() 238 | XCTAssertEqual(all.count, 1) 239 | } 240 | 241 | func testOverlaps() async throws { 242 | let testPath = GeometricLineString2D(points: [ 243 | GeometricPoint2D(x: 15, y: 0), 244 | GeometricPoint2D(x: 5, y: 5), 245 | GeometricPoint2D(x: 6, y: 6), 246 | GeometricPoint2D(x: 0, y: 0), 247 | ]) 248 | 249 | let testPath2 = GeometricLineString2D(points: [ 250 | GeometricPoint2D(x: 16, y: 0), 251 | GeometricPoint2D(x: 5, y: 5), 252 | GeometricPoint2D(x: 6, y: 6), 253 | GeometricPoint2D(x: 2, y: 0), 254 | ]) 255 | 256 | let user = UserPath(path: testPath) 257 | try await user.save(on: self.db) 258 | 259 | let all = try await UserPath.query(on: self.db) 260 | .filterGeometryOverlaps(\.$path, testPath2) 261 | .all() 262 | XCTAssertEqual(all.count, 1) 263 | } 264 | 265 | func testOverlapsReversed() async throws { 266 | let testPath = GeometricLineString2D(points: [ 267 | GeometricPoint2D(x: 15, y: 0), 268 | GeometricPoint2D(x: 5, y: 5), 269 | GeometricPoint2D(x: 6, y: 6), 270 | GeometricPoint2D(x: 0, y: 0), 271 | ]) 272 | 273 | let testPath2 = GeometricLineString2D(points: [ 274 | GeometricPoint2D(x: 16, y: 0), 275 | GeometricPoint2D(x: 5, y: 5), 276 | GeometricPoint2D(x: 6, y: 6), 277 | GeometricPoint2D(x: 2, y: 0), 278 | ]) 279 | 280 | let user = UserPath(path: testPath) 281 | try await user.save(on: self.db) 282 | 283 | let all = try await UserPath.query(on: self.db) 284 | .filterGeometryOverlaps(testPath2, \.$path) 285 | .all() 286 | XCTAssertEqual(all.count, 1) 287 | } 288 | 289 | func testTouches() async throws { 290 | let testPath = GeometricLineString2D(points: [ 291 | GeometricPoint2D(x: 0, y: 0), 292 | GeometricPoint2D(x: 1, y: 1), 293 | GeometricPoint2D(x: 0, y: 2), 294 | ]) 295 | 296 | let testPoint = GeometricPoint2D(x: 0, y: 2) 297 | 298 | let user = UserPath(path: testPath) 299 | try await user.save(on: self.db) 300 | 301 | let all = try await UserPath.query(on: self.db) 302 | .filterGeometryTouches(\.$path, testPoint) 303 | .all() 304 | XCTAssertEqual(all.count, 1) 305 | } 306 | 307 | func testTouchesReversed() async throws { 308 | let testPath = GeometricLineString2D(points: [ 309 | GeometricPoint2D(x: 0, y: 0), 310 | GeometricPoint2D(x: 1, y: 1), 311 | GeometricPoint2D(x: 0, y: 2), 312 | ]) 313 | 314 | let testPoint = GeometricPoint2D(x: 0, y: 2) 315 | 316 | let user = UserPath(path: testPath) 317 | try await user.save(on: self.db) 318 | 319 | let all = try await UserPath.query(on: self.db) 320 | .filterGeometryTouches(testPoint, \.$path) 321 | .all() 322 | XCTAssertEqual(all.count, 1) 323 | } 324 | 325 | func testWithin() async throws { 326 | let exteriorRing = GeometricLineString2D(points: [ 327 | GeometricPoint2D(x: 0, y: 0), 328 | GeometricPoint2D(x: 10, y: 0), 329 | GeometricPoint2D(x: 10, y: 10), 330 | GeometricPoint2D(x: 0, y: 10), 331 | GeometricPoint2D(x: 0, y: 0), 332 | ]) 333 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 334 | let hole = GeometricLineString2D(points: [ 335 | GeometricPoint2D(x: 2.5, y: 2.5), 336 | GeometricPoint2D(x: 7.5, y: 2.5), 337 | GeometricPoint2D(x: 7.5, y: 7.5), 338 | GeometricPoint2D(x: 2.5, y: 7.5), 339 | GeometricPoint2D(x: 2.5, y: 2.5), 340 | ]) 341 | let polygon2 = GeometricPolygon2D(exteriorRing: hole) 342 | 343 | let user = UserArea(area: polygon2) 344 | try await user.save(on: self.db) 345 | 346 | let all = try await UserArea.query(on: self.db) 347 | .filterGeometryWithin(\.$area, polygon) 348 | .all() 349 | XCTAssertEqual(all.count, 1) 350 | } 351 | 352 | func testWithinReversed() async throws { 353 | let exteriorRing = GeometricLineString2D(points: [ 354 | GeometricPoint2D(x: 0, y: 0), 355 | GeometricPoint2D(x: 10, y: 0), 356 | GeometricPoint2D(x: 10, y: 10), 357 | GeometricPoint2D(x: 0, y: 10), 358 | GeometricPoint2D(x: 0, y: 0), 359 | ]) 360 | let polygon = GeometricPolygon2D(exteriorRing: exteriorRing) 361 | let hole = GeometricLineString2D(points: [ 362 | GeometricPoint2D(x: 2.5, y: 2.5), 363 | GeometricPoint2D(x: 7.5, y: 2.5), 364 | GeometricPoint2D(x: 7.5, y: 7.5), 365 | GeometricPoint2D(x: 2.5, y: 7.5), 366 | GeometricPoint2D(x: 2.5, y: 2.5), 367 | ]) 368 | let polygon2 = GeometricPolygon2D(exteriorRing: hole) 369 | 370 | let user = UserArea(area: polygon) 371 | try await user.save(on: self.db) 372 | 373 | let all = try await UserArea.query(on: self.db) 374 | .filterGeometryWithin(polygon2, \.$area) 375 | .all() 376 | XCTAssertEqual(all.count, 1) 377 | } 378 | 379 | func testDistanceWithin() async throws { 380 | let berlin = GeographicPoint2D(longitude: 13.41053, latitude: 52.52437) 381 | 382 | // 255 km from Berlin 383 | let hamburg = City(location: GeographicPoint2D(longitude: 10.01534, latitude: 53.57532)) 384 | try await hamburg.save(on: self.db) 385 | 386 | // 505 km from Berlin 387 | let munich = City(location: GeographicPoint2D(longitude: 11.57549, latitude: 48.13743)) 388 | try await munich.save(on: self.db) 389 | 390 | // 27 km from Berlin 391 | let potsdam = City(location: GeographicPoint2D(longitude: 13.06566, latitude: 52.39886)) 392 | try await potsdam.save(on: self.db) 393 | 394 | let all = try await City.query(on: self.db) 395 | .filterGeographyDistanceWithin(\.$location, berlin, 30 * 1000) 396 | .all() 397 | 398 | XCTAssertEqual(all.map(\.id), [potsdam].map(\.id)) 399 | } 400 | 401 | func testSortByDistance() async throws { 402 | let berlin = GeographicPoint2D(longitude: 13.41053, latitude: 52.52437) 403 | 404 | // 255 km from Berlin 405 | let hamburg = City(location: GeographicPoint2D(longitude: 10.01534, latitude: 53.57532)) 406 | try await hamburg.save(on: self.db) 407 | 408 | // 505 km from Berlin 409 | let munich = City(location: GeographicPoint2D(longitude: 11.57549, latitude: 48.13743)) 410 | try await munich.save(on: self.db) 411 | 412 | // 27 km from Berlin 413 | let potsdam = City(location: GeographicPoint2D(longitude: 13.06566, latitude: 52.39886)) 414 | try await potsdam.save(on: self.db) 415 | 416 | let all = try await City.query(on: self.db) 417 | .sortByDistance(between: \.$location, berlin) 418 | .all() 419 | XCTAssertEqual(all.map(\.id), [potsdam, hamburg, munich].map(\.id)) 420 | } 421 | } 422 | --------------------------------------------------------------------------------