Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4e04815
feat(plugin-qdrant): add a Qdrant driver over the REST API
J2TeamNNL Nov 1, 2026
3526d12
docs(changelog): add the Qdrant driver entry
J2TeamNNL Nov 1, 2026
6937ba5
chore(l10n): add the Qdrant tagline to the catalog
J2TeamNNL Nov 1, 2026
c7b17f6
Merge origin/main into feat/plugin-qdrant
datlechin Nov 1, 2026
3d4c78f
Merge remote-tracking branch 'origin/main' into pr-3972-qdrant
datlechin Nov 1, 2026
20a58ff
refactor(plugin-qdrant): type writes from the grid, fix filters, sort…
datlechin Nov 1, 2026
339937c
fix(plugin-qdrant): keep indexed lists as lists and refuse sorts Qdra…
datlechin Nov 1, 2026
79605dc
fix(plugin-qdrant): insert without overwriting, widen types per page,…
datlechin Nov 1, 2026
57df5b9
fix(plugin-qdrant): new id for duplicated rows, list element types in…
datlechin Nov 1, 2026
de7428c
fix(plugin-qdrant): count with the page driver's types, type console …
datlechin Nov 1, 2026
b37d152
refactor(plugin-qdrant): type filters by payload index only, recheck …
datlechin Nov 1, 2026
a889fcb
fix(plugin-qdrant): quote text in json cells, exact large integers, i…
datlechin Nov 1, 2026
a179783
refactor(plugin-qdrant): type cells by stored values, not the payload…
datlechin Nov 1, 2026
f61f63d
fix(plugin-qdrant): revalidate cursors per filtered list, let contain…
datlechin Nov 1, 2026
d695491
fix(plugin-qdrant): keep id, score and vectors out of payload field l…
datlechin Nov 1, 2026
09824a9
fix(plugin-qdrant): write integers in float columns exactly
datlechin Nov 1, 2026
dda9941
refactor(plugin-qdrant): page with a server-side offset instead of ca…
datlechin Nov 1, 2026
882a48a
fix(plugin-qdrant): check an insert landed, filter off-type values un…
datlechin Nov 1, 2026
80fc271
refactor(plugin-qdrant): filters match every spelling regardless of i…
datlechin Nov 1, 2026
1952884
fix(plugin-qdrant): check an insert by payload and vector names, not …
datlechin Nov 1, 2026
2058374
fix(plugin-qdrant): refuse range bounds a float cannot hold, reserve …
datlechin Nov 1, 2026
b48dd47
fix(plugin-qdrant): rank payload overwrite, cluster and alias request…
datlechin Nov 1, 2026
615cd2f
Merge remote-tracking branch 'origin/main' into pr-3972-qdrant
datlechin Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/plugin-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@
"ParquetExport": {
"name": "Parquet Export"
},
"QdrantDriverPlugin": {
"name": "Qdrant Driver"
},
"SnowflakeDriverPlugin": {
"name": "Snowflake Driver"
},
Expand Down
15 changes: 14 additions & 1 deletion apple/Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ let package = Package(
.library(name: "TableProGoogleCloud", targets: ["TableProGoogleCloud"]),
.library(name: "TableProSpannerCore", targets: ["TableProSpannerCore"]),
.library(name: "TableProWeaviateCore", targets: ["TableProWeaviateCore"]),
.library(name: "TableProQdrantCore", targets: ["TableProQdrantCore"]),
.library(name: "TableProInfluxDBCore", targets: ["TableProInfluxDBCore"]),
.library(name: "TableProNeo4jCore", targets: ["TableProNeo4jCore"]),
.library(name: "TableProFirebaseCore", targets: ["TableProFirebaseCore"]),
Expand Down Expand Up @@ -156,7 +157,7 @@ let package = Package(
),
.target(
name: "TableProTrinoCore",
dependencies: ["TableProSQLGrammar"],
dependencies: ["TableProSQLGrammar", "TableProTLSClientIdentity"],
path: "Sources/TableProTrinoCore"
),
.target(
Expand Down Expand Up @@ -195,6 +196,12 @@ let package = Package(
dependencies: [],
path: "Sources/TableProWeaviateCore"
),
.target(
name: "TableProQdrantCore",
dependencies: [],
path: "Sources/TableProQdrantCore",
swiftSettings: pluginCoreSettings
),
.target(
name: "TableProInfluxDBCore",
dependencies: [],
Expand Down Expand Up @@ -319,6 +326,12 @@ let package = Package(
dependencies: ["TableProTeradataCore"],
path: "Tests/TableProTeradataCoreTests"
),
.testTarget(
name: "TableProQdrantCoreTests",
dependencies: ["TableProQdrantCore"],
path: "Tests/TableProQdrantCoreTests",
swiftSettings: pluginCoreSettings
),
.testTarget(
name: "TableProInfluxDBCoreTests",
dependencies: ["TableProInfluxDBCore"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public enum SQLDDLFallbackPolicy {
"Elasticsearch",
"Typesense",
"Weaviate",
"Qdrant",
"Memcached",
"Kafka",
"etcd",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
public static let kafka = DatabaseType(rawValue: "Kafka")
public static let cloudflareR2SQL = DatabaseType(rawValue: "Cloudflare R2 SQL")
public static let weaviate = DatabaseType(rawValue: "Weaviate")
public static let qdrant = DatabaseType(rawValue: "Qdrant")
public static let memcached = DatabaseType(rawValue: "Memcached")
public static let firestore = DatabaseType(rawValue: "Firestore")
public static let firebaseRealtimeDatabase = DatabaseType(rawValue: "Firebase Realtime Database")
Expand All @@ -52,7 +53,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
.mysql, .mariadb, .tidb, .databend, .oceanbase, .starrocks, .postgresql, .sqlite, .redis, .mongodb,
.clickhouse, .mssql, .oracle, .dameng, .duckdb, .cassandra, .redshift,
.etcd, .cloudflareD1, .dynamodb, .bigquery, .spanner, .snowflake, .libsql, .beancount,
.surrealdb, .teradata, .trino, .kafka, .cloudflareR2SQL, .weaviate, .memcached, .firestore, .firebaseRealtimeDatabase, .bigtable
.surrealdb, .teradata, .trino, .kafka, .cloudflareR2SQL, .weaviate, .qdrant, .memcached, .firestore, .firebaseRealtimeDatabase, .bigtable
]

/// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback
Expand Down Expand Up @@ -90,6 +91,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
case .kafka: return "kafka-icon"
case .cloudflareR2SQL: return "cloudflare-r2-sql-icon"
case .weaviate: return "weaviate-icon"
case .qdrant: return "qdrant-icon"
case .memcached: return "memorychip"
case .firestore, .firebaseRealtimeDatabase: return "firebase-icon"
case .bigtable: return "bigtable-icon"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Foundation

public struct QdrantSortSpec: Sendable, Equatable {
public let column: String
public let ascending: Bool

public init(column: String, ascending: Bool) {
self.column = column
self.ascending = ascending
}
}

public struct QdrantBrowseRequest: Sendable, Equatable {
public let collection: String
public let offset: Int
public let limit: Int
public let sorts: [QdrantSortSpec]
public let filters: [QdrantFilterSpec]
public let logicMode: String

public init(
collection: String,
offset: Int,
limit: Int,
sorts: [QdrantSortSpec],
filters: [QdrantFilterSpec],
logicMode: String
) {
self.collection = collection
self.offset = max(offset, 0)
self.limit = max(limit, 1)
self.sorts = sorts
self.filters = filters
self.logicMode = logicMode
}
}

// The page is built before the driver knows the field types the filter JSON depends on, so it travels
// from `buildFilteredQuery` to `execute` as tagged text.
public enum QdrantBrowseQuery {
public static let tag = "QDRANT_BROWSE:"

public static func encode(_ request: QdrantBrowseRequest) -> String {
let payload: [String: Any] = [
"collection": request.collection,
"offset": request.offset,
"limit": request.limit,
"logicMode": request.logicMode,
"sorts": request.sorts.map { ["column": $0.column, "ascending": $0.ascending] },
"filters": request.filters.map { filter -> [String: Any] in
var encoded: [String: Any] = ["column": filter.column, "op": filter.op, "value": filter.value]
if let secondValue = filter.secondValue {
encoded["secondValue"] = secondValue
}
return encoded
}
]
let body = (try? QdrantJSON.data(payload)) ?? Data()
return tag + body.base64EncodedString()
}

public static func parse(_ query: String) -> QdrantBrowseRequest? {
guard query.hasPrefix(tag),
let data = Data(base64Encoded: String(query.dropFirst(tag.count))),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let collection = json["collection"] as? String
else { return nil }
let sorts = (json["sorts"] as? [[String: Any]] ?? []).compactMap { item -> QdrantSortSpec? in
guard let column = item["column"] as? String else { return nil }
return QdrantSortSpec(column: column, ascending: item["ascending"] as? Bool ?? true)
}
let filters = (json["filters"] as? [[String: Any]] ?? []).compactMap { item -> QdrantFilterSpec? in
guard let column = item["column"] as? String, let op = item["op"] as? String else { return nil }
return QdrantFilterSpec(
column: column,
op: op,
value: item["value"] as? String ?? "",
secondValue: item["secondValue"] as? String
)
}
return QdrantBrowseRequest(
collection: collection,
offset: json["offset"] as? Int ?? 0,
limit: json["limit"] as? Int ?? 100,
sorts: sorts,
filters: filters,
logicMode: json["logicMode"] as? String ?? "AND"
)
}
}

public enum QdrantSortPlan: Sendable, Equatable {
case byID
case byPayload(column: String, key: String, ascending: Bool)

public static func resolve(_ sorts: [QdrantSortSpec]) throws -> QdrantSortPlan {
guard let sort = sorts.first else { return .byID }
guard sorts.count == 1 else {
throw QdrantError.unsupported(String(localized: "Qdrant sorts by one field at a time. Remove the sort from the other columns."))
}
if sort.column == QdrantSchema.idColumn {
guard sort.ascending else {
throw QdrantError.unsupported(String(localized: "Qdrant lists points by id in ascending order only."))
}
return .byID
}
guard !QdrantSchema.isVectorColumn(sort.column), sort.column != QdrantSchema.scoreColumn else {
throw QdrantError.unsupported(String(format: String(localized: "Qdrant cannot sort by %@."), sort.column))
}
guard let key = QdrantKeyPath.quoted(QdrantSchema.payloadKey(forColumn: sort.column)) else {
throw QdrantFilterError.keyNotAddressable(column: sort.column)
}
return .byPayload(column: sort.column, key: key, ascending: sort.ascending)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation

public enum QdrantFieldID {
public static let apiKey = "qdApiKey"
}

public struct QdrantConnectionSettings: Sendable, Equatable {
public static let defaultPort = 6_333

public let host: String
public let port: Int
public let usesTLS: Bool
public let apiKey: String

public init(host: String, port: Int, usesTLS: Bool, apiKey: String) {
self.host = host.trimmingCharacters(in: .whitespacesAndNewlines)
self.port = port
self.usesTLS = usesTLS
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
}

public static func parse(
host: String,
port: Int,
usesTLS: Bool,
fields: [String: String]
) throws -> QdrantConnectionSettings {
let resolvedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
let settings = QdrantConnectionSettings(
host: resolvedHost.isEmpty ? "localhost" : resolvedHost,
port: port > 0 ? port : defaultPort,
usesTLS: usesTLS,
apiKey: fields[QdrantFieldID.apiKey] ?? ""
)
_ = try settings.baseURL()
return settings
}

public var headers: [String: String] {
apiKey.isEmpty ? [:] : ["api-key": apiKey]
}

public func baseURL() throws -> URL {
var components = URLComponents()
components.scheme = usesTLS ? "https" : "http"
components.host = host
components.port = port
guard let url = components.url else {
throw QdrantError.configuration(String(localized: "The host is not valid in a URL."))
}
return url
}
}
Loading
Loading