diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index a246abf3..04f789be 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -57,22 +57,27 @@ jobs: TARGET="OracleDriver"; BUNDLE_ID="com.TablePro.OracleDriver" DISPLAY_NAME="Oracle Driver"; SUMMARY="Oracle Database 12c+ driver via OracleNIO" DB_TYPE_IDS='["Oracle"]'; ICON="server.rack"; BUNDLE_NAME="OracleDriver" - HOMEPAGE="https://tablepro.app/databases/oracle" ;; + HOMEPAGE="https://docs.tablepro.app/databases/oracle" ;; clickhouse) TARGET="ClickHouseDriver"; BUNDLE_ID="com.TablePro.ClickHouseDriver" DISPLAY_NAME="ClickHouse Driver"; SUMMARY="ClickHouse OLAP database driver via HTTP interface" DB_TYPE_IDS='["ClickHouse"]'; ICON="chart.bar.xaxis"; BUNDLE_NAME="ClickHouseDriver" - HOMEPAGE="https://tablepro.app/databases/clickhouse" ;; + HOMEPAGE="https://docs.tablepro.app/databases/clickhouse" ;; sqlite) TARGET="SQLiteDriver"; BUNDLE_ID="com.TablePro.SQLiteDriver" DISPLAY_NAME="SQLite Driver"; SUMMARY="SQLite embedded database driver" DB_TYPE_IDS='["SQLite"]'; ICON="internaldrive"; BUNDLE_NAME="SQLiteDriver" - HOMEPAGE="https://tablepro.app" ;; + HOMEPAGE="https://docs.tablepro.app/databases/sqlite" ;; duckdb) TARGET="DuckDBDriver"; BUNDLE_ID="com.TablePro.DuckDBDriver" DISPLAY_NAME="DuckDB Driver"; SUMMARY="DuckDB analytical database driver" DB_TYPE_IDS='["DuckDB"]'; ICON="bird"; BUNDLE_NAME="DuckDBDriver" - HOMEPAGE="https://tablepro.app" ;; + HOMEPAGE="https://docs.tablepro.app/databases/duckdb" ;; + cassandra) + TARGET="CassandraDriver"; BUNDLE_ID="com.TablePro.CassandraDriver" + DISPLAY_NAME="Cassandra Driver"; SUMMARY="Apache Cassandra and ScyllaDB driver via DataStax C driver" + DB_TYPE_IDS='["Cassandra", "ScyllaDB"]'; ICON="cassandra-icon"; BUNDLE_NAME="CassandraDriver" + HOMEPAGE="https://docs.tablepro.app/databases/cassandra" ;; *) echo "Unknown plugin: $plugin_name"; return 1 ;; esac } @@ -91,6 +96,11 @@ jobs: echo "Building $TARGET v$VERSION" + # Build Cassandra dependencies if needed + if [ "$PLUGIN_NAME" = "cassandra" ]; then + ./scripts/build-cassandra.sh both + fi + # Build both architectures ./scripts/build-plugin.sh "$TARGET" arm64 ./scripts/build-plugin.sh "$TARGET" x86_64 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f1d95b6..116a3c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pre-connect script: run a shell command before each connection (e.g., to refresh credentials or update ~/.pgpass) - `ParameterStyle` enum in TableProPluginKit: plugins declare `?` or `$1` placeholder style via `parameterStyle` property on `PluginDatabaseDriver` - DML statement generation in ClickHouse, MSSQL, and Oracle plugins via `generateStatements()` for database-specific UPDATE/DELETE syntax +- Cassandra and ScyllaDB database support via DataStax C driver (downloadable plugin) - `quoteIdentifier` method on `PluginDatabaseDriver` and `DatabaseDriver` protocols: plugins provide database-specific identifier quoting (backticks for MySQL/SQLite/ClickHouse, brackets for MSSQL, double-quotes for PostgreSQL/Oracle/DuckDB, passthrough for MongoDB/Redis) ### Changed diff --git a/Plugins/CassandraDriverPlugin/CCassandra/CCassandra.h b/Plugins/CassandraDriverPlugin/CCassandra/CCassandra.h new file mode 100644 index 00000000..879ff621 --- /dev/null +++ b/Plugins/CassandraDriverPlugin/CCassandra/CCassandra.h @@ -0,0 +1,14 @@ +// +// CCassandra.h +// TablePro +// +// C bridging header for the DataStax Cassandra C driver. +// Headers are bundled in the include/ subdirectory. +// + +#ifndef CCassandra_h +#define CCassandra_h + +#include "include/cassandra.h" + +#endif /* CCassandra_h */ diff --git a/Plugins/CassandraDriverPlugin/CCassandra/module.modulemap b/Plugins/CassandraDriverPlugin/CCassandra/module.modulemap new file mode 100644 index 00000000..18c49082 --- /dev/null +++ b/Plugins/CassandraDriverPlugin/CCassandra/module.modulemap @@ -0,0 +1,4 @@ +module CCassandra [system] { + header "CCassandra.h" + export * +} diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift new file mode 100644 index 00000000..9231e1f4 --- /dev/null +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -0,0 +1,1190 @@ +// +// CassandraPlugin.swift +// TablePro +// +// Cassandra/ScyllaDB database driver plugin using the DataStax C driver. +// Provides CQL query execution and schema introspection via system_schema tables. +// + +#if canImport(CCassandra) +import CCassandra +#endif +import Foundation +import os +import TableProPluginKit + +// MARK: - Plugin Entry Point + +internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Cassandra Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Apache Cassandra and ScyllaDB support via DataStax C driver" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Cassandra" + static let databaseDisplayName = "Cassandra / ScyllaDB" + static let iconName = "cassandra-icon" + static let defaultPort = 9042 + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "sslCaCertPath", + label: "CA Certificate", + placeholder: "/path/to/ca-cert.pem", + section: .advanced + ), + ] + static let additionalDatabaseTypeIds: [String] = ["ScyllaDB"] + + // MARK: - UI/Capability Metadata + + static let urlSchemes: [String] = ["cassandra", "cql", "scylladb", "scylla"] + static let requiresAuthentication = false + static let supportsForeignKeys = false + static let brandColorHex = "#26A0D8" + static let queryLanguageName = "CQL" + static let supportsDatabaseSwitching = true + static let databaseGroupingStrategy: GroupingStrategy = .byDatabase + static let defaultGroupName = "default" + static let systemDatabaseNames: [String] = [ + "system", "system_schema", "system_auth", + "system_distributed", "system_traces", "system_virtual_schema", + ] + static let supportsImport = false + static let supportsExport = true + static let supportsCascadeDrop = false + static let supportsForeignKeyDisable = false + static let supportsSSH = true + static let supportsSSL = true + static let columnTypesByCategory: [String: [String]] = [ + "Numeric": ["TINYINT", "SMALLINT", "INT", "BIGINT", "VARINT", "FLOAT", "DOUBLE", "DECIMAL", "COUNTER"], + "String": ["TEXT", "VARCHAR", "ASCII"], + "Date": ["TIMESTAMP", "DATE", "TIME"], + "Binary": ["BLOB"], + "Boolean": ["BOOLEAN"], + "Other": ["UUID", "TIMEUUID", "INET", "LIST", "SET", "MAP", "TUPLE", "FROZEN"], + ] + + static var sqlDialect: SQLDialectDescriptor? { + SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "AS", + "ORDER", "BY", "LIMIT", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", + "PRIMARY", "KEY", "ADD", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", + "CASE", "WHEN", "THEN", "ELSE", "END", + "KEYSPACE", "USE", "TRUNCATE", "BATCH", "GRANT", "REVOKE", + "CLUSTERING", "PARTITION", "TTL", "WRITETIME", + "ALLOW FILTERING", "IF NOT EXISTS", "IF EXISTS", + "USING TIMESTAMP", "USING TTL", + "MATERIALIZED VIEW", "CONTAINS", "FROZEN", "COUNTER", "TOKEN", + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", + "NOW", "UUID", "TOTIMESTAMP", "TOKEN", "TTL", "WRITETIME", + "MINTIMEUUID", "MAXTIMEUUID", "TODATE", "TOUNIXTIMESTAMP", + "CAST", + ], + dataTypes: [ + "TEXT", "VARCHAR", "ASCII", + "INT", "BIGINT", "SMALLINT", "TINYINT", "VARINT", + "FLOAT", "DOUBLE", "DECIMAL", + "BOOLEAN", "UUID", "TIMEUUID", + "TIMESTAMP", "DATE", "TIME", + "BLOB", "INET", "COUNTER", + "LIST", "SET", "MAP", "TUPLE", "FROZEN", + ], + regexSyntax: .unsupported, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit, + autoLimitStyle: .limit + ) + } + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + CassandraPluginDriver(config: config) + } +} + +// MARK: - Connection Actor + +private actor CassandraConnectionActor { + private static let logger = Logger(subsystem: "com.TablePro.CassandraDriver", category: "Connection") + + nonisolated(unsafe) private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + nonisolated(unsafe) private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = TimeZone(identifier: "UTC") + return f + }() + + private var cluster: OpaquePointer? // CassCluster* + private var session: OpaquePointer? // CassSession* + private var currentKeyspace: String? + + var isConnected: Bool { session != nil } + + var keyspace: String? { currentKeyspace } + + func connect( + host: String, + port: Int, + username: String?, + password: String?, + keyspace: String?, + sslMode: String, + sslCaCertPath: String? + ) throws { + cluster = cass_cluster_new() + guard let cluster else { + throw CassandraPluginError.connectionFailed("Failed to create cluster object") + } + + cass_cluster_set_contact_points(cluster, host) + cass_cluster_set_port(cluster, Int32(port)) + + if let username, !username.isEmpty, let password { + cass_cluster_set_credentials(cluster, username, password) + } + + // SSL/TLS + if sslMode != "Disabled" { + guard let ssl = cass_ssl_new() else { + cass_cluster_free(cluster) + self.cluster = nil + throw CassandraPluginError.connectionFailed("Failed to create SSL context") + } + + if sslMode == "Verify CA" || sslMode == "Verify Identity" { + if sslMode == "Verify Identity" { + let flags = Int32(CASS_SSL_VERIFY_PEER_CERT.rawValue | CASS_SSL_VERIFY_PEER_IDENTITY.rawValue) + cass_ssl_set_verify_flags(ssl, flags) + } else { + cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_PEER_CERT.rawValue)) + } + + if let caCertPath = sslCaCertPath, !caCertPath.isEmpty, + let certData = FileManager.default.contents(atPath: caCertPath), + let certString = String(data: certData, encoding: .utf8) { + let rc = cass_ssl_add_trusted_cert(ssl, certString) + if rc != CASS_OK { + Self.logger.warning("Failed to add CA certificate, proceeding without verification") + cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_NONE.rawValue)) + } + } + } else { + // "Preferred" / "Required" — encrypt but skip cert verification + cass_ssl_set_verify_flags(ssl, Int32(CASS_SSL_VERIFY_NONE.rawValue)) + } + + cass_cluster_set_ssl(cluster, ssl) + cass_ssl_free(ssl) + } + + // Connection timeout (10 seconds) + cass_cluster_set_connect_timeout(cluster, 10_000) + cass_cluster_set_request_timeout(cluster, 30_000) + + let newSession = cass_session_new() + guard let newSession else { + cass_cluster_free(cluster) + self.cluster = nil + throw CassandraPluginError.connectionFailed("Failed to create session") + } + + let connectFuture: OpaquePointer? + if let keyspace, !keyspace.isEmpty { + connectFuture = cass_session_connect_keyspace(newSession, cluster, keyspace) + currentKeyspace = keyspace + } else { + connectFuture = cass_session_connect(newSession, cluster) + currentKeyspace = nil + } + + guard let future = connectFuture else { + cass_session_free(newSession) + cass_cluster_free(cluster) + self.cluster = nil + throw CassandraPluginError.connectionFailed("Failed to initiate connection") + } + + cass_future_wait(future) + let rc = cass_future_error_code(future) + + if rc != CASS_OK { + let errorMessage = extractFutureError(future) + cass_future_free(future) + cass_session_free(newSession) + cass_cluster_free(cluster) + self.cluster = nil + throw CassandraPluginError.connectionFailed(errorMessage) + } + + cass_future_free(future) + session = newSession + + Self.logger.info("Connected to Cassandra at \(host):\(port)") + } + + func close() { + if let session { + let closeFuture = cass_session_close(session) + if let closeFuture { + cass_future_wait(closeFuture) + cass_future_free(closeFuture) + } + cass_session_free(session) + self.session = nil + } + + if let cluster { + cass_cluster_free(cluster) + self.cluster = nil + } + + currentKeyspace = nil + Self.logger.info("Disconnected from Cassandra") + } + + func executeQuery(_ cql: String) throws -> CassandraRawResult { + guard let session else { + throw CassandraPluginError.notConnected + } + + let startTime = Date() + let statement = cass_statement_new(cql, 0) + guard let statement else { + throw CassandraPluginError.queryFailed("Failed to create statement") + } + + defer { cass_statement_free(statement) } + + let future = cass_session_execute(session, statement) + guard let future else { + throw CassandraPluginError.queryFailed("Failed to execute query") + } + + defer { cass_future_free(future) } + + cass_future_wait(future) + let rc = cass_future_error_code(future) + + if rc != CASS_OK { + throw CassandraPluginError.queryFailed(extractFutureError(future)) + } + + let result = cass_future_get_result(future) + defer { + if let result { cass_result_free(result) } + } + + guard let result else { + let executionTime = Date().timeIntervalSince(startTime) + return CassandraRawResult( + columns: [], + columnTypeNames: [], + rows: [], + rowsAffected: 0, + executionTime: executionTime + ) + } + + return extractResult(from: result, startTime: startTime) + } + + func executePrepared(_ cql: String, parameters: [String?]) throws -> CassandraRawResult { + guard let session else { + throw CassandraPluginError.notConnected + } + + let startTime = Date() + + // Prepare + let prepareFuture = cass_session_prepare(session, cql) + guard let prepareFuture else { + throw CassandraPluginError.queryFailed("Failed to prepare statement") + } + defer { cass_future_free(prepareFuture) } + + cass_future_wait(prepareFuture) + let prepRc = cass_future_error_code(prepareFuture) + if prepRc != CASS_OK { + throw CassandraPluginError.queryFailed(extractFutureError(prepareFuture)) + } + + let prepared = cass_future_get_prepared(prepareFuture) + guard let prepared else { + throw CassandraPluginError.queryFailed("Failed to get prepared statement") + } + defer { cass_prepared_free(prepared) } + + // Bind parameters + let statement = cass_prepared_bind(prepared) + guard let statement else { + throw CassandraPluginError.queryFailed("Failed to bind prepared statement") + } + defer { cass_statement_free(statement) } + + for (index, param) in parameters.enumerated() { + if let value = param { + cass_statement_bind_string(statement, index, value) + } else { + cass_statement_bind_null(statement, index) + } + } + + // Execute + let future = cass_session_execute(session, statement) + guard let future else { + throw CassandraPluginError.queryFailed("Failed to execute prepared statement") + } + defer { cass_future_free(future) } + + cass_future_wait(future) + let rc = cass_future_error_code(future) + + if rc != CASS_OK { + throw CassandraPluginError.queryFailed(extractFutureError(future)) + } + + let result = cass_future_get_result(future) + defer { + if let result { cass_result_free(result) } + } + + guard let result else { + let executionTime = Date().timeIntervalSince(startTime) + return CassandraRawResult( + columns: [], + columnTypeNames: [], + rows: [], + rowsAffected: 0, + executionTime: executionTime + ) + } + + return extractResult(from: result, startTime: startTime) + } + + func switchKeyspace(_ keyspace: String) throws { + _ = try executeQuery("USE \"\(escapeIdentifier(keyspace))\"") + currentKeyspace = keyspace + } + + func serverVersion() throws -> String? { + let result = try executeQuery("SELECT release_version FROM system.local WHERE key = 'local'") + return result.rows.first?.first ?? nil + } + + // MARK: - Private Helpers + + private func extractResult( + from result: OpaquePointer, + startTime: Date + ) -> CassandraRawResult { + let colCount = cass_result_column_count(result) + let rowCount = cass_result_row_count(result) + + var columns: [String] = [] + var columnTypeNames: [String] = [] + + for i in 0..? + var nameLength: Int = 0 + cass_result_column_name(result, i, &namePtr, &nameLength) + if let namePtr { + columns.append(String(cString: namePtr)) + } else { + columns.append("column_\(i)") + } + + let colType = cass_result_column_type(result, i) + columnTypeNames.append(Self.cassTypeName(colType)) + } + + var rows: [[String?]] = [] + let iterator = cass_iterator_from_result(result) + defer { + if let iterator { cass_iterator_free(iterator) } + } + + guard let iterator else { + let executionTime = Date().timeIntervalSince(startTime) + return CassandraRawResult( + columns: columns, + columnTypeNames: columnTypeNames, + rows: [], + rowsAffected: Int(rowCount), + executionTime: executionTime + ) + } + + let maxRows = min(Int(rowCount), 100_000) + var count = 0 + + while cass_iterator_next(iterator) == cass_true && count < maxRows { + let row = cass_iterator_get_row(iterator) + guard let row else { continue } + + var rowData: [String?] = [] + for col in 0.. String? { + let valueType = cass_value_type(value) + + switch valueType { + case CASS_VALUE_TYPE_ASCII, CASS_VALUE_TYPE_TEXT, CASS_VALUE_TYPE_VARCHAR: + var output: UnsafePointer? + var outputLength: Int = 0 + let rc = cass_value_get_string(value, &output, &outputLength) + if rc == CASS_OK, let output { + return String( + bytesNoCopy: UnsafeMutableRawPointer(mutating: output), + length: outputLength, + encoding: .utf8, + freeWhenDone: false + ) + } + return nil + + case CASS_VALUE_TYPE_INT: + var intVal: Int32 = 0 + if cass_value_get_int32(value, &intVal) == CASS_OK { + return String(intVal) + } + return nil + + case CASS_VALUE_TYPE_BIGINT, CASS_VALUE_TYPE_COUNTER: + var bigintVal: Int64 = 0 + if cass_value_get_int64(value, &bigintVal) == CASS_OK { + return String(bigintVal) + } + return nil + + case CASS_VALUE_TYPE_SMALL_INT: + var smallVal: Int16 = 0 + if cass_value_get_int16(value, &smallVal) == CASS_OK { + return String(smallVal) + } + return nil + + case CASS_VALUE_TYPE_TINY_INT: + var tinyVal: Int8 = 0 + if cass_value_get_int8(value, &tinyVal) == CASS_OK { + return String(tinyVal) + } + return nil + + case CASS_VALUE_TYPE_FLOAT: + var floatVal: Float = 0 + if cass_value_get_float(value, &floatVal) == CASS_OK { + return String(floatVal) + } + return nil + + case CASS_VALUE_TYPE_DOUBLE: + var doubleVal: Double = 0 + if cass_value_get_double(value, &doubleVal) == CASS_OK { + return String(doubleVal) + } + return nil + + case CASS_VALUE_TYPE_BOOLEAN: + var boolVal: cass_bool_t = cass_false + if cass_value_get_bool(value, &boolVal) == CASS_OK { + return boolVal == cass_true ? "true" : "false" + } + return nil + + case CASS_VALUE_TYPE_UUID, CASS_VALUE_TYPE_TIMEUUID: + var uuid = CassUuid() + if cass_value_get_uuid(value, &uuid) == CASS_OK { + var buffer = [CChar](repeating: 0, count: Int(CASS_UUID_STRING_LENGTH)) + cass_uuid_string(uuid, &buffer) + return String(cString: buffer) + } + return nil + + case CASS_VALUE_TYPE_TIMESTAMP: + var timestamp: Int64 = 0 + if cass_value_get_int64(value, ×tamp) == CASS_OK { + let date = Date(timeIntervalSince1970: Double(timestamp) / 1000.0) + return isoFormatter.string(from: date) + } + return nil + + case CASS_VALUE_TYPE_BLOB: + var bytes: UnsafePointer? + var length: Int = 0 + if cass_value_get_bytes(value, &bytes, &length) == CASS_OK, let bytes { + let data = Data(bytes: bytes, count: length) + return "0x" + data.map { String(format: "%02x", $0) }.joined() + } + return nil + + case CASS_VALUE_TYPE_INET: + var inet = CassInet() + if cass_value_get_inet(value, &inet) == CASS_OK { + var buffer = [CChar](repeating: 0, count: Int(CASS_INET_STRING_LENGTH)) + cass_inet_string(inet, &buffer) + return String(cString: buffer) + } + return nil + + case CASS_VALUE_TYPE_LIST, CASS_VALUE_TYPE_SET: + return extractCollectionString(value, open: "[", close: "]") + + case CASS_VALUE_TYPE_MAP: + return extractMapString(value) + + case CASS_VALUE_TYPE_TUPLE: + return extractCollectionString(value, open: "(", close: ")") + + case CASS_VALUE_TYPE_DATE: + var dateVal: UInt32 = 0 + if cass_value_get_uint32(value, &dateVal) == CASS_OK { + let daysSinceEpoch = Int64(dateVal) - Int64(1 << 31) + let epochSeconds = daysSinceEpoch * 86400 + let date = Date(timeIntervalSince1970: Double(epochSeconds)) + return dateFormatter.string(from: date) + } + return nil + + case CASS_VALUE_TYPE_TIME: + var timeVal: Int64 = 0 + if cass_value_get_int64(value, &timeVal) == CASS_OK { + // Cassandra time is nanoseconds since midnight + let totalSeconds = timeVal / 1_000_000_000 + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + let nanos = timeVal % 1_000_000_000 + if nanos > 0 { + let millis = nanos / 1_000_000 + return String(format: "%02lld:%02lld:%02lld.%03lld", hours, minutes, seconds, millis) + } + return String(format: "%02lld:%02lld:%02lld", hours, minutes, seconds) + } + return nil + + case CASS_VALUE_TYPE_DECIMAL, CASS_VALUE_TYPE_VARINT: + // Read as bytes and display as hex since proper numeric decoding + // requires BigInteger support not available in the C driver API + var bytes: UnsafePointer? + var length: Int = 0 + if cass_value_get_bytes(value, &bytes, &length) == CASS_OK, let bytes { + let data = Data(bytes: bytes, count: length) + return "0x" + data.map { String(format: "%02x", $0) }.joined() + } + return nil + + default: + // Fallback: try reading as string + var output: UnsafePointer? + var outputLength: Int = 0 + if cass_value_get_string(value, &output, &outputLength) == CASS_OK, let output { + return String( + bytesNoCopy: UnsafeMutableRawPointer(mutating: output), + length: outputLength, + encoding: .utf8, + freeWhenDone: false + ) + } + return "" + } + } + + private static func extractCollectionString( + _ value: OpaquePointer, + open: String, + close: String + ) -> String { + guard let iterator = cass_iterator_from_collection(value) else { + return "\(open)\(close)" + } + defer { cass_iterator_free(iterator) } + + var elements: [String] = [] + while cass_iterator_next(iterator) == cass_true { + if let elem = cass_iterator_get_value(iterator) { + elements.append(extractStringValue(elem) ?? "null") + } + } + return "\(open)\(elements.joined(separator: ", "))\(close)" + } + + private static func extractMapString(_ value: OpaquePointer) -> String { + guard let iterator = cass_iterator_from_map(value) else { + return "{}" + } + defer { cass_iterator_free(iterator) } + + var pairs: [String] = [] + while cass_iterator_next(iterator) == cass_true { + let key = cass_iterator_get_map_key(iterator) + let val = cass_iterator_get_map_value(iterator) + let keyStr = key.flatMap { extractStringValue($0) } ?? "null" + let valStr = val.flatMap { extractStringValue($0) } ?? "null" + pairs.append("\(keyStr): \(valStr)") + } + return "{\(pairs.joined(separator: ", "))}" + } + + private static func cassTypeName(_ type: CassValueType) -> String { + switch type { + case CASS_VALUE_TYPE_ASCII: return "ascii" + case CASS_VALUE_TYPE_BIGINT: return "bigint" + case CASS_VALUE_TYPE_BLOB: return "blob" + case CASS_VALUE_TYPE_BOOLEAN: return "boolean" + case CASS_VALUE_TYPE_COUNTER: return "counter" + case CASS_VALUE_TYPE_DECIMAL: return "decimal" + case CASS_VALUE_TYPE_DOUBLE: return "double" + case CASS_VALUE_TYPE_FLOAT: return "float" + case CASS_VALUE_TYPE_INT: return "int" + case CASS_VALUE_TYPE_TEXT: return "text" + case CASS_VALUE_TYPE_TIMESTAMP: return "timestamp" + case CASS_VALUE_TYPE_UUID: return "uuid" + case CASS_VALUE_TYPE_VARCHAR: return "varchar" + case CASS_VALUE_TYPE_VARINT: return "varint" + case CASS_VALUE_TYPE_TIMEUUID: return "timeuuid" + case CASS_VALUE_TYPE_INET: return "inet" + case CASS_VALUE_TYPE_DATE: return "date" + case CASS_VALUE_TYPE_TIME: return "time" + case CASS_VALUE_TYPE_SMALL_INT: return "smallint" + case CASS_VALUE_TYPE_TINY_INT: return "tinyint" + case CASS_VALUE_TYPE_LIST: return "list" + case CASS_VALUE_TYPE_MAP: return "map" + case CASS_VALUE_TYPE_SET: return "set" + case CASS_VALUE_TYPE_TUPLE: return "tuple" + case CASS_VALUE_TYPE_UDT: return "udt" + default: return "text" + } + } + + private func extractFutureError(_ future: OpaquePointer) -> String { + var message: UnsafePointer? + var messageLength: Int = 0 + cass_future_error_message(future, &message, &messageLength) + if let message { + return String( + bytesNoCopy: UnsafeMutableRawPointer(mutating: message), + length: messageLength, + encoding: .utf8, + freeWhenDone: false + ) ?? "Unknown error" + } + return "Unknown error" + } + + private func escapeIdentifier(_ value: String) -> String { + value.replacingOccurrences(of: "\"", with: "\"\"") + } +} + +// MARK: - Raw Result + +private struct CassandraRawResult: Sendable { + let columns: [String] + let columnTypeNames: [String] + let rows: [[String?]] + let rowsAffected: Int + let executionTime: TimeInterval +} + +// MARK: - Plugin Driver + +internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private let config: DriverConnectionConfig + private let connectionActor = CassandraConnectionActor() + private let stateLock = NSLock() + nonisolated(unsafe) private var _currentKeyspace: String? + + private static let logger = Logger(subsystem: "com.TablePro.CassandraDriver", category: "Driver") + + var currentSchema: String? { + stateLock.lock() + defer { stateLock.unlock() } + return _currentKeyspace + } + + var serverVersion: String? { + // Fetched lazily and cached + stateLock.lock() + let cached = _cachedVersion + stateLock.unlock() + return cached + } + + nonisolated(unsafe) private var _cachedVersion: String? + + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + + init(config: DriverConnectionConfig) { + self.config = config + } + + // MARK: - Connection + + func connect() async throws { + let sslMode = config.additionalFields["sslMode"] ?? "Disabled" + let sslCaCertPath = config.additionalFields["sslCaCertPath"] + + let keyspace = config.database.isEmpty ? nil : config.database + + try await connectionActor.connect( + host: config.host, + port: Int(config.port) ?? 9042, + username: config.username.isEmpty ? nil : config.username, + password: config.password.isEmpty ? nil : config.password, + keyspace: keyspace, + sslMode: sslMode, + sslCaCertPath: sslCaCertPath + ) + + if let keyspace { + stateLock.lock() + _currentKeyspace = keyspace + stateLock.unlock() + } + + // Cache server version + if let version = try? await connectionActor.serverVersion() { + stateLock.lock() + _cachedVersion = version + stateLock.unlock() + } + } + + func disconnect() { + Task.detached(priority: .utility) { [connectionActor] in + await connectionActor.close() + } + stateLock.lock() + _currentKeyspace = nil + _cachedVersion = nil + stateLock.unlock() + } + + func ping() async throws { + _ = try await execute(query: "SELECT key FROM system.local WHERE key = 'local'") + } + + func applyQueryTimeout(_ seconds: Int) async throws { + // Cassandra doesn't support session-level query timeouts via CQL. + // The request timeout is set at connection time via cass_cluster_set_request_timeout. + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> PluginQueryResult { + let rawResult = try await connectionActor.executeQuery(query) + return PluginQueryResult( + columns: rawResult.columns, + columnTypeNames: rawResult.columnTypeNames, + rows: rawResult.rows, + rowsAffected: rawResult.rowsAffected, + executionTime: rawResult.executionTime + ) + } + + func executeParameterized( + query: String, + parameters: [String?] + ) async throws -> PluginQueryResult { + let rawResult = try await connectionActor.executePrepared(query, parameters: parameters) + return PluginQueryResult( + columns: rawResult.columns, + columnTypeNames: rawResult.columnTypeNames, + rows: rawResult.rows, + rowsAffected: rawResult.rowsAffected, + executionTime: rawResult.executionTime + ) + } + + // MARK: - Pagination + + func fetchRowCount(query: String) async throws -> Int { + // CQL does not support subqueries, so we can't wrap an arbitrary query in SELECT COUNT(*) FROM (...). + // Return -1 to signal unknown count; the UI will hide the total page count. + -1 + } + + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult { + // CQL does not support OFFSET. Only the first page (offset=0) can be fetched via simple LIMIT. + // For offset>0, throw so the caller knows pagination is unsupported for arbitrary queries. + if offset > 0 { + throw CassandraPluginError.unsupportedOperation + } + let baseQuery = stripTrailingSemicolon(query) + let paginatedQuery = "\(baseQuery) LIMIT \(limit)" + return try await execute(query: paginatedQuery) + } + + // MARK: - Schema Operations + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + let ks = resolveKeyspace(schema) + + // Fetch tables + let tablesQuery = """ + SELECT table_name FROM system_schema.tables WHERE keyspace_name = '\(escapeSingleQuote(ks))' + """ + let tablesResult = try await execute(query: tablesQuery) + + var tables = tablesResult.rows.compactMap { row -> PluginTableInfo? in + guard let name = row[safe: 0] ?? nil else { return nil } + return PluginTableInfo(name: name, type: "TABLE") + } + + // Fetch materialized views + let viewsQuery = """ + SELECT view_name FROM system_schema.views WHERE keyspace_name = '\(escapeSingleQuote(ks))' + """ + if let viewsResult = try? await execute(query: viewsQuery) { + let views = viewsResult.rows.compactMap { row -> PluginTableInfo? in + guard let name = row[safe: 0] ?? nil else { return nil } + return PluginTableInfo(name: name, type: "VIEW") + } + tables.append(contentsOf: views) + } + + return tables.sorted { $0.name < $1.name } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + let ks = resolveKeyspace(schema) + let query = """ + SELECT column_name, type, kind, clustering_order, position + FROM system_schema.columns + WHERE keyspace_name = '\(escapeSingleQuote(ks))' + AND table_name = '\(escapeSingleQuote(table))' + """ + let result = try await execute(query: query) + + // Parse and sort by kind order then position before mapping to PluginColumnInfo + struct RawColumn { + let name: String + let dataType: String + let kind: String + let position: Int + let isPrimaryKey: Bool + } + + let rawColumns = result.rows.compactMap { row -> RawColumn? in + guard let name = row[safe: 0] ?? nil, + let dataType = row[safe: 1] ?? nil else { + return nil + } + let kind = (row[safe: 2] ?? nil) ?? "regular" + let position = Int((row[safe: 4] ?? nil) ?? "0") ?? 0 + let isPrimaryKey = kind == "partition_key" || kind == "clustering" + return RawColumn(name: name, dataType: dataType, kind: kind, position: position, isPrimaryKey: isPrimaryKey) + }.sorted { lhs, rhs in + let lhsOrder = columnKindOrder(lhs.kind) + let rhsOrder = columnKindOrder(rhs.kind) + if lhsOrder != rhsOrder { return lhsOrder < rhsOrder } + return lhs.position < rhs.position + } + + return rawColumns.map { col in + PluginColumnInfo( + name: col.name, + dataType: col.dataType, + isNullable: !col.isPrimaryKey, + isPrimaryKey: col.isPrimaryKey, + defaultValue: nil + ) + } + } + + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { + let ks = resolveKeyspace(schema) + let query = """ + SELECT table_name, column_name, type, kind, clustering_order, position + FROM system_schema.columns + WHERE keyspace_name = '\(escapeSingleQuote(ks))' + """ + let result = try await execute(query: query) + + var allColumns: [String: [PluginColumnInfo]] = [:] + + for row in result.rows { + guard let tableName = row[safe: 0] ?? nil, + let columnName = row[safe: 1] ?? nil, + let dataType = row[safe: 2] ?? nil else { + continue + } + let kind = row[safe: 3] ?? nil + let isPrimaryKey = kind == "partition_key" || kind == "clustering" + + let column = PluginColumnInfo( + name: columnName, + dataType: dataType, + isNullable: !isPrimaryKey, + isPrimaryKey: isPrimaryKey, + defaultValue: nil + ) + + allColumns[tableName, default: []].append(column) + } + + return allColumns + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + let ks = resolveKeyspace(schema) + let query = """ + SELECT index_name, kind, options + FROM system_schema.indexes + WHERE keyspace_name = '\(escapeSingleQuote(ks))' + AND table_name = '\(escapeSingleQuote(table))' + """ + + do { + let result = try await execute(query: query) + return result.rows.compactMap { row in + guard let name = row[safe: 0] ?? nil else { return nil } + let kind = (row[safe: 1] ?? nil) ?? "COMPOSITES" + let options = (row[safe: 2] ?? nil) ?? "" + + // Extract target column from options map + var targetColumns: [String] = [] + if let targetRange = options.range(of: "target: ") { + let target = String(options[targetRange.upperBound...]) + .trimmingCharacters(in: CharacterSet(charactersIn: "{},' ")) + targetColumns = [target] + } + + return PluginIndexInfo( + name: name, + columns: targetColumns, + isUnique: false, + isPrimary: false, + type: kind + ) + } + } catch { + return [] + } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + // Cassandra does not support foreign keys + [] + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let ks = resolveKeyspace(schema) + + // Build DDL from schema metadata + let columns = try await fetchColumns(table: table, schema: ks) + + let partitionKeys = columns.filter(\.isPrimaryKey) + let regularColumns = columns.filter { !$0.isPrimaryKey } + + var ddl = "CREATE TABLE \"\(escapeIdentifier(ks))\".\"\(escapeIdentifier(table))\" (\n" + + let allCols = partitionKeys + regularColumns + let colDefs = allCols.map { col in + " \"\(escapeIdentifier(col.name))\" \(col.dataType)" + } + + var allDefs = colDefs + + if !partitionKeys.isEmpty { + let pkCols = partitionKeys.map { "\"\(escapeIdentifier($0.name))\"" } + .joined(separator: ", ") + allDefs.append(" PRIMARY KEY (\(pkCols))") + } + + ddl += allDefs.joined(separator: ",\n") + ddl += "\n);" + + return ddl + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + let ks = resolveKeyspace(schema) + let query = """ + SELECT base_table_name, where_clause, include_all_columns + FROM system_schema.views + WHERE keyspace_name = '\(escapeSingleQuote(ks))' + AND view_name = '\(escapeSingleQuote(view))' + """ + let result = try await execute(query: query) + + guard let row = result.rows.first else { + throw CassandraPluginError.queryFailed("View '\(view)' not found") + } + + let baseTable = (row[safe: 0] ?? nil) ?? "unknown" + let whereClause = (row[safe: 1] ?? nil) ?? "" + + let columns = try await fetchColumns(table: view, schema: ks) + let colNames = columns.map { "\"\(escapeIdentifier($0.name))\"" }.joined(separator: ", ") + let pkColumns = columns.filter(\.isPrimaryKey) + let pkStr = pkColumns.map { "\"\(escapeIdentifier($0.name))\"" }.joined(separator: ", ") + + var ddl = "CREATE MATERIALIZED VIEW \"\(escapeIdentifier(ks))\".\"\(escapeIdentifier(view))\" AS\n" + ddl += " SELECT \(colNames)\n" + ddl += " FROM \"\(escapeIdentifier(ks))\".\"\(escapeIdentifier(baseTable))\"\n" + if !whereClause.isEmpty { + ddl += " WHERE \(whereClause)\n" + } + ddl += " PRIMARY KEY (\(pkStr));" + + return ddl + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + let ks = resolveKeyspace(schema) + // Cassandra doesn't have a cheap row count — use a bounded count + let countQuery = "SELECT COUNT(*) FROM \"\(escapeIdentifier(ks))\".\"\(escapeIdentifier(table))\" LIMIT 100001" + let countResult = try? await execute(query: countQuery) + let rowCount: Int64? = { + guard let row = countResult?.rows.first, let countStr = row.first else { return nil } + return Int64(countStr ?? "0") + }() + + return PluginTableMetadata( + tableName: table, + rowCount: rowCount, + engine: "Cassandra" + ) + } + + // MARK: - Database (Keyspace) Operations + + func fetchDatabases() async throws -> [String] { + let query = "SELECT keyspace_name FROM system_schema.keyspaces" + let result = try await execute(query: query) + let systemKeyspaces: Set = [ + "system", "system_schema", "system_auth", + "system_distributed", "system_traces", "system_virtual_schema", + ] + return result.rows.compactMap { $0[safe: 0] ?? nil } + .filter { !systemKeyspaces.contains($0) } + .sorted() + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func fetchAllDatabaseMetadata() async throws -> [PluginDatabaseMetadata] { + let databases = try await fetchDatabases() + return databases.map { PluginDatabaseMetadata(name: $0) } + } + + func createDatabase(name: String, charset: String, collation: String?) async throws { + let safeKs = escapeIdentifier(name) + let query = """ + CREATE KEYSPACE "\(safeKs)" + WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3} + """ + _ = try await execute(query: query) + } + + func switchDatabase(to database: String) async throws { + try await connectionActor.switchKeyspace(database) + stateLock.lock() + _currentKeyspace = database + stateLock.unlock() + } + + // MARK: - Schemas (Cassandra uses keyspaces, not schemas) + + func fetchSchemas() async throws -> [String] { + [] + } + + func switchSchema(to schema: String) async throws { + // Cassandra uses keyspaces instead of schemas + try await switchDatabase(to: schema) + } + + // MARK: - Private Helpers + + private func resolveKeyspace(_ schema: String?) -> String { + if let schema, !schema.isEmpty { return schema } + stateLock.lock() + defer { stateLock.unlock() } + return _currentKeyspace ?? "system" + } + + private func escapeIdentifier(_ value: String) -> String { + value.replacingOccurrences(of: "\"", with: "\"\"") + } + + private func escapeSingleQuote(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + private func stripTrailingSemicolon(_ query: String) -> String { + var result = query.trimmingCharacters(in: .whitespacesAndNewlines) + while result.hasSuffix(";") { + result = String(result.dropLast()).trimmingCharacters(in: .whitespaces) + } + return result + } + + private func columnKindOrder(_ kind: String) -> Int { + switch kind { + case "partition_key": return 0 + case "clustering": return 1 + case "static": return 2 + default: return 3 + } + } +} + +// MARK: - Errors + +internal enum CassandraPluginError: Error { + case connectionFailed(String) + case notConnected + case queryFailed(String) + case unsupportedOperation +} + +extension CassandraPluginError: PluginDriverError { + var pluginErrorMessage: String { + switch self { + case .connectionFailed(let msg): return msg + case .notConnected: return String(localized: "Not connected to database") + case .queryFailed(let msg): return msg + case .unsupportedOperation: return String(localized: "Operation not supported by Cassandra") + } + } +} diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist new file mode 100644 index 00000000..737aa31f --- /dev/null +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + $(PRODUCT_MODULE_NAME).CassandraPlugin + + diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 73d93cd8..d68f0efa 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; }; 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; @@ -217,6 +218,7 @@ 5A86D000100000000 /* XLSXExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XLSXExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A86E000100000000 /* MQLExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MQLExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A86F000100000000 /* SQLImport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLImport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A87A000100000000 /* CassandraDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CassandraDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5ASECRETS000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ @@ -334,6 +336,13 @@ ); target = 5A86F000000000000 /* SQLImport */; }; + 5A87A000900000000 /* Exceptions for "Plugins/CassandraDriverPlugin" folder in "CassandraDriver" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5A87A000000000000 /* CassandraDriver */; + }; 5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -480,6 +489,14 @@ path = Plugins/SQLImportPlugin; sourceTree = ""; }; + 5A87A000500000000 /* Plugins/CassandraDriverPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5A87A000900000000 /* Exceptions for "Plugins/CassandraDriverPlugin" folder in "CassandraDriver" target */, + ); + path = Plugins/CassandraDriverPlugin; + sourceTree = ""; + }; 5ABCC5A82F43856700EAF3FC /* TableProTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = TableProTests; @@ -628,6 +645,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5A87A000300000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5ABCC5A42F43856700EAF3FC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -660,6 +685,7 @@ 5A867000500000000 /* Plugins/RedisDriverPlugin */, 5A868000500000000 /* Plugins/PostgreSQLDriverPlugin */, 5A869000500000000 /* Plugins/DuckDBDriverPlugin */, + 5A87A000500000000 /* Plugins/CassandraDriverPlugin */, 5A86A000500000000 /* Plugins/CSVExportPlugin */, 5A86B000500000000 /* Plugins/JSONExportPlugin */, 5A86C000500000000 /* Plugins/SQLExportPlugin */, @@ -686,6 +712,7 @@ 5A867000100000000 /* RedisDriver.tableplugin */, 5A868000100000000 /* PostgreSQLDriver.tableplugin */, 5A869000100000000 /* DuckDBDriver.tableplugin */, + 5A87A000100000000 /* CassandraDriver.tableplugin */, 5A86A000100000000 /* CSVExport.tableplugin */, 5A86B000100000000 /* JSONExport.tableplugin */, 5A86C000100000000 /* SQLExport.tableplugin */, @@ -1079,6 +1106,26 @@ productReference = 5A86F000100000000 /* SQLImport.tableplugin */; productType = "com.apple.product-type.bundle"; }; + 5A87A000000000000 /* CassandraDriver */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5A87A000800000000 /* Build configuration list for PBXNativeTarget "CassandraDriver" */; + buildPhases = ( + 5A87A000200000000 /* Sources */, + 5A87A000300000000 /* Frameworks */, + 5A87A000400000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5A87A000500000000 /* Plugins/CassandraDriverPlugin */, + ); + name = CassandraDriver; + productName = CassandraDriver; + productReference = 5A87A000100000000 /* CassandraDriver.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; 5ABCC5A62F43856700EAF3FC /* TableProTests */ = { isa = PBXNativeTarget; buildConfigurationList = 5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */; @@ -1198,6 +1245,7 @@ 5A867000000000000 /* RedisDriver */, 5A868000000000000 /* PostgreSQLDriver */, 5A869000000000000 /* DuckDBDriver */, + 5A87A000000000000 /* CassandraDriver */, 5A86A000000000000 /* CSVExport */, 5A86B000000000000 /* JSONExport */, 5A86C000000000000 /* SQLExport */, @@ -1329,6 +1377,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5A87A000400000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5ABCC5A52F43856700EAF3FC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1458,6 +1513,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5A87A000200000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5ABCC5A32F43856700EAF3FC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1690,11 +1752,11 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 31; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -1750,13 +1812,13 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = YES; CURRENT_PROJECT_VERSION = 31; DEAD_CODE_STRIPPING = YES; DEPLOYMENT_POSTPROCESSING = YES; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -1808,7 +1870,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1834,7 +1896,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1860,7 +1922,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/OracleDriverPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).OraclePlugin"; @@ -1883,7 +1945,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/OracleDriverPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).OraclePlugin"; @@ -1906,7 +1968,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/SQLiteDriverPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLitePlugin"; @@ -1930,7 +1992,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/SQLiteDriverPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLitePlugin"; @@ -1954,7 +2016,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/ClickHouseDriverPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).ClickHousePlugin"; @@ -1977,7 +2039,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/ClickHouseDriverPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).ClickHousePlugin"; @@ -2000,7 +2062,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/MSSQLDriverPlugin/CFreeTDS/include"; INFOPLIST_FILE = Plugins/MSSQLDriverPlugin/Info.plist; @@ -2033,7 +2095,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/MSSQLDriverPlugin/CFreeTDS/include"; INFOPLIST_FILE = Plugins/MSSQLDriverPlugin/Info.plist; @@ -2066,7 +2128,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/MySQLDriverPlugin/CMariaDB/include"; INFOPLIST_FILE = Plugins/MySQLDriverPlugin/Info.plist; @@ -2099,7 +2161,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/MySQLDriverPlugin/CMariaDB/include"; INFOPLIST_FILE = Plugins/MySQLDriverPlugin/Info.plist; @@ -2132,7 +2194,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = ( "$(SRCROOT)/Plugins/MongoDBDriverPlugin/CLibMongoc/include", @@ -2172,7 +2234,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = ( "$(SRCROOT)/Plugins/MongoDBDriverPlugin/CLibMongoc/include", @@ -2212,7 +2274,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/RedisDriverPlugin/CRedis/include"; INFOPLIST_FILE = Plugins/RedisDriverPlugin/Info.plist; @@ -2246,7 +2308,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/RedisDriverPlugin/CRedis/include"; INFOPLIST_FILE = Plugins/RedisDriverPlugin/Info.plist; @@ -2280,7 +2342,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/PostgreSQLDriverPlugin/CLibPQ/include"; INFOPLIST_FILE = Plugins/PostgreSQLDriverPlugin/Info.plist; @@ -2315,7 +2377,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/PostgreSQLDriverPlugin/CLibPQ/include"; INFOPLIST_FILE = Plugins/PostgreSQLDriverPlugin/Info.plist; @@ -2350,7 +2412,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/DuckDBDriverPlugin/CDuckDB/include"; INFOPLIST_FILE = Plugins/DuckDBDriverPlugin/Info.plist; @@ -2381,7 +2443,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/DuckDBDriverPlugin/CDuckDB/include"; INFOPLIST_FILE = Plugins/DuckDBDriverPlugin/Info.plist; @@ -2412,7 +2474,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/CSVExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CSVExportPlugin"; @@ -2435,7 +2497,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/CSVExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CSVExportPlugin"; @@ -2458,7 +2520,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/JSONExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).JSONExportPlugin"; @@ -2481,7 +2543,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/JSONExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).JSONExportPlugin"; @@ -2504,7 +2566,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/SQLExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLExportPlugin"; @@ -2527,7 +2589,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/SQLExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLExportPlugin"; @@ -2550,7 +2612,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/XLSXExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).XLSXExportPlugin"; @@ -2573,7 +2635,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/XLSXExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).XLSXExportPlugin"; @@ -2596,7 +2658,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin"; @@ -2619,7 +2681,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin"; @@ -2642,7 +2704,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/SQLImportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin"; @@ -2665,7 +2727,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Plugins/SQLImportPlugin/Info.plist; INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin"; @@ -2682,13 +2744,85 @@ }; name = Release; }; + 5A87A000600000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/CassandraDriverPlugin/CCassandra/include"; + INFOPLIST_FILE = Plugins/CassandraDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CassandraPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-force_load", + "$(PROJECT_DIR)/Libs/libcassandra.a", + "-force_load", + "$(PROJECT_DIR)/Libs/libuv.a", + "-lssl", + "-lcrypto", + "-lc++", + "-lz", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CassandraDriver; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Plugins/CassandraDriverPlugin/CCassandra"; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5A87A000700000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = "$(SRCROOT)/Plugins/CassandraDriverPlugin/CCassandra/include"; + INFOPLIST_FILE = Plugins/CassandraDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CassandraPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-force_load", + "$(PROJECT_DIR)/Libs/libcassandra.a", + "-force_load", + "$(PROJECT_DIR)/Libs/libuv.a", + "-lssl", + "-lcrypto", + "-lc++", + "-lz", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CassandraDriver; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Plugins/CassandraDriverPlugin/CCassandra"; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; 5ABCC5AE2F43856700EAF3FC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; @@ -2710,7 +2844,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; @@ -2891,6 +3025,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5A87A000800000000 /* Build configuration list for PBXNativeTarget "CassandraDriver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5A87A000600000000 /* Debug */, + 5A87A000700000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TablePro/Assets.xcassets/cassandra-icon.imageset/Contents.json b/TablePro/Assets.xcassets/cassandra-icon.imageset/Contents.json new file mode 100644 index 00000000..02cbfc78 --- /dev/null +++ b/TablePro/Assets.xcassets/cassandra-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "cassandra.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/cassandra-icon.imageset/cassandra.svg b/TablePro/Assets.xcassets/cassandra-icon.imageset/cassandra.svg new file mode 100644 index 00000000..6410f737 --- /dev/null +++ b/TablePro/Assets.xcassets/cassandra-icon.imageset/cassandra.svg @@ -0,0 +1,4 @@ + + + + diff --git a/TablePro/Assets.xcassets/scylladb-icon.imageset/Contents.json b/TablePro/Assets.xcassets/scylladb-icon.imageset/Contents.json new file mode 100644 index 00000000..e5fdcfbd --- /dev/null +++ b/TablePro/Assets.xcassets/scylladb-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "scylladb.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/scylladb-icon.imageset/scylladb.svg b/TablePro/Assets.xcassets/scylladb-icon.imageset/scylladb.svg new file mode 100644 index 00000000..8e07c53d --- /dev/null +++ b/TablePro/Assets.xcassets/scylladb-icon.imageset/scylladb.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 41290fbf..364025e7 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -381,6 +381,14 @@ enum DatabaseDriverFactory { switch connection.type { case .mongodb: fields["sslCACertPath"] = ssl.caCertificatePath + fields["mongoReadPreference"] = connection.mongoReadPreference ?? "" + fields["mongoWriteConcern"] = connection.mongoWriteConcern ?? "" + case .redis: + fields["redisDatabase"] = String(connection.redisDatabase ?? 0) + case .mssql: + fields["mssqlSchema"] = connection.mssqlSchema ?? "dbo" + case .oracle: + fields["oracleServiceName"] = connection.oracleServiceName ?? "" default: break } diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 96ce8674..e2f2bc90 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -110,6 +110,10 @@ struct ConnectionURLParser { dbType = .oracle case "clickhouse", "ch": dbType = .clickhouse + case "cassandra", "cql": + dbType = .cassandra + case "scylladb", "scylla": + dbType = .scylladb default: return .failure(.unsupportedScheme(scheme)) } diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 77d14f1d..d68fc051 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -200,6 +200,10 @@ mssql sqlserver duckdb + cassandra + cql + scylladb + scylla CFBundleTypeRole Viewer diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index d62c94fb..48540485 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -216,6 +216,8 @@ extension DatabaseType { static let oracle = DatabaseType(rawValue: "Oracle") static let clickhouse = DatabaseType(rawValue: "ClickHouse") static let duckdb = DatabaseType(rawValue: "DuckDB") + static let cassandra = DatabaseType(rawValue: "Cassandra") + static let scylladb = DatabaseType(rawValue: "ScyllaDB") } extension DatabaseType: Codable { @@ -235,6 +237,7 @@ extension DatabaseType { static let allKnownTypes: [DatabaseType] = [ .mysql, .mariadb, .postgresql, .sqlite, .redshift, .mongodb, .redis, .mssql, .oracle, .clickhouse, .duckdb, + .cassandra, .scylladb, ] /// Compatibility shim for CaseIterable call sites. @@ -289,10 +292,11 @@ extension DatabaseType { .oracle: "Oracle", .clickhouse: "ClickHouse", .duckdb: "DuckDB", + .cassandra: "Cassandra", .scylladb: "Cassandra", ] private static let isDownloadablePluginSet: Set = [ - .oracle, .clickhouse, .sqlite, .duckdb, + .oracle, .clickhouse, .sqlite, .duckdb, .cassandra, .scylladb, ] private static let iconNameMap: [DatabaseType: String] = [ @@ -307,6 +311,8 @@ extension DatabaseType { .oracle: "oracle-icon", .clickhouse: "clickhouse-icon", .duckdb: "duckdb-icon", + .cassandra: "cassandra-icon", + .scylladb: "scylladb-icon", ] private static let defaultPortMap: [DatabaseType: Int] = [ @@ -320,6 +326,7 @@ extension DatabaseType { .oracle: 1_521, .clickhouse: 8_123, .duckdb: 0, + .cassandra: 9_042, .scylladb: 9_042, ] private static let requiresAuthenticationSet: Set = [ @@ -331,7 +338,7 @@ extension DatabaseType { ] private static let supportsSchemaEditingSet: Set = [ - .mysql, .mariadb, .postgresql, .sqlite, .mssql, .oracle, .clickhouse, .duckdb, + .mysql, .mariadb, .postgresql, .sqlite, .mssql, .oracle, .clickhouse, .duckdb, .cassandra, .scylladb, ] } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index fa91c3b7..35594420 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -244,6 +244,13 @@ extension MainContentCoordinator { WindowOpener.shared.openNativeTab(payload) } + private func currentSchemaName(fallback: String) -> String { + if let schemaDriver = DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable { + return schemaDriver.escapedSchema + } + return fallback + } + private func allTablesMetadataSQL() -> String? { let editorLang = PluginManager.shared.editorLanguage(for: connection.type) // Non-SQL databases: open a command tab instead diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index f3d07c0d..51ee465a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -710,6 +710,9 @@ final class MainContentCoordinator { guard let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter, let explainSQL = adapter.buildExplainQuery(stmt) else { + if let index = tabManager.selectedTabIndex { + tabManager.tabs[index].errorMessage = String(localized: "EXPLAIN is not supported for this database type.") + } return } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index af2d544e..2677674c 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -331,12 +331,13 @@ struct MainContentView: View { } // Lazy-load: execute query for restored tabs that skipped auto-execute, // or re-query tabs whose row data was evicted while inactive. - if let tab = tabManager.selectedTab, - tab.tabType == .table, - tab.resultRows.isEmpty || tab.rowBuffer.isEvicted, - tab.lastExecutedAt == nil || tab.rowBuffer.isEvicted, - !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { + let needsLazyLoad = tabManager.selectedTab.map { tab in + tab.tabType == .table + && (tab.resultRows.isEmpty || tab.rowBuffer.isEvicted) + && (tab.lastExecutedAt == nil || tab.rowBuffer.isEvicted) + && !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } ?? false + if needsLazyLoad { coordinator.runQuery() } } diff --git a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift index 6ad779a2..44030fab 100644 --- a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift +++ b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift @@ -195,13 +195,18 @@ struct DatabaseURLSchemeTests { #expect(error == .unsupportedScheme("http")) } - @Test("Cassandra scheme returns unsupported error") - func cassandraSchemeUnsupported() { + @Test("Cassandra scheme parses successfully") + func cassandraSchemeSupported() { let result = ConnectionURLParser.parse("cassandra://user:pass@host:9042/keyspace") - guard case .failure(let error) = result else { - Issue.record("Expected failure"); return + guard case .success(let parsed) = result else { + Issue.record("Expected success, got: \(result)"); return } - #expect(error == .unsupportedScheme("cassandra")) + #expect(parsed.type == .cassandra) + #expect(parsed.host == "host") + #expect(parsed.port == nil) // 9042 is the default port, so parser normalizes to nil + #expect(parsed.database == "keyspace") + #expect(parsed.username == "user") + #expect(parsed.password == "pass") } // MARK: - Case Insensitivity diff --git a/TableProTests/Models/DatabaseTypeCassandraTests.swift b/TableProTests/Models/DatabaseTypeCassandraTests.swift new file mode 100644 index 00000000..2a90534e --- /dev/null +++ b/TableProTests/Models/DatabaseTypeCassandraTests.swift @@ -0,0 +1,115 @@ +import Testing +@testable import TablePro + +@Suite("DatabaseType Cassandra Properties") +struct DatabaseTypeCassandraTests { + @Test("Cassandra raw value is Cassandra") + func cassandraRawValue() { + #expect(DatabaseType.cassandra.rawValue == "Cassandra") + } + + @Test("ScyllaDB raw value is ScyllaDB") + func scylladbRawValue() { + #expect(DatabaseType.scylladb.rawValue == "ScyllaDB") + } + + @Test("Cassandra pluginTypeId is Cassandra") + func cassandraPluginTypeId() { + #expect(DatabaseType.cassandra.pluginTypeId == "Cassandra") + } + + @Test("ScyllaDB pluginTypeId is Cassandra") + func scylladbPluginTypeId() { + #expect(DatabaseType.scylladb.pluginTypeId == "Cassandra") + } + + @Test("Cassandra default port is 9042") + func cassandraDefaultPort() { + #expect(DatabaseType.cassandra.defaultPort == 9_042) + } + + @Test("ScyllaDB default port is 9042") + func scylladbDefaultPort() { + #expect(DatabaseType.scylladb.defaultPort == 9_042) + } + + @Test("Cassandra does not require authentication") + func cassandraRequiresAuthentication() { + #expect(DatabaseType.cassandra.requiresAuthentication == false) + } + + @Test("ScyllaDB does not require authentication") + func scylladbRequiresAuthentication() { + #expect(DatabaseType.scylladb.requiresAuthentication == false) + } + + @Test("Cassandra does not support foreign keys") + func cassandraSupportsForeignKeys() { + #expect(DatabaseType.cassandra.supportsForeignKeys == false) + } + + @Test("ScyllaDB does not support foreign keys") + func scylladbSupportsForeignKeys() { + #expect(DatabaseType.scylladb.supportsForeignKeys == false) + } + + @Test("Cassandra supports schema editing") + func cassandraSupportsSchemaEditing() { + #expect(DatabaseType.cassandra.supportsSchemaEditing == true) + } + + @Test("ScyllaDB supports schema editing") + func scylladbSupportsSchemaEditing() { + #expect(DatabaseType.scylladb.supportsSchemaEditing == true) + } + + @Test("Cassandra identifier quote is double quote") + func cassandraIdentifierQuote() { + #expect(DatabaseType.cassandra.identifierQuote == "\"") + } + + @Test("ScyllaDB identifier quote is double quote") + func scylladbIdentifierQuote() { + #expect(DatabaseType.scylladb.identifierQuote == "\"") + } + + @Test("Cassandra icon name is cassandra-icon") + func cassandraIconName() { + #expect(DatabaseType.cassandra.iconName == "cassandra-icon") + } + + @Test("ScyllaDB icon name is scylladb-icon") + func scylladbIconName() { + #expect(DatabaseType.scylladb.iconName == "scylladb-icon") + } + + @Test("Cassandra theme color matches Theme.cassandraColor") + func cassandraThemeColor() { + #expect(DatabaseType.cassandra.themeColor == Theme.cassandraColor) + } + + @Test("ScyllaDB theme color matches Theme.scylladbColor") + func scylladbThemeColor() { + #expect(DatabaseType.scylladb.themeColor == Theme.scylladbColor) + } + + @Test("Cassandra is a downloadable plugin") + func cassandraIsDownloadablePlugin() { + #expect(DatabaseType.cassandra.isDownloadablePlugin == true) + } + + @Test("ScyllaDB is a downloadable plugin") + func scylladbIsDownloadablePlugin() { + #expect(DatabaseType.scylladb.isDownloadablePlugin == true) + } + + @Test("Cassandra included in allCases") + func cassandraIncludedInAllCases() { + #expect(DatabaseType.allCases.contains(.cassandra)) + } + + @Test("ScyllaDB included in allCases") + func scylladbIncludedInAllCases() { + #expect(DatabaseType.allCases.contains(.scylladb)) + } +} diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 01447574..57054253 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -37,9 +37,9 @@ struct DatabaseTypeTests { #expect(DatabaseType.mongodb.defaultPort == 27_017) } - @Test("allKnownTypes count is 11") + @Test("allKnownTypes count is 13") func testAllKnownTypesCount() { - #expect(DatabaseType.allKnownTypes.count == 11) + #expect(DatabaseType.allKnownTypes.count == 13) } @Test("allCases shim matches allKnownTypes") @@ -58,7 +58,9 @@ struct DatabaseTypeTests { (DatabaseType.mssql, "SQL Server"), (DatabaseType.oracle, "Oracle"), (DatabaseType.clickhouse, "ClickHouse"), - (DatabaseType.duckdb, "DuckDB") + (DatabaseType.duckdb, "DuckDB"), + (DatabaseType.cassandra, "Cassandra"), + (DatabaseType.scylladb, "ScyllaDB") ]) func testRawValueMatchesDisplayName(dbType: DatabaseType, expectedRawValue: String) { #expect(dbType.rawValue == expectedRawValue) diff --git a/docs/databases/cassandra.mdx b/docs/databases/cassandra.mdx new file mode 100644 index 00000000..e73759f7 --- /dev/null +++ b/docs/databases/cassandra.mdx @@ -0,0 +1,356 @@ +--- +title: Cassandra / ScyllaDB +description: Connect to Cassandra and ScyllaDB clusters, browse keyspaces, and run CQL queries +--- + +# Cassandra / ScyllaDB Connections + +TablePro supports Apache Cassandra 3.11+ and ScyllaDB 4.0+ via the CQL native protocol. Browse keyspaces, inspect table structures, view materialized views, and run CQL queries from the editor. + +## Quick setup + + + + Click **New Connection** from the Welcome screen or **File** > **New Connection** + + + Choose **Cassandra** or **ScyllaDB** from the database type selector + + + Fill in host, port, username, password, and keyspace + + + Click **Test Connection**, then **Create** + + + +## Connection settings + +### Required fields + +| Field | Description | Default | +|-------|-------------|---------| +| **Name** | Connection identifier | - | +| **Host** | Contact point hostname or IP | `localhost` | +| **Port** | CQL native transport port | `9042` | + +### Optional fields + +| Field | Description | +|-------|-------------| +| **Username** | Authentication username | +| **Password** | Authentication password | +| **Keyspace** | Default keyspace to use after connecting | + + +Local Cassandra installations typically have authentication disabled. Leave username and password empty for local development. + + +## Connection URL format + +Import connections using a Cassandra or ScyllaDB URL. + +See [Connection URL Reference](/databases/connection-urls#cassandra--scylladb) for the full URL format. + +## Example configurations + +### Local development server + +```text +Name: Local Cassandra +Host: localhost +Port: 9042 +Username: (empty) +Password: (empty) +Keyspace: my_keyspace +``` + +### Docker Cassandra container + +```text +Name: Docker Cassandra +Host: localhost +Port: 9042 (or your mapped port) +Username: cassandra +Password: cassandra +Keyspace: (empty) +``` + +### DataStax Astra DB (Cloud) + +```text +Name: Astra DB +Host: -.apps.astra.datastax.com +Port: 29042 +Username: (Client ID from token) +Password: (Client Secret from token) +Keyspace: my_keyspace +``` + + +Astra DB requires a Secure Connect Bundle for TLS. Download the bundle from the Astra dashboard and configure it in the SSL/TLS section. + + +### Remote server + +```text +Name: Production Cassandra +Host: cassandra.example.com +Port: 9042 +Username: app_user +Password: (secure password) +Keyspace: production +``` + + +For production Cassandra clusters, consider using [SSH tunneling](/databases/ssh-tunneling) for secure connections. + + +## SSL/TLS connections + +Configure SSL in the **SSL/TLS** section of the connection form. + +| SSL Mode | Description | +|----------|-------------| +| **Disabled** | No SSL encryption | +| **Required** | Require SSL encryption | +| **Verify CA** | Require SSL and verify the server certificate against a CA | + +For **Verify CA** mode, provide the path to your CA certificate file. You can also provide optional client certificate and key files for mutual TLS. + + +If you'd rather skip SSL certificate setup, [SSH tunneling](/databases/ssh-tunneling) encrypts all traffic through an SSH tunnel instead. + + +## SSH tunnel support + +Connect to Cassandra through an SSH tunnel for secure access to remote clusters. See [SSH Tunneling](/databases/ssh-tunneling) for setup instructions. + +## Features + +### Keyspace browsing + +After connecting, the sidebar lists all keyspaces. Expand a keyspace to see its tables, materialized views, user-defined types (UDTs), and secondary indexes. + +1. Click the connection name in the sidebar +2. Expand a keyspace to see its objects +3. Click a table to view its data + +### Table structure + +View the full schema for any table: + +- **Columns**: name, CQL type, clustering order +- **Partition key**: columns that determine data distribution +- **Clustering columns**: columns that determine row ordering within a partition +- **Secondary indexes**: index name, target column, index class +- **Table options**: compaction strategy, compression, TTL defaults, gc_grace_seconds + +### Materialized views + +Browse materialized views alongside tables in the sidebar. View their definition, base table, and column mappings. + +### Data grid + +Browse table data with pagination. Cell values display with CQL type-aware formatting: + +- **text/varchar** values show as plain text +- **int/bigint/varint** display as numbers +- **uuid/timeuuid** display as formatted UUIDs +- **timestamp** values display with configurable date formatting +- **map/set/list/tuple** display as formatted collections +- **blob** values display as hex + +### CQL editor + +Execute CQL statements directly in the editor tab: + +```sql +-- Select with partition key restriction +SELECT * FROM users WHERE user_id = 123e4567-e89b-12d3-a456-426614174000; + +-- Insert data +INSERT INTO users (user_id, name, email, created_at) +VALUES (uuid(), 'Alice', 'alice@example.com', toTimestamp(now())); + +-- Update with TTL +UPDATE users USING TTL 86400 +SET email = 'new@example.com' +WHERE user_id = 123e4567-e89b-12d3-a456-426614174000; + +-- Delete +DELETE FROM users WHERE user_id = 123e4567-e89b-12d3-a456-426614174000; + +-- Create table +CREATE TABLE IF NOT EXISTS events ( + event_id timeuuid, + user_id uuid, + event_type text, + payload text, + PRIMARY KEY ((user_id), event_id) +) WITH CLUSTERING ORDER BY (event_id DESC); + +-- Create index +CREATE INDEX ON users (email); + +-- Describe table +DESCRIBE TABLE users; +``` + +## CQL-specific notes + +Cassandra Query Language (CQL) looks like SQL but has important differences: + +### No JOINs +CQL does not support JOIN operations. Design your data model around query patterns, denormalizing data across multiple tables as needed. + +### No subqueries + +CQL does not support subqueries. Break complex queries into multiple sequential statements. + +### Partition key restrictions + +Every SELECT query must include the full partition key in the WHERE clause, unless you use `ALLOW FILTERING` (which scans the entire cluster and should be avoided in production). + +```sql +-- Good: includes partition key +SELECT * FROM orders WHERE customer_id = 42; + +-- Bad: missing partition key, requires ALLOW FILTERING +SELECT * FROM orders WHERE total > 100 ALLOW FILTERING; +``` + +### ALLOW FILTERING + +The `ALLOW FILTERING` clause forces a full cluster scan. It works for development and small datasets but causes timeouts and performance issues on production clusters. TablePro shows a warning when a query includes `ALLOW FILTERING`. + +### Lightweight transactions + +Cassandra supports conditional writes using `IF` clauses (lightweight transactions / compare-and-set): + +```sql +INSERT INTO users (user_id, email) VALUES (uuid(), 'alice@example.com') IF NOT EXISTS; +UPDATE users SET email = 'new@example.com' WHERE user_id = ? IF email = 'old@example.com'; +``` + +### TTL and Writetime + +Set per-row or per-column TTL (time-to-live) in seconds: + +```sql +INSERT INTO cache (key, value) VALUES ('k1', 'v1') USING TTL 3600; +SELECT TTL(value), WRITETIME(value) FROM cache WHERE key = 'k1'; +``` + +## Troubleshooting + +### Connection refused + +**Symptoms**: "Connection refused" or timeout + +**Causes and Solutions**: + +1. **Cassandra not running** + ```bash + # Check if Cassandra is running + nodetool status + + # Start Cassandra (macOS with Homebrew) + brew services start cassandra + + # Start Cassandra (Docker) + docker run -d -p 9042:9042 cassandra:latest + ``` + +2. **Wrong port** + - Verify `native_transport_port` in `cassandra.yaml` + - Default CQL port is `9042`, not `9160` (Thrift, deprecated) + +3. **Cassandra not listening on the expected address** + - Check `rpc_address` and `listen_address` in `cassandra.yaml` + - For remote connections, set `rpc_address` to `0.0.0.0` + +### Authentication failed + +**Symptoms**: "Provided username and/or password are incorrect" + +**Solutions**: + +1. Verify credentials match the role configured in Cassandra +2. Check if authentication is enabled: + ```yaml + # cassandra.yaml + authenticator: PasswordAuthenticator + ``` +3. Default superuser is `cassandra` / `cassandra` (change this in production) + +### Connection timeout + +**Symptoms**: Connection hangs or times out + +**Solutions**: + +1. Verify the host and port are correct +2. Check network connectivity and firewall rules (port 9042) +3. For cloud-hosted Cassandra, ensure your IP is in the allowed list +4. Increase the connection timeout in the Advanced tab + +### Read timeout + +**Symptoms**: "Read timed out" on queries + +**Solutions**: + +1. Add the full partition key to your WHERE clause +2. Remove `ALLOW FILTERING` and redesign the query +3. Check cluster health with `nodetool status` +4. Reduce the result set with `LIMIT` + +## Known limitations + +- **Multi-datacenter**: connecting to a specific datacenter is not yet configurable. TablePro connects to whichever node the contact point resolves to. +- **User-Defined Functions (UDFs)**: UDFs and UDAs are not displayed in the sidebar but can be used in CQL queries. +- **BATCH statements**: supported in the CQL editor but not generated by the change tracking system. +- **Counter tables**: counter columns are read-only in the data grid. Use the CQL editor for counter updates. +- **Large partitions**: partitions with millions of rows are paginated automatically to prevent memory issues. + +## Performance tips + +### Query performance + +For Cassandra clusters with large datasets: + +1. Always include the partition key in WHERE clauses +2. Avoid `ALLOW FILTERING` in production +3. Use `LIMIT` to cap result sets +4. Use `TOKEN()` for range scans across partitions + +### Monitoring + +Check cluster health and performance: + +```sql +-- Check cluster status (via nodetool, not CQL) +-- nodetool status +-- nodetool info + +-- Check table statistics +SELECT * FROM system.size_estimates WHERE keyspace_name = 'my_keyspace'; +``` + +## Next steps + + + + Connect securely to remote Cassandra clusters + + + Master the query editor features + + + Import and export Cassandra data + + + Browse and edit data in the data grid + + diff --git a/docs/databases/connection-urls.mdx b/docs/databases/connection-urls.mdx index 42cc9aa0..7d1f7253 100644 --- a/docs/databases/connection-urls.mdx +++ b/docs/databases/connection-urls.mdx @@ -25,6 +25,8 @@ TablePro parses standard database connection URLs for importing connections, ope | `sqlserver://` | Microsoft SQL Server (alias) | | `oracle://` | Oracle Database | | `jdbc:oracle:thin:@//` | Oracle Database (JDBC thin) | +| `cassandra://` | Cassandra | +| `scylladb://` | ScyllaDB | Append `+ssh` to any scheme (except SQLite) to use an SSH tunnel: @@ -164,6 +166,16 @@ redis://:password@host:6379/2 # database index 2 rediss://:password@host:6380 # TLS, default index 0 ``` +### Cassandra / ScyllaDB + +The path component sets the default keyspace. Both `cassandra://` and `scylladb://` schemes use the same format. + +```text +cassandra://user:pass@host:9042/my_keyspace +scylladb://user:pass@host:9042/my_keyspace +cassandra://host:9042 # no auth, no default keyspace +``` + ### SSH Tunnel | Parameter | Description | diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index cea7c5fa..ba7f54e0 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -1,15 +1,15 @@ --- title: Connection Management -description: Create, organize, and manage database connections across 10 supported engines in TablePro +description: Create, organize, and manage database connections across 12 supported engines in TablePro --- # Connection Management -TablePro connects to ten database systems from a single interface. Create connections, organize them with colors, tags, and groups, and switch between them without leaving your workflow. +TablePro connects to twelve database systems from a single interface. Create connections, organize them with colors, tags, and groups, and switch between them without leaving your workflow. ## Supported Databases -TablePro supports ten database systems natively: +TablePro supports twelve database systems natively: @@ -42,6 +42,9 @@ TablePro supports ten database systems natively: ClickHouse OLAP database via HTTP API. Default port: 8123 + + Cassandra 3.11+ and ScyllaDB 4.0+ via CQL native protocol. Default port: 9042 + ## Creating a Connection @@ -105,7 +108,7 @@ open "mysql://root:secret@localhost:3306/myapp" open "redis://:password@localhost:6379" ``` -TablePro registers `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `mssql`, `sqlserver`, `oracle`, and `clickhouse` as URL schemes on macOS, so the OS routes these URLs directly to the app. +TablePro registers `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `mssql`, `sqlserver`, `oracle`, `clickhouse`, `cassandra`, and `scylladb` as URL schemes on macOS, so the OS routes these URLs directly to the app. **What happens:** @@ -128,7 +131,7 @@ This is different from **Import from URL**, which fills in the connection form s | Field | Description | |-------|-------------| | **Name** | A friendly name to identify this connection | -| **Type** | Database type: MySQL, MariaDB, PostgreSQL, SQLite, MongoDB, Redis, Redshift, SQL Server, or Oracle | +| **Type** | Database type: MySQL, MariaDB, PostgreSQL, SQLite, MongoDB, Redis, Redshift, SQL Server, Oracle, ClickHouse, Cassandra, or ScyllaDB | #### Appearance Section @@ -569,6 +572,8 @@ TablePro auto-fills the port when you select a database type: | Microsoft SQL Server | 1433 | | Oracle Database | 1521 | | ClickHouse | 8123 | +| Cassandra | 9042 | +| ScyllaDB | 9042 | ## Related Guides @@ -600,6 +605,9 @@ TablePro auto-fills the port when you select a database type: ClickHouse OLAP database connections + + Cassandra and ScyllaDB CQL connections + Secure connections through SSH diff --git a/docs/docs.json b/docs/docs.json index 79791031..2e2a5bfe 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -41,6 +41,7 @@ "databases/redshift", "databases/oracle", "databases/clickhouse", + "databases/cassandra", "databases/mssql", "databases/oracle", "databases/ssh-tunneling" @@ -134,6 +135,7 @@ "vi/databases/redshift", "vi/databases/oracle", "vi/databases/clickhouse", + "vi/databases/cassandra", "vi/databases/mssql", "vi/databases/oracle", "vi/databases/ssh-tunneling" @@ -231,6 +233,8 @@ "zh/databases/redshift", "zh/databases/oracle", "zh/databases/clickhouse", + "zh/databases/cassandra", + "zh/databases/duckdb", "zh/databases/mssql", "zh/databases/oracle", "zh/databases/ssh-tunneling" diff --git a/docs/vi/databases/cassandra.mdx b/docs/vi/databases/cassandra.mdx new file mode 100644 index 00000000..08da1a53 --- /dev/null +++ b/docs/vi/databases/cassandra.mdx @@ -0,0 +1,357 @@ +--- +title: Cassandra / ScyllaDB +description: Kết nối Cassandra và ScyllaDB cluster, duyệt keyspace và chạy truy vấn CQL +--- + +# Kết nối Cassandra / ScyllaDB + +TablePro hỗ trợ Apache Cassandra 3.11+ và ScyllaDB 4.0+ qua giao thức CQL native. Duyệt keyspace, xem cấu trúc bảng, materialized view và chạy truy vấn CQL từ editor. + +## Thiết lập Nhanh + + + + Nhấp **New Connection** từ màn hình Chào mừng hoặc **File** > **New Connection** + + + Chọn **Cassandra** hoặc **ScyllaDB** từ trình chọn loại database + + + Điền host, port, username, password và keyspace + + + Nhấp **Test Connection**, rồi **Create** + + + +## Cài đặt Kết nối + +### Trường Bắt buộc + +| Trường | Mô tả | Mặc định | +|-------|-------------|---------| +| **Name** | Tên định danh kết nối | - | +| **Host** | Hostname hoặc IP contact point | `localhost` | +| **Port** | Cổng CQL native transport | `9042` | + +### Trường Tùy chọn + +| Trường | Mô tả | +|-------|-------------| +| **Username** | Tên đăng nhập | +| **Password** | Mật khẩu | +| **Keyspace** | Keyspace mặc định sau khi kết nối | + + +Cassandra cài local thường tắt xác thực. Để trống username và password cho dev local. + + +## Định dạng URL + +Import kết nối bằng Cassandra hoặc ScyllaDB URL. + +Xem [Tham chiếu URL kết nối](/vi/databases/connection-urls#cassandra--scylladb) để biết format đầy đủ. + +## Ví dụ Cấu hình + +### Server Dev Local + +```text +Name: Local Cassandra +Host: localhost +Port: 9042 +Username: (để trống) +Password: (để trống) +Keyspace: my_keyspace +``` + +### Docker Cassandra Container + +```text +Name: Docker Cassandra +Host: localhost +Port: 9042 (hoặc cổng mapped) +Username: cassandra +Password: cassandra +Keyspace: (để trống) +``` + +### DataStax Astra DB (Cloud) + +```text +Name: Astra DB +Host: -.apps.astra.datastax.com +Port: 29042 +Username: (Client ID từ token) +Password: (Client Secret từ token) +Keyspace: my_keyspace +``` + + +Astra DB yêu cầu Secure Connect Bundle cho TLS. Tải bundle từ Astra dashboard và cấu hình trong phần SSL/TLS. + + +### Server Từ xa + +```text +Name: Production Cassandra +Host: cassandra.example.com +Port: 9042 +Username: app_user +Password: (mật khẩu bảo mật) +Keyspace: production +``` + + +Cho Cassandra cluster production, nên dùng [SSH tunneling](/vi/databases/ssh-tunneling) để kết nối an toàn. + + +## Kết nối SSL/TLS + +Cấu hình SSL trong phần **SSL/TLS** của form kết nối. + +| Chế độ SSL | Mô tả | +|----------|-------------| +| **Disabled** | Không mã hóa SSL | +| **Required** | Bắt buộc SSL | +| **Verify CA** | Bắt buộc SSL và xác minh chứng chỉ server với CA | + +Với **Verify CA**, cung cấp file chứng chỉ CA. Tùy chọn thêm file chứng chỉ client và key cho mutual TLS. + + +Nếu không muốn cấu hình chứng chỉ SSL, [SSH tunneling](/vi/databases/ssh-tunneling) mã hóa toàn bộ traffic qua tunnel SSH. + + +## Hỗ trợ SSH Tunnel + +Kết nối Cassandra qua SSH tunnel để truy cập an toàn cluster từ xa. Xem [SSH Tunneling](/vi/databases/ssh-tunneling) để biết cách cài đặt. + +## Tính năng + +### Duyệt Keyspace + +Sau khi kết nối, sidebar liệt kê tất cả keyspace. Mở rộng keyspace để xem bảng, materialized view, user-defined type (UDT) và secondary index. + +1. Nhấp tên kết nối trong sidebar +2. Mở rộng keyspace để xem các đối tượng +3. Nhấp bảng để xem dữ liệu + +### Cấu trúc Bảng + +Xem schema đầy đủ cho bất kỳ bảng nào: + +- **Cột**: tên, kiểu CQL, thứ tự clustering +- **Partition key**: cột xác định phân phối dữ liệu +- **Clustering column**: cột xác định thứ tự hàng trong partition +- **Secondary index**: tên index, cột đích, lớp index +- **Tùy chọn bảng**: chiến lược compaction, compression, TTL mặc định, gc_grace_seconds + +### Materialized View + +Duyệt materialized view cạnh bảng trong sidebar. Xem định nghĩa, bảng gốc và ánh xạ cột. + +### Data Grid + +Duyệt dữ liệu bảng với phân trang. Giá trị hiển thị theo kiểu CQL: + +- **text/varchar** hiển thị văn bản thuần +- **int/bigint/varint** hiển thị số +- **uuid/timeuuid** hiển thị UUID đã format +- **timestamp** hiển thị với format ngày tùy chỉnh +- **map/set/list/tuple** hiển thị collection đã format +- **blob** hiển thị dạng hex + +### CQL Editor + +Chạy lệnh CQL trực tiếp trong editor tab: + +```sql +-- Select với partition key +SELECT * FROM users WHERE user_id = 123e4567-e89b-12d3-a456-426614174000; + +-- Thêm dữ liệu +INSERT INTO users (user_id, name, email, created_at) +VALUES (uuid(), 'Alice', 'alice@example.com', toTimestamp(now())); + +-- Cập nhật với TTL +UPDATE users USING TTL 86400 +SET email = 'new@example.com' +WHERE user_id = 123e4567-e89b-12d3-a456-426614174000; + +-- Xóa +DELETE FROM users WHERE user_id = 123e4567-e89b-12d3-a456-426614174000; + +-- Tạo bảng +CREATE TABLE IF NOT EXISTS events ( + event_id timeuuid, + user_id uuid, + event_type text, + payload text, + PRIMARY KEY ((user_id), event_id) +) WITH CLUSTERING ORDER BY (event_id DESC); + +-- Tạo index +CREATE INDEX ON users (email); + +-- Mô tả bảng +DESCRIBE TABLE users; +``` + +## Lưu ý CQL + +Cassandra Query Language (CQL) giống SQL nhưng có các khác biệt quan trọng: + +### Không có JOIN + +CQL không hỗ trợ JOIN. Thiết kế data model theo query pattern, denormalize dữ liệu qua nhiều bảng khi cần. + +### Không có Subquery + +CQL không hỗ trợ subquery. Tách truy vấn phức tạp thành nhiều lệnh tuần tự. + +### Hạn chế Partition Key + +Mọi truy vấn SELECT phải có đầy đủ partition key trong WHERE, trừ khi dùng `ALLOW FILTERING` (quét toàn bộ cluster, nên tránh trong production). + +```sql +-- Tốt: có partition key +SELECT * FROM orders WHERE customer_id = 42; + +-- Không tốt: thiếu partition key, cần ALLOW FILTERING +SELECT * FROM orders WHERE total > 100 ALLOW FILTERING; +``` + +### ALLOW FILTERING + +`ALLOW FILTERING` buộc quét toàn bộ cluster. Dùng được cho dev và dataset nhỏ nhưng gây timeout trên cluster production. TablePro hiển thị cảnh báo khi truy vấn có `ALLOW FILTERING`. + +### Lightweight Transaction + +Cassandra hỗ trợ ghi có điều kiện dùng mệnh đề `IF` (lightweight transaction / compare-and-set): + +```sql +INSERT INTO users (user_id, email) VALUES (uuid(), 'alice@example.com') IF NOT EXISTS; +UPDATE users SET email = 'new@example.com' WHERE user_id = ? IF email = 'old@example.com'; +``` + +### TTL và Writetime + +Đặt TTL (thời gian sống) theo giây cho mỗi hàng hoặc cột: + +```sql +INSERT INTO cache (key, value) VALUES ('k1', 'v1') USING TTL 3600; +SELECT TTL(value), WRITETIME(value) FROM cache WHERE key = 'k1'; +``` + +## Khắc phục sự cố + +### Kết nối Bị từ chối + +**Triệu chứng**: "Connection refused" hoặc timeout + +**Nguyên nhân và Giải pháp**: + +1. **Cassandra không chạy** + ```bash + # Kiểm tra Cassandra + nodetool status + + # Khởi động (macOS Homebrew) + brew services start cassandra + + # Khởi động (Docker) + docker run -d -p 9042:9042 cassandra:latest + ``` + +2. **Sai cổng** + - Kiểm tra `native_transport_port` trong `cassandra.yaml` + - Cổng CQL mặc định là `9042`, không phải `9160` (Thrift, đã deprecated) + +3. **Cassandra không lắng nghe đúng địa chỉ** + - Kiểm tra `rpc_address` và `listen_address` trong `cassandra.yaml` + - Cho kết nối remote, đặt `rpc_address` thành `0.0.0.0` + +### Xác thực Thất bại + +**Triệu chứng**: "Provided username and/or password are incorrect" + +**Giải pháp**: + +1. Kiểm tra thông tin đăng nhập khớp role cấu hình trong Cassandra +2. Kiểm tra xác thực đã bật: + ```yaml + # cassandra.yaml + authenticator: PasswordAuthenticator + ``` +3. Superuser mặc định là `cassandra` / `cassandra` (đổi trong production) + +### Hết thời gian Kết nối + +**Triệu chứng**: Kết nối treo hoặc timeout + +**Giải pháp**: + +1. Kiểm tra host và port đúng +2. Kiểm tra mạng và firewall (cổng 9042) +3. Cho Cassandra cloud, đảm bảo IP nằm trong danh sách cho phép +4. Tăng timeout kết nối trong tab Advanced + +### Read Timeout + +**Triệu chứng**: "Read timed out" khi truy vấn + +**Giải pháp**: + +1. Thêm đầy đủ partition key vào WHERE +2. Bỏ `ALLOW FILTERING` và thiết kế lại truy vấn +3. Kiểm tra sức khỏe cluster bằng `nodetool status` +4. Giảm tập kết quả bằng `LIMIT` + +## Hạn chế + +- **Multi-datacenter**: chưa hỗ trợ cấu hình datacenter cụ thể. TablePro kết nối đến node mà contact point phân giải. +- **User-Defined Function (UDF)**: UDF và UDA không hiển thị trong sidebar nhưng dùng được trong truy vấn CQL. +- **BATCH**: hỗ trợ trong CQL editor nhưng change tracking không tạo BATCH. +- **Counter table**: cột counter chỉ đọc trong data grid. Dùng CQL editor để cập nhật counter. +- **Partition lớn**: partition có hàng triệu hàng được phân trang tự động để tránh vấn đề bộ nhớ. + +## Mẹo Hiệu suất + +### Hiệu suất Truy vấn + +Cho Cassandra cluster có dataset lớn: + +1. Luôn có partition key trong WHERE +2. Tránh `ALLOW FILTERING` trong production +3. Dùng `LIMIT` để giới hạn kết quả +4. Dùng `TOKEN()` cho range scan qua nhiều partition + +### Giám sát + +Kiểm tra sức khỏe và hiệu suất cluster: + +```sql +-- Kiểm tra trạng thái cluster (qua nodetool, không phải CQL) +-- nodetool status +-- nodetool info + +-- Kiểm tra thống kê bảng +SELECT * FROM system.size_estimates WHERE keyspace_name = 'my_keyspace'; +``` + +## Các bước tiếp theo + + + + Kết nối an toàn đến Cassandra cluster từ xa + + + Các tính năng query editor + + + Import và export dữ liệu Cassandra + + + Duyệt và chỉnh sửa dữ liệu trong data grid + + diff --git a/docs/vi/databases/connection-urls.mdx b/docs/vi/databases/connection-urls.mdx index bd17b287..09a8b26b 100644 --- a/docs/vi/databases/connection-urls.mdx +++ b/docs/vi/databases/connection-urls.mdx @@ -25,6 +25,8 @@ TablePro phân tích URL kết nối database chuẩn để import kết nối, | `sqlserver://` | Microsoft SQL Server (alias) | | `oracle://` | Oracle Database | | `jdbc:oracle:thin:@//` | Oracle Database (JDBC thin) | +| `cassandra://` | Cassandra | +| `scylladb://` | ScyllaDB | Thêm `+ssh` vào bất kỳ scheme nào (trừ SQLite) để dùng SSH tunnel: @@ -164,6 +166,16 @@ redis://:password@host:6379/2 # database index 2 rediss://:password@host:6380 # TLS, index mặc định 0 ``` +### Cassandra / ScyllaDB + +Phần path đặt keyspace mặc định. Cả hai scheme `cassandra://` và `scylladb://` dùng cùng format. + +```text +cassandra://user:pass@host:9042/my_keyspace +scylladb://user:pass@host:9042/my_keyspace +cassandra://host:9042 # không auth, không keyspace mặc định +``` + ### SSH Tunnel | Parameter | Mô tả | diff --git a/docs/vi/databases/overview.mdx b/docs/vi/databases/overview.mdx index a91785f3..66416c18 100644 --- a/docs/vi/databases/overview.mdx +++ b/docs/vi/databases/overview.mdx @@ -1,15 +1,15 @@ --- title: Quản lý Kết nối -description: Tạo, tổ chức và quản lý kết nối đến 10 hệ quản trị cơ sở dữ liệu từ một giao diện duy nhất +description: Tạo, tổ chức và quản lý kết nối đến 12 hệ quản trị cơ sở dữ liệu từ một giao diện duy nhất --- # Quản lý Kết nối -TablePro kết nối được đến 10 hệ quản trị cơ sở dữ liệu từ cùng một giao diện. Tạo kết nối, sắp xếp bằng màu sắc, tag và nhóm, rồi chuyển đổi giữa chúng mà không cần rời khỏi cửa sổ làm việc. +TablePro kết nối được đến 12 hệ quản trị cơ sở dữ liệu từ cùng một giao diện. Tạo kết nối, sắp xếp bằng màu sắc, tag và nhóm, rồi chuyển đổi giữa chúng mà không cần rời khỏi cửa sổ làm việc. ## Cơ sở dữ liệu được hỗ trợ -TablePro hỗ trợ 10 hệ thống cơ sở dữ liệu: +TablePro hỗ trợ 12 hệ thống cơ sở dữ liệu: @@ -42,6 +42,9 @@ TablePro hỗ trợ 10 hệ thống cơ sở dữ liệu: Cơ sở dữ liệu OLAP ClickHouse qua HTTP API. Cổng mặc định: 8123 + + Cassandra 3.11+ và ScyllaDB 4.0+ qua giao thức CQL native. Cổng mặc định: 9042 + ## Tạo Kết nối @@ -105,7 +108,7 @@ open "mysql://root:secret@localhost:3306/myapp" open "redis://:password@localhost:6379" ``` -TablePro đăng ký `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `mssql`, `sqlserver`, `oracle` và `clickhouse` là các URL scheme trên macOS, nên hệ điều hành sẽ chuyển hướng các URL này trực tiếp đến ứng dụng. +TablePro đăng ký `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `mssql`, `sqlserver`, `oracle`, `clickhouse`, `duckdb`, `cassandra` và `scylladb` là các URL scheme trên macOS, nên hệ điều hành sẽ chuyển hướng các URL này trực tiếp đến ứng dụng. **Khi mở URL:** @@ -128,7 +131,7 @@ Khác với **Import from URL** (điền form để bạn xem xét và lưu), m | Trường | Mô tả | |-------|-------------| | **Name** | Tên thân thiện để xác định kết nối này | -| **Type** | Loại cơ sở dữ liệu: MySQL, MariaDB, PostgreSQL, SQLite, MongoDB, Redis, Redshift, SQL Server hoặc Oracle | +| **Type** | Loại cơ sở dữ liệu: MySQL, MariaDB, PostgreSQL, SQLite, MongoDB, Redis, Redshift, SQL Server, Oracle, ClickHouse, DuckDB, Cassandra hoặc ScyllaDB | #### Phần Appearance @@ -569,6 +572,8 @@ TablePro tự động điền cổng khi chọn loại database: | Microsoft SQL Server | 1433 | | Oracle Database | 1521 | | ClickHouse | 8123 | +| Cassandra | 9042 | +| ScyllaDB | 9042 | ## Hướng dẫn Liên quan @@ -600,6 +605,9 @@ TablePro tự động điền cổng khi chọn loại database: Kết nối cơ sở dữ liệu OLAP ClickHouse + + Kết nối Cassandra và ScyllaDB qua CQL + Kết nối an toàn qua SSH diff --git a/docs/zh/databases/cassandra.mdx b/docs/zh/databases/cassandra.mdx new file mode 100644 index 00000000..bddb1133 --- /dev/null +++ b/docs/zh/databases/cassandra.mdx @@ -0,0 +1,356 @@ +--- +title: Cassandra / ScyllaDB +description: 连接 Cassandra 和 ScyllaDB 集群,浏览 keyspace,执行 CQL 查询 +--- + +# Cassandra / ScyllaDB 连接 + +TablePro 通过 CQL 原生协议支持 Apache Cassandra 3.11+ 和 ScyllaDB 4.0+。浏览 keyspace、查看表结构、查看物化视图,并在编辑器中执行 CQL 查询。 + +## 快速设置 + + + + 在欢迎界面点击 **New Connection**,或选择 **File** > **New Connection** + + + 从数据库类型选择器中选择 **Cassandra** 或 **ScyllaDB** + + + 填写 host、port、username、password 和 keyspace + + + 点击 **Test Connection**,然后点击 **Create** + + + +## 连接设置 + +### 必填字段 + +| 字段 | 描述 | 默认值 | +|-------|-------------|---------| +| **Name** | 连接标识名称 | - | +| **Host** | Contact point 主机名或 IP | `localhost` | +| **Port** | CQL 原生传输端口 | `9042` | + +### 可选字段 + +| 字段 | 描述 | +|-------|-------------| +| **Username** | 认证用户名 | +| **Password** | 认证密码 | +| **Keyspace** | 连接后使用的默认 keyspace | + + +本地 Cassandra 安装通常未启用认证。本地开发时可以留空 username 和 password。 + + +## 连接 URL 格式 + +使用 Cassandra 或 ScyllaDB URL 导入连接。 + +参阅 [连接 URL 参考](/zh/databases/connection-urls#cassandra--scylladb) 了解完整的 URL 格式。 + +## 配置示例 + +### 本地开发服务器 + +```text +Name: Local Cassandra +Host: localhost +Port: 9042 +Username: (留空) +Password: (留空) +Keyspace: my_keyspace +``` + +### Docker Cassandra 容器 + +```text +Name: Docker Cassandra +Host: localhost +Port: 9042(或你映射的端口) +Username: cassandra +Password: cassandra +Keyspace: (留空) +``` + +### DataStax Astra DB(云) + +```text +Name: Astra DB +Host: -.apps.astra.datastax.com +Port: 29042 +Username: (来自 token 的 Client ID) +Password: (来自 token 的 Client Secret) +Keyspace: my_keyspace +``` + + +Astra DB 需要 Secure Connect Bundle 进行 TLS 连接。从 Astra 控制面板下载 bundle 并在 SSL/TLS 部分配置。 + + +### 远程服务器 + +```text +Name: Production Cassandra +Host: cassandra.example.com +Port: 9042 +Username: app_user +Password: (安全密码) +Keyspace: production +``` + + +对于生产环境的 Cassandra 集群,建议使用 [SSH tunneling](/zh/databases/ssh-tunneling) 进行安全连接。 + + +## SSL/TLS 连接 + +在连接表单的 **SSL/TLS** 部分配置 SSL。 + +| SSL 模式 | 描述 | +|----------|-------------| +| **Disabled** | 不使用 SSL 加密 | +| **Required** | 要求 SSL 加密 | +| **Verify CA** | 要求 SSL 并验证服务器证书是否由 CA 签发 | + +对于 **Verify CA** 模式,需要提供 CA 证书文件路径。也可以提供可选的客户端证书和密钥文件以实现 mutual TLS。 + + +如果不想配置 SSL 证书,[SSH tunneling](/zh/databases/ssh-tunneling) 可以通过 SSH tunnel 加密所有流量。 + + +## SSH Tunnel 支持 + +通过 SSH tunnel 连接 Cassandra 以安全访问远程集群。设置方法请参阅 [SSH Tunneling](/zh/databases/ssh-tunneling)。 + +## 功能 + +### Keyspace 浏览 + +连接后,侧栏会列出所有 keyspace。展开 keyspace 可以查看其表、物化视图、用户自定义类型(UDT)和二级索引。 + +1. 点击侧栏中的连接名称 +2. 展开 keyspace 查看其对象 +3. 点击表查看数据 + +### 表结构 + +查看任意表的完整 schema: + +- **列**:名称、CQL 类型、聚簇排序 +- **分区键**:决定数据分布的列 +- **聚簇列**:决定分区内行排序的列 +- **二级索引**:索引名称、目标列、索引类 +- **表选项**:压缩策略、压缩方式、TTL 默认值、gc_grace_seconds + +### 物化视图 + +在侧栏中可以与表一起浏览物化视图。查看视图定义、基表和列映射。 + +### 数据网格 + +带分页的表数据浏览。单元格值以 CQL 类型感知的格式显示: + +- **text/varchar** 值显示为纯文本 +- **int/bigint/varint** 显示为数字 +- **uuid/timeuuid** 显示为格式化的 UUID +- **timestamp** 值以可配置的日期格式显示 +- **map/set/list/tuple** 显示为格式化的集合 +- **blob** 值显示为十六进制 + +### CQL 编辑器 + +在编辑器标签页中直接执行 CQL 语句: + +```sql +-- 使用分区键限制查询 +SELECT * FROM users WHERE user_id = 123e4567-e89b-12d3-a456-426614174000; + +-- 插入数据 +INSERT INTO users (user_id, name, email, created_at) +VALUES (uuid(), 'Alice', 'alice@example.com', toTimestamp(now())); + +-- 带 TTL 的更新 +UPDATE users USING TTL 86400 +SET email = 'new@example.com' +WHERE user_id = 123e4567-e89b-12d3-a456-426614174000; + +-- 删除 +DELETE FROM users WHERE user_id = 123e4567-e89b-12d3-a456-426614174000; + +-- 创建表 +CREATE TABLE IF NOT EXISTS events ( + event_id timeuuid, + user_id uuid, + event_type text, + payload text, + PRIMARY KEY ((user_id), event_id) +) WITH CLUSTERING ORDER BY (event_id DESC); + +-- 创建索引 +CREATE INDEX ON users (email); + +-- 描述表 +DESCRIBE TABLE users; +``` + +## CQL 特殊说明 + +Cassandra Query Language (CQL) 看起来像 SQL,但有重要区别: + +### 不支持 JOIN +CQL 不支持 JOIN 操作。请围绕查询模式设计数据模型,按需在多个表中反规范化数据。 + +### 不支持子查询 + +CQL 不支持子查询。将复杂查询拆分为多条顺序执行的语句。 + +### 分区键限制 + +每个 SELECT 查询的 WHERE 子句中必须包含完整的分区键,除非使用 `ALLOW FILTERING`(这会扫描整个集群,在生产环境中应避免使用)。 + +```sql +-- 正确:包含分区键 +SELECT * FROM orders WHERE customer_id = 42; + +-- 错误:缺少分区键,需要 ALLOW FILTERING +SELECT * FROM orders WHERE total > 100 ALLOW FILTERING; +``` + +### ALLOW FILTERING + +`ALLOW FILTERING` 子句会强制执行全集群扫描。它适用于开发和小型数据集,但在生产集群上会导致超时和性能问题。当查询包含 `ALLOW FILTERING` 时,TablePro 会显示警告。 + +### 轻量级事务 + +Cassandra 支持使用 `IF` 子句的条件写入(轻量级事务 / compare-and-set): + +```sql +INSERT INTO users (user_id, email) VALUES (uuid(), 'alice@example.com') IF NOT EXISTS; +UPDATE users SET email = 'new@example.com' WHERE user_id = ? IF email = 'old@example.com'; +``` + +### TTL 和 Writetime + +设置每行或每列的 TTL(生存时间),单位为秒: + +```sql +INSERT INTO cache (key, value) VALUES ('k1', 'v1') USING TTL 3600; +SELECT TTL(value), WRITETIME(value) FROM cache WHERE key = 'k1'; +``` + +## 故障排除 + +### 连接被拒绝 + +**症状**:"Connection refused" 或超时 + +**原因及解决方案**: + +1. **Cassandra 未运行** + ```bash + # 检查 Cassandra 是否运行 + nodetool status + + # 启动 Cassandra(macOS Homebrew) + brew services start cassandra + + # 启动 Cassandra(Docker) + docker run -d -p 9042:9042 cassandra:latest + ``` + +2. **端口错误** + - 检查 `cassandra.yaml` 中的 `native_transport_port` + - 默认 CQL 端口是 `9042`,不是 `9160`(Thrift,已废弃) + +3. **Cassandra 未监听预期地址** + - 检查 `cassandra.yaml` 中的 `rpc_address` 和 `listen_address` + - 对于远程连接,将 `rpc_address` 设为 `0.0.0.0` + +### 认证失败 + +**症状**:"Provided username and/or password are incorrect" + +**解决方案**: + +1. 验证凭据是否匹配 Cassandra 中配置的角色 +2. 检查认证是否已启用: + ```yaml + # cassandra.yaml + authenticator: PasswordAuthenticator + ``` +3. 默认超级用户为 `cassandra` / `cassandra`(生产环境中请更改) + +### 连接超时 + +**症状**:连接挂起或超时 + +**解决方案**: + +1. 验证 host 和 port 是否正确 +2. 检查网络连通性和防火墙规则(端口 9042) +3. 对于云托管的 Cassandra,确保你的 IP 已加入白名单 +4. 在 Advanced 标签页中增加连接超时时间 + +### 读取超时 + +**症状**:"Read timed out" + +**解决方案**: + +1. 在 WHERE 子句中添加完整的分区键 +2. 移除 `ALLOW FILTERING` 并重新设计查询 +3. 使用 `nodetool status` 检查集群健康状况 +4. 使用 `LIMIT` 限制结果集大小 + +## 已知限制 + +- **多数据中心**:尚不支持连接到特定数据中心。TablePro 连接到 contact point 解析到的节点。 +- **User-Defined Functions (UDFs)**:UDF 和 UDA 不会显示在侧栏中,但可以在 CQL 查询中使用。 +- **BATCH 语句**:在 CQL 编辑器中支持,但不会由变更跟踪系统生成。 +- **Counter 表**:counter 列在数据网格中为只读。使用 CQL 编辑器更新 counter。 +- **大分区**:包含数百万行的分区会自动分页以防止内存问题。 + +## 性能建议 + +### 查询性能 + +对于大数据集的 Cassandra 集群: + +1. 始终在 WHERE 子句中包含分区键 +2. 在生产环境中避免使用 `ALLOW FILTERING` +3. 使用 `LIMIT` 限制结果集大小 +4. 使用 `TOKEN()` 进行跨分区的范围扫描 + +### 监控 + +检查集群健康状况和性能: + +```sql +-- 检查集群状态(通过 nodetool,非 CQL) +-- nodetool status +-- nodetool info + +-- 检查表统计信息 +SELECT * FROM system.size_estimates WHERE keyspace_name = 'my_keyspace'; +``` + +## 后续步骤 + + + + 安全连接远程 Cassandra 集群 + + + 掌握查询编辑器功能 + + + 导入和导出 Cassandra 数据 + + + 在数据网格中浏览和编辑数据 + + diff --git a/docs/zh/databases/connection-urls.mdx b/docs/zh/databases/connection-urls.mdx index 230e0db8..1320183e 100644 --- a/docs/zh/databases/connection-urls.mdx +++ b/docs/zh/databases/connection-urls.mdx @@ -25,6 +25,8 @@ TablePro 解析标准的数据库连接 URL,用于导入连接、从浏览器 | `sqlserver://` | Microsoft SQL Server(别名) | | `oracle://` | Oracle Database | | `jdbc:oracle:thin:@//` | Oracle Database(JDBC thin) | +| `cassandra://` | Cassandra | +| `scylladb://` | ScyllaDB | 在任意 scheme 后追加 `+ssh`(SQLite 除外)即可使用 SSH tunnel: @@ -164,6 +166,16 @@ redis://:password@host:6379/2 # 数据库索引 2 rediss://:password@host:6380 # TLS,默认索引 0 ``` +### Cassandra / ScyllaDB + +路径部分设置默认 keyspace。`cassandra://` 和 `scylladb://` 使用相同的格式。 + +```text +cassandra://user:pass@host:9042/my_keyspace +scylladb://user:pass@host:9042/my_keyspace +cassandra://host:9042 # 无认证,无默认 keyspace +``` + ### SSH Tunnel | 参数 | 描述 | diff --git a/docs/zh/databases/overview.mdx b/docs/zh/databases/overview.mdx index a60e5e61..fe292015 100644 --- a/docs/zh/databases/overview.mdx +++ b/docs/zh/databases/overview.mdx @@ -1,15 +1,15 @@ --- title: 连接管理 -description: 在 TablePro 中创建、组织和管理 10 种数据库引擎的连接 +description: 在 TablePro 中创建、组织和管理 12 种数据库引擎的连接 --- # 连接管理 -TablePro 通过统一界面连接十种数据库系统。你可以创建连接、用颜色、标签和分组来组织它们,并在不离开当前工作区的情况下自由切换。 +TablePro 通过统一界面连接十二种数据库系统。你可以创建连接、用颜色、标签和分组来组织它们,并在不离开当前工作区的情况下自由切换。 ## 支持的数据库 -TablePro 原生支持十种数据库系统: +TablePro 原生支持十二种数据库系统: @@ -42,6 +42,12 @@ TablePro 原生支持十种数据库系统: ClickHouse OLAP 数据库,通过 HTTP API 连接。默认端口:8123 + + Cassandra 3.11+ 和 ScyllaDB 4.0+,通过 CQL 原生协议连接。默认端口:9042 + + + DuckDB 嵌入式分析数据库。基于文件 + ## 创建连接 @@ -105,7 +111,7 @@ open "mysql://root:secret@localhost:3306/myapp" open "redis://:password@localhost:6379" ``` -TablePro 在 macOS 上注册了 `postgresql`、`postgres`、`mysql`、`mariadb`、`sqlite`、`mongodb`、`mongodb+srv`、`redis`、`rediss`、`redshift`、`mssql`、`sqlserver`、`oracle` 和 `clickhouse` 作为 URL scheme,因此系统会直接将这些 URL 路由到应用。 +TablePro 在 macOS 上注册了 `postgresql`、`postgres`、`mysql`、`mariadb`、`sqlite`、`mongodb`、`mongodb+srv`、`redis`、`rediss`、`redshift`、`mssql`、`sqlserver`、`oracle`、`clickhouse`、`cassandra`、`scylladb` 和 `duckdb` 作为 URL scheme,因此系统会直接将这些 URL 路由到应用。 **打开 URL 后的行为:** @@ -128,7 +134,7 @@ TablePro 在 macOS 上注册了 `postgresql`、`postgres`、`mysql`、`mariadb` | 字段 | 描述 | |-------|-------------| | **Name** | 用于标识此连接的名称 | -| **Type** | 数据库类型:MySQL、MariaDB、PostgreSQL、SQLite、MongoDB、Redis、Redshift、SQL Server 或 Oracle | +| **Type** | 数据库类型:MySQL、MariaDB、PostgreSQL、SQLite、MongoDB、Redis、Redshift、SQL Server、Oracle、ClickHouse、Cassandra、ScyllaDB 或 DuckDB | #### Appearance 部分 @@ -569,6 +575,8 @@ cp ~/Desktop/tablepro-backup.plist ~/Library/Preferences/com.TablePro.plist | Microsoft SQL Server | 1433 | | Oracle Database | 1521 | | ClickHouse | 8123 | +| Cassandra / ScyllaDB | 9042 | +| DuckDB | 不适用(基于文件) | ## 相关指南 @@ -600,6 +608,9 @@ cp ~/Desktop/tablepro-backup.plist ~/Library/Preferences/com.TablePro.plist 连接 ClickHouse OLAP 数据库 + + 连接 Cassandra 和 ScyllaDB 集群 + 通过 SSH 安全连接 diff --git a/scripts/build-cassandra.sh b/scripts/build-cassandra.sh new file mode 100755 index 00000000..f3427df4 --- /dev/null +++ b/scripts/build-cassandra.sh @@ -0,0 +1,174 @@ +#!/bin/bash +set -euo pipefail + +# Build DataStax C/C++ driver (cassandra-cpp-driver) static library for TablePro +# Usage: ./scripts/build-cassandra.sh [arm64|x86_64|both] +# +# Dependencies: cmake, libuv (built automatically), OpenSSL (from Libs/) + +CASSANDRA_VERSION="2.17.1" +LIBUV_VERSION="1.48.0" +BUILD_DIR="/tmp/cassandra-build" +LIBS_DIR="$(cd "$(dirname "$0")/.." && pwd)/Libs" +HEADERS_DIR="$(cd "$(dirname "$0")/.." && pwd)/Plugins/CassandraDriverPlugin/CCassandra/include" +ARCH="${1:-both}" +MACOS_TARGET="14.0" + +echo "Building DataStax Cassandra C driver $CASSANDRA_VERSION..." + +mkdir -p "$BUILD_DIR" +mkdir -p "$LIBS_DIR" +mkdir -p "$HEADERS_DIR" + +# --- Build libuv --- +build_libuv() { + local arch=$1 + local uv_build_dir="$BUILD_DIR/libuv-build-${arch}" + + if [ -f "$LIBS_DIR/libuv_${arch}.a" ]; then + echo "✅ libuv_${arch}.a already exists, skipping" + return 0 + fi + + echo "📦 Building libuv $LIBUV_VERSION for $arch..." + cd "$BUILD_DIR" + + if [ ! -d "libuv-v${LIBUV_VERSION}" ]; then + curl -sL "https://dist.libuv.org/dist/v${LIBUV_VERSION}/libuv-v${LIBUV_VERSION}.tar.gz" -o libuv.tar.gz + tar xzf libuv.tar.gz + fi + + rm -rf "$uv_build_dir" + mkdir -p "$uv_build_dir" + + cmake -S "libuv-v${LIBUV_VERSION}" -B "$uv_build_dir" \ + -DCMAKE_OSX_ARCHITECTURES="$arch" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$MACOS_TARGET" \ + -DCMAKE_BUILD_TYPE=Release \ + -DLIBUV_BUILD_TESTS=OFF \ + -DLIBUV_BUILD_BENCH=OFF \ + -DBUILD_TESTING=OFF + + cmake --build "$uv_build_dir" --config Release -j "$(sysctl -n hw.ncpu)" + + cp "$uv_build_dir/libuv_a.a" "$LIBS_DIR/libuv_${arch}.a" 2>/dev/null \ + || cp "$uv_build_dir/libuv.a" "$LIBS_DIR/libuv_${arch}.a" + + echo "✅ Created libuv_${arch}.a" +} + +# --- Build cassandra-cpp-driver --- +build_cassandra() { + local arch=$1 + local cass_build_dir="$BUILD_DIR/cassandra-build-${arch}" + + if [ -f "$LIBS_DIR/libcassandra_${arch}.a" ]; then + echo "✅ libcassandra_${arch}.a already exists, skipping" + return 0 + fi + + echo "📦 Building cassandra-cpp-driver $CASSANDRA_VERSION for $arch..." + cd "$BUILD_DIR" + + if [ ! -d "cassandra-cpp-driver-${CASSANDRA_VERSION}" ]; then + curl -sL "https://github.com/datastax/cpp-driver/archive/refs/tags/${CASSANDRA_VERSION}.tar.gz" -o cpp-driver.tar.gz + tar xzf cpp-driver.tar.gz + fi + + # Patch CMakeLists.txt to accept AppleClang (macOS default compiler) + sed -i '' 's/"${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang"/"${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang" OR "${CMAKE_CXX_COMPILER_ID}" STREQUAL "AppleClang"/g' \ + "cassandra-cpp-driver-${CASSANDRA_VERSION}/CMakeLists.txt" + + rm -rf "$cass_build_dir" + mkdir -p "$cass_build_dir" + + cmake -S "cassandra-cpp-driver-${CASSANDRA_VERSION}" -B "$cass_build_dir" \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DCMAKE_OSX_ARCHITECTURES="$arch" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$MACOS_TARGET" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCASS_BUILD_STATIC=ON \ + -DCASS_BUILD_SHARED=OFF \ + -DCASS_BUILD_TESTS=OFF \ + -DCASS_BUILD_EXAMPLES=OFF \ + -DCASS_USE_OPENSSL=ON \ + -DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3 2>/dev/null || echo /usr/local/opt/openssl)" \ + -DLIBUV_ROOT_DIR="$BUILD_DIR/libuv-v${LIBUV_VERSION}" \ + -DLIBUV_LIBRARY="$LIBS_DIR/libuv_${arch}.a" \ + -DLIBUV_INCLUDE_DIR="$BUILD_DIR/libuv-v${LIBUV_VERSION}/include" + + cmake --build "$cass_build_dir" --config Release -j "$(sysctl -n hw.ncpu)" + + cp "$cass_build_dir/libcassandra_static.a" "$LIBS_DIR/libcassandra_${arch}.a" 2>/dev/null \ + || cp "$cass_build_dir/libcassandra.a" "$LIBS_DIR/libcassandra_${arch}.a" + + echo "✅ Created libcassandra_${arch}.a" +} + +# --- Copy headers --- +copy_headers() { + echo "📋 Copying cassandra.h header..." + + if [ -f "$HEADERS_DIR/cassandra.h" ]; then + echo "✅ cassandra.h already exists, skipping" + return 0 + fi + + cd "$BUILD_DIR" + + if [ -f "cassandra-cpp-driver-${CASSANDRA_VERSION}/include/cassandra.h" ]; then + cp "cassandra-cpp-driver-${CASSANDRA_VERSION}/include/cassandra.h" "$HEADERS_DIR/" + echo "✅ Copied cassandra.h" + else + echo "❌ cassandra.h not found!" + exit 1 + fi +} + +# --- Main --- +case "$ARCH" in + arm64) + build_libuv arm64 + build_cassandra arm64 + cp "$LIBS_DIR/libcassandra_arm64.a" "$LIBS_DIR/libcassandra.a" + cp "$LIBS_DIR/libuv_arm64.a" "$LIBS_DIR/libuv.a" + copy_headers + ;; + x86_64) + build_libuv x86_64 + build_cassandra x86_64 + cp "$LIBS_DIR/libcassandra_x86_64.a" "$LIBS_DIR/libcassandra.a" + cp "$LIBS_DIR/libuv_x86_64.a" "$LIBS_DIR/libuv.a" + copy_headers + ;; + both|universal) + build_libuv arm64 + build_libuv x86_64 + build_cassandra arm64 + build_cassandra x86_64 + + echo "Creating universal binaries..." + lipo -create "$LIBS_DIR/libcassandra_arm64.a" "$LIBS_DIR/libcassandra_x86_64.a" \ + -output "$LIBS_DIR/libcassandra_universal.a" + cp "$LIBS_DIR/libcassandra_universal.a" "$LIBS_DIR/libcassandra.a" + + lipo -create "$LIBS_DIR/libuv_arm64.a" "$LIBS_DIR/libuv_x86_64.a" \ + -output "$LIBS_DIR/libuv_universal.a" + cp "$LIBS_DIR/libuv_universal.a" "$LIBS_DIR/libuv.a" + + echo "✅ Created universal binaries" + copy_headers + ;; + *) + echo "Usage: $0 [arm64|x86_64|both]" + exit 1 + ;; +esac + +echo "" +echo "Cassandra driver built successfully!" +echo "Libraries:" +ls -lh "$LIBS_DIR"/libcassandra*.a "$LIBS_DIR"/libuv*.a 2>/dev/null +echo "" +echo "Headers:" +ls -lh "$HEADERS_DIR"/cassandra.h 2>/dev/null