diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 92d42ab5a0..32d0e244de 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -3895,6 +3895,7 @@ async function refreshSavedRows(request: { dirtyRows: ReadonlyMap { await createEditor(false).saveChanges(); expect(mocks.prepareDataGridSave.mock.calls[1]![0]).toMatchObject({ includeDatabaseName: false }); }); + + it("forwards the connected server version so saved statements match the read SQL (#10503)", async () => { + mocks.getConfig.mockReturnValue({ database_info: { productVersion: "Neo4j/4.4.44" } }); + await createEditor(false).saveChanges(); + expect(mocks.prepareDataGridSave.mock.calls[0]![0]).toMatchObject({ serverVersion: "Neo4j/4.4.44" }); + }); }); diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts index da3af7e023..9c66561b21 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ setErrorResult: vi.fn(), setTableMeta: vi.fn(), updateSql: vi.fn(), + databaseInfo: undefined as { productName?: string; productVersion?: string } | undefined, })); vi.mock("@/lib/backend/api", () => ({ @@ -27,7 +28,7 @@ vi.mock("@/lib/backend/api", () => ({ vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ activeConnectionId: "", - getConfig: () => ({ id: "connection-1", db_type: mocks.databaseType }), + getConfig: () => ({ id: "connection-1", db_type: mocks.databaseType, database_info: mocks.databaseInfo }), ensureConnected: mocks.ensureConnected, connectionIdentifierQuote: () => undefined, metadataGenerationFor: () => 0, @@ -108,6 +109,7 @@ describe("useNavigationTargets openTableTarget", () => { mocks.tabs.length = 0; mocks.databaseType = "postgres"; mocks.tableOpenSortMode = "none"; + mocks.databaseInfo = undefined; mocks.ensureConnected.mockResolvedValue(undefined); mocks.buildTableSelectSql.mockImplementation(async ({ tableName }: { tableName: string }) => `SELECT * FROM ${tableName}`); mocks.executeTabSql.mockResolvedValue(undefined); @@ -130,6 +132,30 @@ describe("useNavigationTargets openTableTarget", () => { expect(mocks.tabs[0]?.orderByInput).toBeUndefined(); }); + it("forwards the connected server version to the table SQL builder (#10503)", async () => { + mocks.databaseType = "neo4j"; + mocks.databaseInfo = { productName: "Neo4j", productVersion: "Neo4j/4.4.44" }; + + await useNavigationTargets(dialogs).openTableTarget({ connectionId: "connection-1", database: "neo4j", tableName: "users", tableType: "TABLE" }); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ serverVersion: "Neo4j/4.4.44" })); + }); + + it("reads the server version when the SQL is built, not from the config captured before connect (#10503)", async () => { + mocks.databaseType = "neo4j"; + mocks.databaseInfo = undefined; + // The store swaps the config object when the probe lands, so the reference captured before + // connect never sees the version. + mocks.getColumns.mockImplementation(async () => { + mocks.databaseInfo = { productName: "Neo4j", productVersion: "Neo4j/4.4.44" }; + return [column("id")]; + }); + + await useNavigationTargets(dialogs).openTableTarget({ connectionId: "connection-1", database: "neo4j", tableName: "users", tableType: "TABLE" }); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ serverVersion: "Neo4j/4.4.44" })); + }); + it.each(["oracle", "oceanbase-oracle"])("uses the %s physical primary index for default ordering", async (databaseType) => { mocks.databaseType = databaseType; mocks.tableOpenSortMode = "database"; diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 3d0373fc0b..0c6cd2ce3b 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -122,6 +122,7 @@ export function useDataGridActions(activeTab: ComputedRef) return buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: config?.driver_profile, + serverVersion: config?.database_info?.productVersion, identifierQuote: connectionStore.connectionIdentifierQuote?.(tab.connectionId), database: tableMeta?.database, schema: tableMeta?.schema, diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 1753399406..7a97790a5c 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -1592,6 +1592,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { if (!tableMeta.value) return null; return { databaseType: resolvedDatabaseType.value, + serverVersion: connectionStore.getConfig(connectionId.value ?? "")?.database_info?.productVersion, identifierQuote: connectionStore.connectionIdentifierQuote?.(connectionId.value), tableMeta: tableMeta.value, columns: result.value.columns, diff --git a/apps/desktop/src/composables/useDataGridLargeValues.ts b/apps/desktop/src/composables/useDataGridLargeValues.ts index 7892be3bf4..a76d1e60e7 100644 --- a/apps/desktop/src/composables/useDataGridLargeValues.ts +++ b/apps/desktop/src/composables/useDataGridLargeValues.ts @@ -258,6 +258,7 @@ export function useDataGridLargeValues(options: UseDataGridLargeValuesOptions) { if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || options.result.value !== sourceResult) return; const sql = await buildTableSelectSql({ databaseType: options.databaseType.value, + serverVersion: options.getConnectionConfig(options.connectionId.value!)?.database_info?.productVersion, identifierQuote: options.connectionIdentifierQuote(options.connectionId.value), database: tableMeta.database, schema: tableMeta.schema, @@ -426,6 +427,7 @@ export function useDataGridLargeValues(options: UseDataGridLargeValuesOptions) { const selectedColumns = [...new Set([...tableMeta.primaryKeys, sourceColumn])]; const sql = await buildTableSelectSql({ databaseType: options.databaseType.value, + serverVersion: options.getConnectionConfig(options.connectionId.value!)?.database_info?.productVersion, identifierQuote: options.connectionIdentifierQuote(options.connectionId.value), database: tableMeta.database, schema: tableMeta.schema, diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 22c6ddc55f..4e60020020 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -147,6 +147,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const sql = await buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: config.driver_profile, + serverVersion: connectionStore.getConfig(target.connectionId)?.database_info?.productVersion, identifierQuote, schema: tableSchema, catalog: target.catalog, @@ -204,6 +205,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const sql = await buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: config.driver_profile, + serverVersion: connectionStore.getConfig(target.connectionId)?.database_info?.productVersion, identifierQuote, schema: tableSchema, catalog: target.catalog, @@ -255,6 +257,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const emptySql = await buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: config.driver_profile, + serverVersion: connectionStore.getConfig(target.connectionId)?.database_info?.productVersion, identifierQuote, schema: tableSchema, catalog: target.catalog, @@ -302,6 +305,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const newSql = await buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: config.driver_profile, + serverVersion: connectionStore.getConfig(target.connectionId)?.database_info?.productVersion, identifierQuote, schema: tableSchema, catalog: target.catalog, diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index ea4d3e5d47..be1a57ff06 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -375,6 +375,7 @@ export function useSidebarDataOpenRuntime() { const sql = await buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: config?.driver_profile, + serverVersion: connectionStore.getConfig(node.connectionId)?.database_info?.productVersion, identifierQuote: connectionStore.connectionIdentifierQuote?.(node.connectionId), schema: tableSchema, database: node.database, diff --git a/apps/desktop/src/lib/__tests__/dataGrid/joinedRowChanges.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/joinedRowChanges.spec.ts index 7451c30fae..cf6c30a17d 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/joinedRowChanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/joinedRowChanges.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { joinedRowChanges, type JoinedWriteSource } from "../../dataGrid/joinedRowChanges"; +import { joinedRowChanges, joinedSaveOptions, type JoinedWriteSource } from "../../dataGrid/joinedRowChanges"; const sources: JoinedWriteSource[] = [ { tableKey: "users", primaryKeys: ["id"], sourceColumns: ["id", "name", undefined, undefined] }, @@ -79,3 +79,22 @@ describe("joined row updates", () => { ).toHaveLength(2); }); }); + +describe("joined save options", () => { + it("keeps the connected server version so saved rows are addressed like the rows that were read", () => { + const options = joinedSaveOptions([{ tableMeta: { tableName: "events", primaryKeys: ["id"] }, sourceColumns: ["id", "name"] }], { + databaseType: "neo4j", + serverVersion: "Neo4j/4.4.44", + identifierQuote: "`", + includeDatabaseName: false, + columns: ["id", "name"], + rows: [[1, "user"]], + dirtyRows: new Map([[0, new Map([[1, "renamed"]])]]), + newRows: [], + deletedRows: new Set(), + }); + expect(options).toHaveLength(1); + expect(options[0]!.serverVersion).toBe("Neo4j/4.4.44"); + expect(options[0]!.databaseType).toBe("neo4j"); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridSql.ts b/apps/desktop/src/lib/dataGrid/dataGridSql.ts index 69927a5f55..1c1cd49f02 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridSql.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridSql.ts @@ -25,6 +25,9 @@ export interface DataGridColumnInfo { export interface DataGridSaveStatementOptions { databaseType?: DatabaseType; + /** Server version reported by the connection (see `BuildTableSelectSqlOptions.serverVersion`). + * The saved statements must address rows the same way the grid read them. */ + serverVersion?: string; identifierQuote?: string; tableMeta: DataGridTableMeta; columns: string[]; diff --git a/apps/desktop/src/lib/dataGrid/joinedRowChanges.ts b/apps/desktop/src/lib/dataGrid/joinedRowChanges.ts index 655f2424c6..1657be6a4c 100644 --- a/apps/desktop/src/lib/dataGrid/joinedRowChanges.ts +++ b/apps/desktop/src/lib/dataGrid/joinedRowChanges.ts @@ -9,7 +9,7 @@ export interface JoinedSaveTarget { /** Feed per-source changes through the existing SQL builder, including undo SQL. */ export function joinedSaveOptions( targets: JoinedSaveTarget[], - options: Pick & { + options: Pick & { rows: CellValue[][]; dirtyRows: Map>; newRows: CellValue[][]; @@ -24,6 +24,7 @@ export function joinedSaveOptions( })); return joinedRowChanges(sources, options.rows, options.dirtyRows).map((update) => ({ databaseType: options.databaseType, + serverVersion: options.serverVersion, identifierQuote: options.identifierQuote, columns: options.columns, tableMeta: targets[update.sourceIndex]!.tableMeta, diff --git a/apps/desktop/src/lib/table/tableSelectSql.ts b/apps/desktop/src/lib/table/tableSelectSql.ts index 304b95dfc0..84025dac7e 100644 --- a/apps/desktop/src/lib/table/tableSelectSql.ts +++ b/apps/desktop/src/lib/table/tableSelectSql.ts @@ -13,6 +13,10 @@ import type { SqlSemanticToken } from "@/lib/sql/semantic/types"; export interface BuildTableSelectSqlOptions { databaseType?: DatabaseType; driverProfile?: string; + /** Server version reported by the connection (for example `Neo4j/4.4.44`). Engines that + * renamed a built-in function across releases (Neo4j below 5 uses `id()` where 5+ uses + * `elementId()`) need it to generate SQL the connected server understands. */ + serverVersion?: string; identifierQuote?: string; schema?: string; tableName: string; diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 9fc68677df..0e30dc628e 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -4842,6 +4842,7 @@ export const useQueryStore = defineStore("query", () => { const sql = await buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: conn?.driver_profile, + serverVersion: conn?.database_info?.productVersion, identifierQuote, database: tableMeta.database, schema: tableMeta.schema, @@ -8962,6 +8963,7 @@ export const useQueryStore = defineStore("query", () => { const sql = await api.buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: conn?.driver_profile, + serverVersion: conn?.database_info?.productVersion, identifierQuote, database: tableMeta.database, schema: tableMeta.schema, diff --git a/crates/dbx-core/src/ai/agent_tools.rs b/crates/dbx-core/src/ai/agent_tools.rs index 01368c224a..852b6b87fe 100644 --- a/crates/dbx-core/src/ai/agent_tools.rs +++ b/crates/dbx-core/src/ai/agent_tools.rs @@ -1491,7 +1491,8 @@ async fn execute_get_sample_data( // Reuse the table-data builder so identifier quoting and row limiting follow // the active database instead of assuming PostgreSQL syntax. - let sql = build_sample_data_sql(db_type, schema.as_deref(), table, limit); + let server_version = connection_server_version(state, connection_id).await; + let sql = build_sample_data_sql(db_type, server_version.as_deref(), schema.as_deref(), table, limit); // Delegate to execute_execute_query with a synthetic tool call let synthetic_call = ToolCall { @@ -1512,9 +1513,28 @@ async fn execute_get_sample_data( .await } -fn build_sample_data_sql(db_type: &DatabaseType, schema: Option<&str>, table: &str, limit: usize) -> String { +/// The sample-data statement is produced by the shared dialect code, so it has to match the +/// connected server too: Neo4j below 5 only accepts `id()` where 5+ uses `elementId()`. +async fn connection_server_version(state: &Arc, connection_id: &str) -> Option { + state + .configs + .read() + .await + .get(connection_id) + .and_then(|config| config.database_info.as_ref()) + .and_then(|info| info.product_version.clone()) +} + +fn build_sample_data_sql( + db_type: &DatabaseType, + server_version: Option<&str>, + schema: Option<&str>, + table: &str, + limit: usize, +) -> String { build_table_data_select_sql(TableDataSelectSqlOptions { database_type: Some(*db_type), + server_version: server_version.map(str::to_owned), schema: schema.map(str::to_owned), table_name: table.to_string(), limit: Some(limit), @@ -2640,23 +2660,35 @@ for line in sys.stdin: #[test] fn sample_data_sql_uses_database_identifier_and_limit_syntax() { assert_eq!( - build_sample_data_sql(&DatabaseType::Mysql, Some("app"), "sys_tenant", 20), + build_sample_data_sql(&DatabaseType::Mysql, None, Some("app"), "sys_tenant", 20), "SELECT * FROM `app`.`sys_tenant` LIMIT 20;" ); assert_eq!( - build_sample_data_sql(&DatabaseType::Postgres, Some("public"), "sys_tenant", 20), + build_sample_data_sql(&DatabaseType::Postgres, None, Some("public"), "sys_tenant", 20), "SELECT * FROM \"public\".\"sys_tenant\" LIMIT 20;" ); assert_eq!( - build_sample_data_sql(&DatabaseType::SqlServer, Some("dbo"), "sys_tenant", 20), + build_sample_data_sql(&DatabaseType::SqlServer, None, Some("dbo"), "sys_tenant", 20), "SELECT TOP (20) * FROM [dbo].[sys_tenant]" ); assert_eq!( - build_sample_data_sql(&DatabaseType::Oracle, Some("APP"), "SYS_TENANT", 20), + build_sample_data_sql(&DatabaseType::Oracle, None, Some("APP"), "SYS_TENANT", 20), "SELECT * FROM (SELECT * FROM \"APP\".\"SYS_TENANT\") WHERE ROWNUM <= 20" ); } + #[test] + fn sample_data_sql_follows_the_connected_neo4j_identifier_function() { + assert_eq!( + build_sample_data_sql(&DatabaseType::Neo4j, Some("Neo4j/4.4.44"), None, "Person", 20), + "MATCH (n:`Person`) RETURN id(n) AS `__DBX_ELEMENT_ID`, n LIMIT 20;" + ); + assert_eq!( + build_sample_data_sql(&DatabaseType::Neo4j, Some("Neo4j/5.26.0"), None, "Person", 20), + "MATCH (n:`Person`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n LIMIT 20;" + ); + } + #[test] fn selected_schema_overrides_the_tool_schema_argument() { let call = ToolCall { diff --git a/crates/dbx-core/src/data/csv_export.rs b/crates/dbx-core/src/data/csv_export.rs index f8c26da754..dbd653399b 100644 --- a/crates/dbx-core/src/data/csv_export.rs +++ b/crates/dbx-core/src/data/csv_export.rs @@ -36,18 +36,27 @@ pub struct TableCsvExportOptions { pub null_literal: String, } -async fn connection_database_type(state: &AppState, connection_id: &str) -> Result { +/// Export pages go through the same dialect code as the grid, so the statement has to match the +/// connected server too: Neo4j below 5 only accepts `id()` where 5+ uses `elementId()`. That needs +/// the recorded version next to the connection type. +async fn connection_server_version_and_type( + state: &AppState, + connection_id: &str, +) -> Result<(Option, DatabaseType), String> { state .configs .read() .await .get(connection_id) - .map(|config| config.db_type) + .map(|config| { + let server_version = config.database_info.as_ref().and_then(|info| info.product_version.clone()); + (server_version, config.db_type) + }) .ok_or_else(|| format!("Connection config not found: {connection_id}")) } pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExportOptions) -> Result { - let database_type = connection_database_type(state, &options.connection_id).await?; + let (server_version, database_type) = connection_server_version_and_type(state, &options.connection_id).await?; // This loop pages with `LIMIT OFFSET ` and stops at the first // short page. Neither half holds for SOQL: a `/query` response carries at most // 2000 rows and hands the rest back as a QueryLocator (`has_more` + @@ -67,6 +76,7 @@ pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExpor let outcome = write_table_csv_pages( &mut writer, database_type, + server_version.as_deref(), &options, client_session_id.as_deref(), |sql, query_options| async move { @@ -93,6 +103,7 @@ pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExpor async fn write_table_csv_pages( writer: &mut impl Write, database_type: DatabaseType, + server_version: Option<&str>, options: &TableCsvExportOptions, client_session_id: Option<&str>, mut execute_page: Execute, @@ -113,6 +124,7 @@ where loop { let sql = build_table_data_select_sql(TableDataSelectSqlOptions { database_type: Some(database_type), + server_version: server_version.map(str::to_owned), schema: options.schema.clone(), table_name: options.table_name.clone(), table_type: None, @@ -225,6 +237,7 @@ mod tests { let count = write_table_csv_pages( &mut output, DatabaseType::Cassandra, + None, &export_options(usize::MAX), Some("export-test"), |sql, options| { @@ -266,6 +279,7 @@ mod tests { let count = write_table_csv_pages( &mut Vec::new(), DatabaseType::Cassandra, + None, &export_options(2), Some("export-test"), |_, _| { @@ -289,6 +303,7 @@ mod tests { let error = write_table_csv_pages( &mut Vec::new(), DatabaseType::Cassandra, + None, &export_options(2), Some("export-test"), |_, _| { @@ -309,6 +324,7 @@ mod tests { let error = write_table_csv_pages( &mut Vec::new(), DatabaseType::Cassandra, + None, &export_options(2), Some("export-test"), |_, options| { @@ -331,23 +347,53 @@ mod tests { async fn non_cassandra_csv_export_preserves_limit_offset_paging() { let mut pages = vec![page(&[1, 2], None, false), page(&[3], None, false)].into_iter(); let mut sqls = Vec::new(); - let count = - write_table_csv_pages(&mut Vec::new(), DatabaseType::Sqlite, &export_options(2), None, |sql, options| { + let count = write_table_csv_pages( + &mut Vec::new(), + DatabaseType::Sqlite, + None, + &export_options(2), + None, + |sql, options| { sqls.push(sql); assert_eq!(options.max_rows, Some(2)); assert!(options.page_size.is_none()); assert!(options.result_session_id.is_none()); assert!(options.client_session_id.is_none()); std::future::ready(Ok(pages.next().unwrap())) - }) - .await - .unwrap(); + }, + ) + .await + .unwrap(); assert_eq!(count, 3); assert_eq!(sqls.len(), 2); assert!(sqls[0].contains("LIMIT 2")); assert!(sqls[1].contains("OFFSET 2")); } + #[tokio::test] + async fn csv_export_uses_the_identifier_function_the_neo4j_server_understands() { + for (server_version, identifier) in [("Neo4j/4.4.44", "id(n)"), ("Neo4j/5.26.0", "elementId(n)")] { + let mut pages = vec![page(&[1], None, false)].into_iter(); + let mut sqls = Vec::new(); + let options = export_options(2); + write_table_csv_pages( + &mut Vec::new(), + DatabaseType::Neo4j, + Some(server_version), + &options, + None, + |sql, _| { + sqls.push(sql); + std::future::ready(Ok(pages.next().expect("unexpected extra export page"))) + }, + ) + .await + .unwrap(); + assert_eq!(sqls.len(), 1); + assert!(sqls[0].contains(identifier), "{server_version} export SQL was {}", sqls[0]); + } + } + fn salesforce_config(id: &str) -> ConnectionConfig { let mut config = serde_json::from_value::(serde_json::json!({ "id": id, diff --git a/crates/dbx-core/tests/mysql_cross_database_editing.rs b/crates/dbx-core/tests/mysql_cross_database_editing.rs index 56fc08b9ec..8324064f02 100644 --- a/crates/dbx-core/tests/mysql_cross_database_editing.rs +++ b/crates/dbx-core/tests/mysql_cross_database_editing.rs @@ -55,6 +55,7 @@ fn mysql_cross_database_query_flow_preserves_target_database() { let save = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, diff --git a/crates/dbx-sql-data/src/data_grid_iotdb_sql.rs b/crates/dbx-sql-data/src/data_grid_iotdb_sql.rs index 83915a9d5b..81e6beffcb 100644 --- a/crates/dbx-sql-data/src/data_grid_iotdb_sql.rs +++ b/crates/dbx-sql-data/src/data_grid_iotdb_sql.rs @@ -244,6 +244,7 @@ mod tests { DataGridSaveStatementOptions { database_type: Some(DatabaseType::Iotdb), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, diff --git a/crates/dbx-sql-data/src/data_grid_neo4j_sql.rs b/crates/dbx-sql-data/src/data_grid_neo4j_sql.rs index 28945e0d32..9136cece46 100644 --- a/crates/dbx-sql-data/src/data_grid_neo4j_sql.rs +++ b/crates/dbx-sql-data/src/data_grid_neo4j_sql.rs @@ -2,9 +2,11 @@ use serde_json::Value; use super::*; use crate::models::connection::DatabaseType; +use crate::sql_dialect::{neo4j_element_id_function, NEO4J_LEGACY_ELEMENT_ID_FUNCTION}; pub(super) fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec { let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_meta.table_name); + let element_id_function = neo4j_element_id_function(options.server_version.as_deref()); let mut statements = Vec::new(); for (row_index, changes) in &options.dirty_rows { @@ -29,8 +31,10 @@ pub(super) fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatem if sets.is_empty() { continue; } - statements - .push(format!("MATCH (n:{label}) WHERE {} SET {sets};", neo4j_element_id_predicate(&options.columns, row))); + statements.push(format!( + "MATCH (n:{label}) WHERE {} SET {sets};", + neo4j_element_id_predicate(&options.columns, row, element_id_function) + )); } for row_index in &options.deleted_rows { @@ -39,7 +43,7 @@ pub(super) fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatem }; statements.push(format!( "MATCH (n:{label}) WHERE {} DETACH DELETE n;", - neo4j_element_id_predicate(&options.columns, row) + neo4j_element_id_predicate(&options.columns, row, element_id_function) )); } @@ -74,6 +78,7 @@ pub(super) fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatem pub(super) fn build_neo4j_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec { let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_meta.table_name); + let element_id_function = neo4j_element_id_function(options.server_version.as_deref()); let mut statements = Vec::new(); for row in &options.new_rows { @@ -140,19 +145,41 @@ pub(super) fn build_neo4j_data_grid_rollback_statements(options: &DataGridSaveSt if sets.is_empty() { continue; } - statements - .push(format!("MATCH (n:{label}) WHERE {} SET {sets};", neo4j_element_id_predicate(&options.columns, row))); + statements.push(format!( + "MATCH (n:{label}) WHERE {} SET {sets};", + neo4j_element_id_predicate(&options.columns, row, element_id_function) + )); } statements } -fn neo4j_element_id_predicate(columns: &[String], row: &[Value]) -> String { +fn neo4j_element_id_predicate(columns: &[String], row: &[Value], element_id_function: &str) -> String { let index = columns.iter().position(|column| column == DBX_NEO4J_ELEMENT_ID_COLUMN).unwrap_or(usize::MAX); - format!( - "elementId(n) = {}", - format_grid_sql_literal(row.get(index).unwrap_or(&Value::Null), Some(DatabaseType::Neo4j), None) - ) + let value = row.get(index).unwrap_or(&Value::Null); + // The legacy `id()` returns an Integer while a grid cell always carries text. Comparing the two + // with a quoted literal matches nothing, so a user edit would quietly update zero rows; the + // identity has to be written as a number there. `elementId()` keeps its string form. + if element_id_function == NEO4J_LEGACY_ELEMENT_ID_FUNCTION { + if let Some(identity) = neo4j_legacy_element_id_literal(value) { + return format!("{element_id_function}(n) = {identity}"); + } + } + format!("{element_id_function}(n) = {}", format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None)) +} + +/// Renders a legacy `id()` value as a Cypher number so it matches the server's Integer. Anything +/// that is not an integral id is left to the caller's normal literal formatting. +fn neo4j_legacy_element_id_literal(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_i64().map(|identity| identity.to_string()), + Value::String(text) => { + let trimmed = text.trim(); + (!trimmed.is_empty() && trimmed.chars().all(|character| character.is_ascii_digit())) + .then(|| trimmed.to_string()) + } + _ => None, + } } fn neo4j_row_property_where(columns: &[String], row: &[Value]) -> String { diff --git a/crates/dbx-sql-data/src/data_grid_salesforce_sql.rs b/crates/dbx-sql-data/src/data_grid_salesforce_sql.rs index 28b994eea5..7375075ab0 100644 --- a/crates/dbx-sql-data/src/data_grid_salesforce_sql.rs +++ b/crates/dbx-sql-data/src/data_grid_salesforce_sql.rs @@ -196,6 +196,7 @@ mod tests { DataGridSaveStatementOptions { database_type: Some(DatabaseType::Salesforce), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { database: None, catalog: None, @@ -234,6 +235,7 @@ mod tests { DataGridSaveStatementOptions { database_type: Some(DatabaseType::Salesforce), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { database: None, catalog: None, diff --git a/crates/dbx-sql-data/src/data_grid_sql.rs b/crates/dbx-sql-data/src/data_grid_sql.rs index dfc4cf32dc..7496e7391d 100644 --- a/crates/dbx-sql-data/src/data_grid_sql.rs +++ b/crates/dbx-sql-data/src/data_grid_sql.rs @@ -98,6 +98,11 @@ pub struct DataGridSaveStatementOptions { pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub identifier_quote: Option, + /// Server version reported by the connection (for example `Neo4j/4.4.44`). Neo4j renamed the + /// node identity function in 5.0, so the saved statements must address rows the same way the + /// grid read them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_version: Option, pub table_meta: DataGridTableMeta, pub columns: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3939,6 +3944,7 @@ mod tests { DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -4174,11 +4180,87 @@ mod tests { assert_eq!(build_data_grid_conditional_update_sql(primary_key), None); } + #[test] + fn neo4j_data_grid_save_addresses_nodes_with_the_identity_function_of_the_server() { + let options = |server_version: Option<&str>, rows: Vec>| DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Neo4j), + identifier_quote: None, + server_version: server_version.map(str::to_string), + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: None, + table_name: "Employee".to_string(), + primary_keys: vec![], + columns: Some(vec![ + column(DBX_NEO4J_ELEMENT_ID_COLUMN, "string", false, None), + column("name", "string", true, None), + ]), + }, + columns: vec![DBX_NEO4J_ELEMENT_ID_COLUMN.to_string(), "name".to_string()], + source_columns: None, + rows, + dirty_rows: vec![(0, vec![(1, json!("after"))])], + deleted_rows: vec![1], + new_rows: vec![], + include_database_name: false, + }; + let legacy_rows = || vec![vec![json!(23), json!("before")], vec![json!(24), json!("gone")]]; + + // Neo4j 4.x only knows `id()`; `elementId()` would be an unknown function there (#10503). + // That `id()` is an Integer while grids carry text, so the predicate compares numbers — + // with a quoted literal the edit would silently match no row. + for rows in [legacy_rows(), vec![vec![json!("23"), json!("before")], vec![json!("24"), json!("gone")]]] { + let on_neo4j_4 = prepare_data_grid_save(options(Some("Neo4j/4.4.44"), rows)); + assert_eq!( + on_neo4j_4.statements, + vec![ + "MATCH (n:`Employee`) WHERE id(n) = 23 SET n.`name` = 'after';".to_string(), + "MATCH (n:`Employee`) WHERE id(n) = 24 DETACH DELETE n;".to_string(), + ] + ); + } + + // Neo4j 5.0+ renamed the identity function, and `elementId()` returns a string. + let on_neo4j_5 = prepare_data_grid_save(options( + Some("Neo4j/5.26.0"), + vec![vec![json!("4:0db5d0e9:1:6"), json!("before")], vec![json!("4:0db5d0e9:1:7"), json!("gone")]], + )); + assert_eq!( + on_neo4j_5.statements, + vec![ + "MATCH (n:`Employee`) WHERE elementId(n) = '4:0db5d0e9:1:6' SET n.`name` = 'after';".to_string(), + "MATCH (n:`Employee`) WHERE elementId(n) = '4:0db5d0e9:1:7' DETACH DELETE n;".to_string(), + ] + ); + + // A non-numeric identity never becomes a bare Cypher number, even on the legacy function. + let legacy_text_id = prepare_data_grid_save(options( + Some("Neo4j/4.4.44"), + vec![vec![json!("4:0db5d0e9:1:6"), json!("before")], vec![json!("4:0db5d0e9:1:7"), json!("gone")]], + )); + assert_eq!( + legacy_text_id.statements, + vec![ + "MATCH (n:`Employee`) WHERE id(n) = '4:0db5d0e9:1:6' SET n.`name` = 'after';".to_string(), + "MATCH (n:`Employee`) WHERE id(n) = '4:0db5d0e9:1:7' DETACH DELETE n;".to_string(), + ] + ); + + // Callers without connection metadata keep the historical `elementId()` spelling. + let without_version = prepare_data_grid_save(options( + None, + vec![vec![json!("4:0db5d0e9:1:6"), json!("before")], vec![json!("4:0db5d0e9:1:7"), json!("gone")]], + )); + assert_eq!(without_version.statements, on_neo4j_5.statements); + } + #[test] fn iris_cache_data_grid_save_uses_unquoted_ordinary_identifiers() { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Iris), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -4216,6 +4298,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Iris), identifier_quote: Some("\"".to_string()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -4259,6 +4342,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -4296,6 +4380,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6363,6 +6448,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Hive), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6403,6 +6489,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Hive), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6442,6 +6529,7 @@ mod tests { let hive_options = DataGridSaveStatementOptions { database_type: Some(DatabaseType::Hive), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6464,6 +6552,7 @@ mod tests { let postgres_options = DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6647,6 +6736,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: Some("\"".to_string()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6714,6 +6804,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: Some("\"".to_string()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6749,6 +6840,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::SqlServer), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6775,6 +6867,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::SqlServer), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: Some("BarDB".to_string()), database: Some("BarDB".to_string()), @@ -6805,6 +6898,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Kingbase), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6843,6 +6937,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Kingbase), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6878,6 +6973,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Kingbase), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6913,6 +7009,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -6943,6 +7040,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7020,6 +7118,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Vastbase), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7051,6 +7150,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Vastbase), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7094,6 +7194,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Goldendb), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7126,6 +7227,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Goldendb), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7166,6 +7268,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Goldendb), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7193,6 +7296,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Goldendb), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7220,6 +7324,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7256,6 +7361,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Kingbase), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7287,6 +7393,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Kingbase), identifier_quote: Some("`".to_string()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7320,6 +7427,7 @@ mod tests { let googlesql = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Spanner), identifier_quote: Some("`".to_string()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7344,6 +7452,7 @@ mod tests { let postgres_dialect = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Spanner), identifier_quote: Some("\"".to_string()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7375,6 +7484,7 @@ mod tests { let no_reported_quote = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Spanner), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7453,6 +7563,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Vastbase), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: Some("smes_dev".to_string()), @@ -7487,6 +7598,7 @@ mod tests { DataGridSaveStatementOptions { database_type: Some(DatabaseType::Informix), identifier_quote: Some(String::new()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: Some("webcenter".to_string()), @@ -7531,6 +7643,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Informix), identifier_quote: Some(String::new()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: Some("dbx_test".to_string()), @@ -7566,6 +7679,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Gaussdb), identifier_quote: Some("\"".to_string()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7592,6 +7706,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: Some("`".to_string()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7621,6 +7736,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7659,6 +7775,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7695,6 +7812,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7729,6 +7847,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7763,6 +7882,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7804,6 +7924,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::OceanbaseOracle), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7851,6 +7972,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::OceanbaseOracle), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7882,6 +8004,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::OceanbaseOracle), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -7915,6 +8038,7 @@ mod tests { DataGridSaveStatementOptions { database_type: Some(DatabaseType::Dameng), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8054,6 +8178,7 @@ mod tests { DataGridSaveStatementOptions { database_type: Some(DatabaseType::Sqlite), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8213,6 +8338,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::OceanbaseOracle), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8365,6 +8491,7 @@ mod tests { let options = DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8420,6 +8547,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8516,6 +8644,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Kingbase), identifier_quote: Some("`".to_string()), + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8608,6 +8737,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Dameng), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8644,6 +8774,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8694,6 +8825,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::SqlServer), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8762,6 +8894,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::SqlServer), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8796,6 +8929,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8828,6 +8962,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8875,6 +9010,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8905,6 +9041,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8935,6 +9072,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::SqlServer), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -8967,6 +9105,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9000,6 +9139,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9042,6 +9182,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9075,6 +9216,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9112,6 +9254,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9152,6 +9295,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9199,6 +9343,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9244,6 +9389,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9274,6 +9420,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9303,6 +9450,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9337,6 +9485,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Tdengine), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9368,6 +9517,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Databend), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9402,6 +9552,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ClickHouse), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9438,6 +9589,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ClickHouse), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9472,6 +9624,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ClickHouse), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9502,6 +9655,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ClickHouse), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9601,6 +9755,7 @@ mod tests { let save = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Doris), identifier_quote: None, + server_version: None, table_meta, columns: vec!["id".to_string(), "status".to_string()], source_columns: None, @@ -9626,6 +9781,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Databend), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9657,6 +9813,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oscar), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9688,6 +9845,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9724,6 +9882,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9778,6 +9937,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9806,6 +9966,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9840,6 +10001,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ManticoreSearch), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9873,6 +10035,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9913,6 +10076,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oracle), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9949,6 +10113,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Xugu), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -9986,6 +10151,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Sqlite), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -10015,6 +10181,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Sqlite), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -10047,6 +10214,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -10076,6 +10244,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -10107,6 +10276,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -10137,6 +10307,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -10168,6 +10339,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -10197,6 +10369,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Kingbase), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -10226,6 +10399,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Sqlite), identifier_quote: None, + server_version: None, table_meta: DataGridTableMeta { catalog: None, database: None, @@ -10266,6 +10440,7 @@ mod tests { let save = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Doris), identifier_quote: None, + server_version: None, table_meta: table_meta.clone(), columns: vec!["name".to_string(), "v2".to_string()], source_columns: None, diff --git a/crates/dbx-sql-dialect/src/sql_dialect.rs b/crates/dbx-sql-dialect/src/sql_dialect.rs index fca9918e2c..20331f57f7 100644 --- a/crates/dbx-sql-dialect/src/sql_dialect.rs +++ b/crates/dbx-sql-dialect/src/sql_dialect.rs @@ -47,8 +47,9 @@ pub use table_select::{ build_table_select_sql, DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX, }; pub use table_select::{ - database_qualified_table_name, quote_table_data_identifier, table_data_qualified_table_name, table_data_schema, - uses_connection_identifier_quote, + database_qualified_table_name, neo4j_element_id_function, quote_table_data_identifier, + table_data_qualified_table_name, table_data_schema, uses_connection_identifier_quote, + NEO4J_LEGACY_ELEMENT_ID_FUNCTION, }; pub use type_rewrite::{ apply_auto_inc_to_column_def, column_is_auto_increment, normalize_len_params, rewrite_column_type, diff --git a/crates/dbx-sql-dialect/src/sql_dialect/table_select.rs b/crates/dbx-sql-dialect/src/sql_dialect/table_select.rs index c2947bff62..80316bea41 100644 --- a/crates/dbx-sql-dialect/src/sql_dialect/table_select.rs +++ b/crates/dbx-sql-dialect/src/sql_dialect/table_select.rs @@ -873,6 +873,38 @@ pub(super) fn build_db2_table_select_page_sql( ) } +/// Neo4j 5.0 replaced `id()` with `elementId()`, and servers before that only know `id()`, so the +/// generated Cypher has to pick the spelling the connected server accepts. An unknown version keeps +/// `elementId()`, which is what every caller without connection metadata got before. +pub fn neo4j_element_id_function(server_version: Option<&str>) -> &'static str { + match neo4j_major_version(server_version) { + Some(major) if major < NEO4J_ELEMENT_ID_MIN_MAJOR_VERSION => NEO4J_LEGACY_ELEMENT_ID_FUNCTION, + _ => "elementId", + } +} + +/// The identity function Neo4j used before 5.0. Unlike `elementId()`, which returns a string, it +/// returns the node's internal `Integer` id, so callers comparing a value read from a grid have to +/// compare numbers. +pub const NEO4J_LEGACY_ELEMENT_ID_FUNCTION: &str = "id"; + +/// Extracts the leading `major` from version strings such as `4.4.44`, `Neo4j/5.26.0` or +/// `Neo4j/2025.01.0`. The product name itself contains a digit ("Neo4j"), so only a token that +/// *starts* with digits counts as the version. Anything without one is treated as unknown. +fn neo4j_major_version(server_version: Option<&str>) -> Option { + let version = server_version?.trim(); + version.split(|character: char| !character.is_ascii_alphanumeric()).find_map(|token| { + let digits: String = token.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() { + None + } else { + digits.parse().ok() + } + }) +} + +const NEO4J_ELEMENT_ID_MIN_MAJOR_VERSION: u32 = 5; + pub(super) fn build_neo4j_table_select_sql(options: &TableDataSelectSqlOptions, limit: usize) -> String { let label = quote_table_identifier(Some(DatabaseType::Neo4j), &options.table_name); let predicate = normalize_where_input(options.where_input.as_deref()); @@ -891,7 +923,8 @@ pub(super) fn build_neo4j_table_select_sql(options: &TableDataSelectSqlOptions, .join(", ") }; let returns = format!( - "elementId(n) AS {}, {returned_columns}", + "{}(n) AS {}, {returned_columns}", + neo4j_element_id_function(options.server_version.as_deref()), quote_table_identifier(Some(DatabaseType::Neo4j), DBX_NEO4J_ELEMENT_ID_COLUMN) ); let order_by = options.order_by.as_deref().filter(|order| !order.trim().is_empty()); @@ -979,6 +1012,7 @@ mod tests { database_type: Some(database_type), driver_profile: None, identifier_quote: None, + server_version: None, schema: None, table_name: table.to_string(), catalog: catalog.map(|c| c.to_string()), diff --git a/crates/dbx-sql-dialect/src/sql_dialect/tests.rs b/crates/dbx-sql-dialect/src/sql_dialect/tests.rs index 3e1ed2db21..af229bdee2 100644 --- a/crates/dbx-sql-dialect/src/sql_dialect/tests.rs +++ b/crates/dbx-sql-dialect/src/sql_dialect/tests.rs @@ -1575,6 +1575,37 @@ fn builds_oracle_and_neo4j_table_data_queries() { ); } +#[test] +fn neo4j_element_id_function_follows_the_connected_server_version() { + // Neo4j 4.x and older only know `id()`; `elementId()` arrived in 5.0. + assert_eq!(neo4j_element_id_function(Some("4.4.44")), "id"); + assert_eq!(neo4j_element_id_function(Some("Neo4j/4.4.44")), "id"); + assert_eq!(neo4j_element_id_function(Some("Neo4j 4.4.44 (community)")), "id"); + assert_eq!(neo4j_element_id_function(Some("3.5.35")), "id"); + assert_eq!(neo4j_element_id_function(Some("Neo4j/5.26.0")), "elementId"); + assert_eq!(neo4j_element_id_function(Some("Neo4j/2025.01.0")), "elementId"); + // Unknown or unparsable versions keep the current spelling. + assert_eq!(neo4j_element_id_function(None), "elementId"); + assert_eq!(neo4j_element_id_function(Some("")), "elementId"); + assert_eq!(neo4j_element_id_function(Some("Neo4j Community")), "elementId"); +} + +#[test] +fn builds_neo4j_table_select_with_the_legacy_element_id_on_neo4j_4() { + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Neo4j), + table_name: "Employee".to_string(), + primary_keys: vec!["id".to_string()], + columns: vec!["id".to_string(), "role".to_string()], + server_version: Some("Neo4j/4.4.44".to_string()), + limit: Some(100), + ..Default::default() + }), + "MATCH (n:`Employee`) RETURN id(n) AS `__DBX_ELEMENT_ID`, n.`id` AS `id`, n.`role` AS `role` LIMIT 100;" + ); +} + #[test] fn oracle_unknown_table_type_does_not_assume_rowid_support() { assert_eq!( diff --git a/crates/dbx-sql-dialect/src/sql_dialect/types.rs b/crates/dbx-sql-dialect/src/sql_dialect/types.rs index 3f979eac81..aa2d22250a 100644 --- a/crates/dbx-sql-dialect/src/sql_dialect/types.rs +++ b/crates/dbx-sql-dialect/src/sql_dialect/types.rs @@ -25,6 +25,11 @@ pub struct TableDataSelectSqlOptions { pub driver_profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub identifier_quote: Option, + /// Server version reported by the connection (for example `Neo4j/4.4.44`). + /// Engines that renamed a built-in function across releases use it to emit + /// SQL the connected server understands instead of the newest spelling. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, pub table_name: String,