diff --git a/Sources/PostgresKit/PostgresDataTranslation.swift b/Sources/PostgresKit/PostgresDataTranslation.swift index 8d866f0..535f002 100644 --- a/Sources/PostgresKit/PostgresDataTranslation.swift +++ b/Sources/PostgresKit/PostgresDataTranslation.swift @@ -8,6 +8,16 @@ extension PostgresCell { } } +fileprivate protocol OptionalType { associatedtype Wrapped } +extension Optional: OptionalType {} + +fileprivate protocol OptionalPostgresArrayEncodableCollection { + static var psqlArrayType: PostgresDataType { get } +} +extension Array: OptionalPostgresArrayEncodableCollection where Self.Element: OptionalType, Self.Element.Wrapped: PostgresArrayEncodable { + static var psqlArrayType: PostgresDataType { Self.Element.Wrapped.psqlArrayType } +} + /// Sidestep problems with URL coding behavior by making it conform directly to Postgres coding. extension URL { public static var psqlType: PostgresDataType { @@ -192,18 +202,18 @@ struct PostgresDataTranslation { if (value as Optional) == nil { bindings.appendNull() } - /// Preferred modern fast-path: Direct conformance to the `PostgresEncodable` family. + /// Preferred fast-path: Direct conformance to the `PostgresEncodable` family. else if let fastPathValue = value as? any PostgresThrowingDynamicTypeEncodable { try bindings.append(fastPathValue, context: context) } /// Legacy "fast"-path: Direct conformance to `PostgresDataConvertible`; use is deprecated. else if let legacyPathValue = value as? any PostgresDataTranslation.PostgresLegacyDataConvertible { guard let legacyData = legacyPathValue.postgresData else { - throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Couldn't get PSQL encoding from value '\(value)' of Swift type \(T.self)/\(type(of: value))")) + throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Couldn't get PSQL encoding from value '\(value)' of Swift type \(type(of: value))")) } bindings.append(legacyData) } - /// Slow path: Descend through the `Encodable` machinery until we fail or find something we can convert. + /// Slow path: Descend through the `Encodable` machinery. else { try bindings.append(self.encode(codingPath: [], userInfo: [:], value: value, in: context, file: file, line: line)) } @@ -217,14 +227,18 @@ struct PostgresDataTranslation { file: String, line: Int ) throws -> PostgresData { - // TODO: Avoid repeating the conformance checks here, or at the very least only repeat them after a second level of nesting... - if let fastPathValue = value as? any PostgresEncodable { + /// Nil bypass-path: Skip the entire machinery for nil optionals. + if (value as Optional) == nil { + return .null + } + /// Preferred fast-path: Direct conformance to the `PostgresEncodable` family. + else if let fastPathValue = value as? any PostgresThrowingDynamicTypeEncodable { var buffer = ByteBuffer() try fastPathValue.encode(into: &buffer, context: context) - return PostgresData(type: type(of: fastPathValue).psqlType, typeModifier: nil, formatCode: type(of: fastPathValue).psqlFormat, value: buffer) + return PostgresData(type: fastPathValue.psqlType, typeModifier: nil, formatCode: fastPathValue.psqlFormat, value: buffer) } else if let legacyPathValue = value as? any PostgresDataTranslation.PostgresLegacyDataConvertible { guard let legacyData = legacyPathValue.postgresData else { - throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Couldn't get PSQL encoding from value '\(value)' of Swift type \(T.self)/\(type(of: value))")) + throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Couldn't get PSQL encoding from value '\(value)' of Swift type \(type(of: value))")) } return legacyData } @@ -233,12 +247,19 @@ struct PostgresDataTranslation { let encoder = ArrayAwareBoxWrappingPostgresEncoder(codingPath: codingPath, userInfo: userInfo, context: context, file: file, line: line) try value.encode(to: encoder) switch encoder.value { - case .invalid: throw ArrayAwareBoxWrappingPostgresEncoder.FallbackSentinel() - case .scalar(let scalar): return scalar + case .invalid: + throw ArrayAwareBoxWrappingPostgresEncoder.FallbackSentinel() + case .scalar(let scalar): + return scalar case .indexed(let ref): - let elementType = (ref.contents.first)??.type ?? .jsonb - assert(ref.contents.allSatisfy { $0.map { $0.type == elementType } ?? true }, "Type \(T.self)/\(type(of: value)) was encoded as a heterogenous array; this is unsupported.") - return PostgresData(array: ref.contents, elementType: elementType) + let contents = ref.contents.map { $0.type == .null ? nil : $0 } + let elementType = (T.self as? any OptionalPostgresArrayEncodableCollection.Type)?.psqlArrayType.psqlkit_elementType ?? (contents.first)??.type ?? .jsonb + + assert( + contents.allSatisfy { $0.map { $0.type == elementType } ?? true }, + "Type \(type(of: value)) at \(codingPath.map(\.description).joined(separator: ".")) contains heterogenous elements; this is unsupported." + ) + return PostgresData(array: contents, elementType: elementType) } } catch is ArrayAwareBoxWrappingPostgresEncoder.FallbackSentinel { /// Glacial path: Fall back to encoding directly to JSON. @@ -295,7 +316,7 @@ private final class ArrayAwareBoxUwrappingDecoder(_: T.Type) throws -> T { try PostgresDataTranslation.decode( - codingPath: self.codingPath + [PostgresKit.SomeCodingKey(stringValue: "(Unwrapping(\(T0.self)))")], userInfo: self.userInfo, + codingPath: self.codingPath + [SomeCodingKey(stringValue: "(Unwrapping(\(T0.self)))")], userInfo: self.userInfo, T.self, from: self.cell, in: self.context, file: self.file, line: self.line ) } @@ -339,9 +360,9 @@ private final class ArrayAwareBoxWrappingPostgresEncoder final class ArrayRef { var contents: [T] = [] } case invalid - case indexed(ArrayRef) + case indexed(ArrayRef) case scalar(PostgresData) - + var isValid: Bool { if case .invalid = self { return false }; return true } mutating func store(scalar: PostgresData) { @@ -362,7 +383,7 @@ private final class ArrayAwareBoxWrappingPostgresEncoder else { preconditionFailure("Internal error in encoder (requested indexed count from non-indexed state)") } } - mutating func store(indexedScalar: PostgresData?) { + mutating func store(indexedScalar: PostgresData) { if case .indexed(let ref) = self { ref.contents.append(indexedScalar) } else { preconditionFailure("Internal error in encoder (attempted store to indexed in non-indexed state)") } } @@ -402,10 +423,10 @@ private final class ArrayAwareBoxWrappingPostgresEncoder let encoder: ArrayAwareBoxWrappingPostgresEncoder var codingPath: [any CodingKey] { self.encoder.codingPath } var count: Int { self.encoder.value.indexedCount } - mutating func encodeNil() throws { self.encoder.value.store(indexedScalar: nil) } + mutating func encodeNil() throws { self.encoder.value.store(indexedScalar: .null) } mutating func encode(_ value: T) throws { self.encoder.value.store(indexedScalar: try PostgresDataTranslation.encode( - codingPath: self.codingPath + [PostgresKit.SomeCodingKey(intValue: self.count)], userInfo: self.encoder.userInfo, + codingPath: self.codingPath + [SomeCodingKey(intValue: self.count)], userInfo: self.encoder.userInfo, value: value, in: self.encoder.context, file: self.encoder.file, line: self.encoder.line )) @@ -413,7 +434,7 @@ private final class ArrayAwareBoxWrappingPostgresEncoder mutating func nestedContainer(keyedBy: K.Type) -> KeyedEncodingContainer { self.superEncoder().container(keyedBy: K.self) } mutating func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { self.superEncoder().unkeyedContainer() } mutating func superEncoder() -> any Encoder { ArrayAwareBoxWrappingPostgresEncoder( - codingPath: self.codingPath + [PostgresKit.SomeCodingKey(intValue: self.count)], userInfo: self.encoder.userInfo, + codingPath: self.codingPath + [SomeCodingKey(intValue: self.count)], userInfo: self.encoder.userInfo, context: self.encoder.context, file: self.encoder.file, line: self.encoder.line, value: self.encoder.value @@ -432,7 +453,7 @@ private final class ArrayAwareBoxWrappingPostgresEncoder /// This is a workaround for the inability of encoders to throw errors in various places. It's still better than fatalError()ing. struct FailureEncoder: Encoder, KeyedEncodingContainerProtocol, UnkeyedEncodingContainer, SingleValueEncodingContainer { let codingPath = [any CodingKey](), userInfo = [CodingUserInfoKey: Any](), count = 0 - init() {}; init() where K == PostgresKit.SomeCodingKey {} + init() {}; init() where K == SomeCodingKey {} func encodeNil() throws { throw FallbackSentinel() } func encodeNil(forKey: K) throws { throw FallbackSentinel() } func encode(_: T) throws { throw FallbackSentinel() } @@ -448,3 +469,37 @@ private final class ArrayAwareBoxWrappingPostgresEncoder func singleValueContainer() -> any SingleValueEncodingContainer { self } } } + +// Taken from PostgresNIO 1.33.0, whuich does not make this useful data public. +extension PostgresDataType { + var psqlkit_elementType: PostgresDataType? { + switch self { + case .xmlArray: .xml case .jsonArray: .json case .xid8Array: .xid8 + case .lineArray: .line case .cidrArray: .cidr case .circleArray: .circle + case .macaddr8Array: .macaddr8 case .moneyArray: .money case .int2vectorArray: .int2vector + case .regprocArray: .regproc case .tidArray: .tid case .xidArray: .xid + case .cidArray: .cid case .oidvectorArray: .oidvector case .bpcharArray: .bpchar + case .lsegArray: .lseg case .pathArray: .path case .boxArray: .box + case .polygonArray: .polygon case .oidArray: .oid case .aclitemArray: .aclitem + case .macaddrArray: .macaddr case .inetArray: .inet case .timestampArray: .timestamp + case .dateArray: .date case .timeArray: .time case .timestamptzArray: .timestamptz + case .intervalArray: .interval case .numericArray: .numeric case .cstringArray: .cstring + case .timetzArray: .timetz case .bitArray: .bit case .varbitArray: .varbit + case .refcursorArray: .refcursor case .regprocedureArray: .regprocedure case .regoperArray: .regoper + case .regoperatorArray: .regoperator case .regclassArray: .regclass case .regtypeArray: .regtype + case .recordArray: .record case .pgLSNArray: .pgLSN case .tsvectorArray: .tsvector + case .gtsvectorArray: .gtsvector case .tsqueryArray: .tsquery case .regconfigArray: .regconfig + case .regdictionaryArray: .regdictionary case .numrangeArray: .numrange case .tsrangeArray: .tsrange + case .tstzrangeArray: .tstzrange case .daterangeArray: .daterange case .jsonpathArray: .jsonpath + case .regnamespaceArray: .regnamespace case .regroleArray: .regrole case .regcollationArray: .regcollation + case .int4multirangeArray: .int4multirange case .tsmultirangeArray: .tsmultirange case .tstzmultirangeArray: .tstzmultirange + case .datemultirangeArray: .datemultirange case .int8multirangeArray: .int8multirange case .boolArray: .bool + case .byteaArray: .bytea case .charArray: .char case .nameArray: .name + case .int2Array: .int2 case .int4Array: .int4 case .int8Array: .int8 + case .pointArray: .point case .float4Array: .float4 case .float8Array: .float8 + case .uuidArray: .uuid case .jsonbArray: .jsonb case .textArray: .text + case .varcharArray: .varchar case .int4RangeArray: .int4Range case .int8RangeArray: .int8Range + default: nil + } + } +} diff --git a/Tests/PostgresKitTests/PostgresKitTests.swift b/Tests/PostgresKitTests/PostgresKitTests.swift index 13ae668..2b49f19 100644 --- a/Tests/PostgresKitTests/PostgresKitTests.swift +++ b/Tests/PostgresKitTests/PostgresKitTests.swift @@ -25,14 +25,11 @@ struct PostgresKitTests { @Test func leak() async throws { struct Foo: Codable { - var id: String - var description: String? - var latitude: Double - var longitude: Double - var created_by: String - var created_at: Date - var modified_by: String - var modified_at: Date + let id: String + let description: String? + let latitude: Double, longitude: Double + let created_by: String, created_at: Date + let modified_by: String, modified_at: Date } let conn = try await PostgresConnection.test(on: self.eventLoop) @@ -46,28 +43,23 @@ struct PostgresKitTests { .column("latitude", type: .custom(SQLRaw("DOUBLE PRECISION"))) .column("longitude", type: .custom(SQLRaw("DOUBLE PRECISION"))) .column("created_by", type: .text) - .column("created_at", type: .custom(SQLRaw("TIMESTAMPTZ"))) + .column("created_at", type: .timestamp) .column("modified_by", type: .text) - .column("modified_at", type: .custom(SQLRaw("TIMESTAMPTZ"))) + .column("modified_at", type: .timestamp) .run() - for i in 0..<5_000 { + for i in 0..<2_000 { let zipcode = Foo( id: UUID().uuidString, description: "test \(i)", - latitude: Double.random(in: 0...100), - longitude: Double.random(in: 0...100), - created_by: "test", - created_at: Date(), - modified_by: "test", - modified_at: Date() + latitude: .random(in: 0...100), longitude: .random(in: 0...100), + created_by: "test", created_at: .now, + modified_by: "test", modified_at: .now ) - try await db.insert(into: "foos") - .model(zipcode) - .run() + try await db.insert(into: "foos").model(zipcode).run() } } - try? await db.raw("DROP TABLE IF EXISTS \(ident: "foos")").run() + try? await db.drop(table: "foos").ifExists().run() try await conn.close() } @@ -251,27 +243,29 @@ struct PostgresKitTests { @Test func encodingArraysContainingNilValues() async throws { let encoded1 = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: [-1, nil, nil, nil] as [Int?], in: .default, file: #fileID, line: #line) - #expect(encoded1.type == .int8Array) - #expect(encoded1.array?.count == 4) - #expect(encoded1.array?.dropFirst(0).first?.type == .int8) - #expect(encoded1.array?.dropFirst(0).first?.int == -1) - #expect(encoded1.array?.dropFirst(1).first?.type == .int8) - #expect(encoded1.array?.dropFirst(1).first?.value == nil) - #expect(encoded1.array?.dropFirst(2).first?.type == .int8) - #expect(encoded1.array?.dropFirst(2).first?.value == nil) - #expect(encoded1.array?.dropFirst(3).first?.type == .int8) - #expect(encoded1.array?.dropFirst(3).first?.value == nil) + #expect(encoded1.type == .int8Array && encoded1.array?.count == 4) + #expect(encoded1.array?.dropFirst(0).first?.type == .int8 && encoded1.array?.dropFirst(0).first?.int == -1) + #expect(encoded1.array?.dropFirst(1).first?.type == .int8 && encoded1.array?.dropFirst(1).first?.value == nil) + #expect(encoded1.array?.dropFirst(2).first?.type == .int8 && encoded1.array?.dropFirst(2).first?.value == nil) + #expect(encoded1.array?.dropFirst(3).first?.type == .int8 && encoded1.array?.dropFirst(3).first?.value == nil) let encoded2 = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: [nil, nil, nil, nil] as [Int?], in: .default, file: #fileID, line: #line) - #expect(encoded2.type == .int8Array) - #expect(encoded2.array?.count == 4) - #expect(encoded2.array?.dropFirst(0).first?.type == .int8) - #expect(encoded2.array?.dropFirst(0).first?.value == nil) - #expect(encoded2.array?.dropFirst(1).first?.type == .int8) - #expect(encoded2.array?.dropFirst(1).first?.value == nil) - #expect(encoded2.array?.dropFirst(2).first?.type == .int8) - #expect(encoded2.array?.dropFirst(2).first?.value == nil) - #expect(encoded2.array?.dropFirst(3).first?.type == .int8) - #expect(encoded2.array?.dropFirst(3).first?.value == nil) + #expect(encoded2.type == .int8Array && encoded2.array?.count == 4) + #expect(encoded2.array?.dropFirst(0).first?.type == .int8 && encoded2.array?.dropFirst(0).first?.value == nil) + #expect(encoded2.array?.dropFirst(1).first?.type == .int8 && encoded2.array?.dropFirst(1).first?.value == nil) + #expect(encoded2.array?.dropFirst(2).first?.type == .int8 && encoded2.array?.dropFirst(2).first?.value == nil) + #expect(encoded2.array?.dropFirst(3).first?.type == .int8 && encoded2.array?.dropFirst(3).first?.value == nil) + let encoded3 = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: [.one, nil, nil, nil] as [Bar?], in: .default, file: #fileID, line: #line) + #expect(encoded3.type == .int8Array && encoded3.array?.count == 4) + #expect(encoded3.array?.dropFirst(0).first?.type == .int8 && encoded3.array?.dropFirst(0).first?.int == 0) + #expect(encoded3.array?.dropFirst(1).first?.type == .int8 && encoded3.array?.dropFirst(1).first?.value == nil) + #expect(encoded3.array?.dropFirst(2).first?.type == .int8 && encoded3.array?.dropFirst(2).first?.value == nil) + #expect(encoded3.array?.dropFirst(3).first?.type == .int8 && encoded3.array?.dropFirst(3).first?.value == nil) + let encoded4 = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: [nil, nil, nil, nil] as [Bar?], in: .default, file: #fileID, line: #line) + #expect(encoded4.type == .int8Array && encoded4.array?.count == 4) + #expect(encoded4.array?.dropFirst(0).first?.type == .int8 && encoded4.array?.dropFirst(0).first?.value == nil) + #expect(encoded4.array?.dropFirst(1).first?.type == .int8 && encoded4.array?.dropFirst(1).first?.value == nil) + #expect(encoded4.array?.dropFirst(2).first?.type == .int8 && encoded4.array?.dropFirst(2).first?.value == nil) + #expect(encoded4.array?.dropFirst(3).first?.type == .int8 && encoded4.array?.dropFirst(3).first?.value == nil) let connection = try await PostgresConnection.test(on: self.eventLoop) @@ -281,9 +275,12 @@ struct PostgresKitTests { try await sql.withSession { db in _ = try await db.create(table: "foo").column("bar", type: .custom(SQLRaw("bigint[]")), .notNull).run() _ = try await db.insert(into: "foo").columns("bar").values(SQLBind([-1, nil, nil, nil] as [Int?])).values(SQLBind([nil, nil, nil, nil] as [Int?])).run() + _ = try await db.insert(into: "foo").columns("bar").values(SQLBind([.one, nil, nil, nil] as [Bar?])).values(SQLBind([nil, nil, nil, nil] as [Bar?])).run() let rows = try await db.select().column("bar").from("foo").all(decodingColumn: "bar", as: [Int?].self) #expect(rows.dropFirst(0).first == [-1, nil, nil, nil]) #expect(rows.dropFirst(1).first == [nil, nil, nil, nil]) + #expect(rows.dropFirst(2).first == [0, nil, nil, nil]) + #expect(rows.dropFirst(3).first == [nil, nil, nil, nil]) } } try await connection.close()