From 9591d7d71d41b473b949b6e167c21e421f7bd6f3 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Wed, 18 Jun 2025 11:19:22 +0200 Subject: [PATCH 01/64] #882 jdp-2025-06: Schema Support --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 119 +++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 devdoc/jdp/jdp-2025-06-schema-support.adoc diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc new file mode 100644 index 000000000..e94008965 --- /dev/null +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -0,0 +1,119 @@ += jdp-2025-06: Schema Support + +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LicenseRef-PDL-1.0 + +== Status + +* Draft +* Proposed for: Jaybird 7, potential backport to Jaybird 6 and/or Jaybird 5 + +== Type + +* Feature-Specification + +== Context + +Firebird 6.0 introduces support for schemas. +To quote from `README.schemas.md` (of an early snapshot): + +____ +Firebird 6.0 introduces support for schemas in the database. +Schemas are not an optional feature, so every Firebird 6 database has at least a `SYSTEM` schema, reserved for Firebird system objects (`RDB$*` and `MON$*`). + +User objects live in different schemas, which may be the automatically created `PUBLIC` schema or user-defined ones. +It is not allowed (except for indexes) to create or modify objects in the `SYSTEM` schema. +____ + +Important details related to schemas: + +* Search path, defaults to `PUBLIC, SYSTEM`. +The session default can be configured with `isc_dpb_search_path` (string DPB item). +The current search path can be altered with `SET SEARCH_PATH TO ...`. +`ALTER SESSION RESET` reverts to the session default. +* The "`current`" schema cannot be set separately; +the first valid schema listed in the search path is considered the current schema. +* `CURRENT_SCHEMA` and `RDB$GET_CONTEXT('SYSTEM', 'CURRENT_SCHEMA')` return the first valid schema from the search path +* `RDB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')` returns the current search path +* Objects not qualified with a schema name will be resolved using the current search path. +This is done -- with some exceptions -- at prepare time. +* TBP has new item `isc_tpb_lock_table_schema` to specify the schema of a table to be locked (1 byte length + string data) +* Gbak has additional options to include/exclude (skip) schema datas in backup or restore, similar to existing options to include/exclude tables +* Gstat has additional options to specify a schema for operations involving tables +* For validation, `val_sch_incl` and `val_sch_excl` (I don't think we use the equivalent,`val_tab_incl`/`val_tab_excl` in Jaybird, so might not be relevant) + +JDBC defines various methods, parameters, and return values or result set columns that are or are related to schemas. + +These are: + +* ... + +Jaybird 5 is the "`long-term support`" version for Java 8. + +[NOTE] +==== +This document is in flux, and will be updated during implementation of the feature. +==== + +== Decision + +Jaybird 7 will implement schema support for Firebird 6.0. +When Jaybird 7 is used on Firebird 5.0 or older, it will behave as before (no schemas at all). + +Further details can be found in <>. + +Decision on backport to Jaybird 6 and/or Jaybird 5 is pending, and may be subject of a separate JDP. + +[#consequences] +== Consequences + +The following changes are made to Jaybird to support schemas when connecting to Firebird 6.0 or higher: + +* Connection property `searchPath` (alias `search_path`, `isc_dpb_search_path`) to configure the default session search path. ++ +On Firebird 5.0 and older, this will be silently ignored. +* In internal queries in Jaybird, and fully qualified object names, we'll always use the quoted identifier `"SYSTEM"`, because `SYSTEM` is SQL:2023 reserved word. +* `Connection.getSchema()` will return the result of `select CURRENT_SCHEMA from "SYSTEM".RDB$DATABASE`; +the connection will not store this value +* `Connection.setSchema(String)` will query the current search path, if not previously called, it will prepend the schema name to the search path, otherwise it will _replace_ the previously prepended schema name. +The schema name is stored _only_ for this replacement operation (i.e. it will not be returned by `getSchema`!) ++ +** The name must match exactly as is stored in the metadata (it is always case-sensitive!) +** Jaybird will take care of quoting, and will always quote +** Existence of the schema is **not** checked, so it is possible the current schema does not change with this operation, as `CURRENT_SCHEMA` reports the first _valid_ schema +** JDBC specifies that "`__Calling ``setSchema`` has no effect on previously created or prepared Statement objects.__`"; +Jaybird cannot honour this requirement for plain `Statement`, as schema resolution is on prepare time (which for plain `Statement` is on execute), and not always for `CallableStatement` (as the implementation may delay actual prepare until execution). +* Request `isc_info_sql_relation_schema` after preparing a query, record it in `FieldDescriptor`, and return it were relevant for JDBC (e.g. `ResultSetMetaData.getSchemaName(int)`) +** For Firebird 5.0 and older, we need to ensure that JDBC methods continue to report the correct value (i.e. ``""`` for schema-less objects) +* A Firebird 6.0 variant of the `DatabaseMetaData` and other internal metadata queries needs to be written to address at least the following things: +** Explicitly qualify metadata tables with `"SYSTEM"`, so the queries will work even if `SYSTEM` is not on the search path. +** Returning schema names, and qualified object names where relevant (e.g. in `DatabaseMetaData` result sets) +** Include schema names in joins to ensure matching the right objects +** Allow searching for schema or schema pattern as specified in JDBC, or were needed for internal metadata queries +** TODO: investigate need for backwards compatible behaviour for `DatabaseMetaData` parameter `schema` with value `""` ("`__[...] retrieves those without a schema__`"). +*** Maybe make it search `PUBLIC`, or `PUBLIC` and `SYSTEM`, or those on the search path? +*** Or add a compatibility connection property to make it behave as `null` ("`__[...] means that the schema name should not be used to narrow the search__`")? +*** Or just accept it as a breaking change? +* TODO: Define effects for management API + +[appendix] +== License Notice + +The contents of this Documentation are subject to the Public Documentation License Version 1.0 (the “License”); +you may only use this Documentation if you comply with the terms of this License. +A copy of the License is available at https://firebirdsql.org/en/public-documentation-license/. + +The Original Documentation is "`jdp-2025-06: Schema Support`". +The Initial Writer of the Original Documentation is Mark Rotteveel, Copyright © 2025. +All Rights Reserved. +(Initial Writer contact(s): mark (at) lawinegevaar (dot) nl). + +//// +Contributor(s): ______________________________________. +Portions created by ______ are Copyright © _________ [Insert year(s)]. +All Rights Reserved. +(Contributor contact(s): ________________ [Insert hyperlink/alias]). +//// + +The exact file history is recorded in our Git repository; +see https://github.com/FirebirdSQL/jaybird \ No newline at end of file From 9865b6bb2e8d313c9b2f383471642877ea058082 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Wed, 18 Jun 2025 12:03:11 +0200 Subject: [PATCH 02/64] #882 Fix tests failing due to Firebird 6 schemas --- .../firebirdsql/util/FirebirdSupportInfo.java | 12 +++++++++-- .../gds/ng/AbstractStatementTest.java | 20 +++++++++++++++---- .../FBPreparedStatementGeneratedKeysTest.java | 8 ++++++-- .../jdbc/FBStatementGeneratedKeysTest.java | 8 ++++++-- .../org/firebirdsql/jdbc/FBStatementTest.java | 8 ++++++-- 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java index c93a6da1e..1b566b40d 100644 --- a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java +++ b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java @@ -517,8 +517,7 @@ public int getSystemTableCount() { case 3 -> 50; case 4 -> 54; case 5 -> 56; - // Intentionally not merged with case 5 as it is likely to change during Firebird 6 development - case 6 -> 56; + case 6 -> 57; default -> -1; }; } @@ -785,6 +784,15 @@ public boolean supportsInlineBlobs() { return isVersionEqualOrAbove(5, 0, 3); } + /** + * Reports if schemas are supported. + * + * @return {@code true} if schemas are not supported, {@code false} otherwise + */ + public boolean supportsSchemas() { + return isVersionEqualOrAbove(6); + } + /** * @return {@code true} when this Firebird version is considered a supported version */ diff --git a/src/test/org/firebirdsql/gds/ng/AbstractStatementTest.java b/src/test/org/firebirdsql/gds/ng/AbstractStatementTest.java index eb738ac96..be44ce567 100644 --- a/src/test/org/firebirdsql/gds/ng/AbstractStatementTest.java +++ b/src/test/org/firebirdsql/gds/ng/AbstractStatementTest.java @@ -385,7 +385,11 @@ public void test_GetExecutionPlan_withStatementPrepared() throws Exception { String executionPlan = statement.getExecutionPlan(); - assertEquals("PLAN (RDB$DATABASE NATURAL)", executionPlan, "Unexpected plan for prepared statement"); + String expected = getDefaultSupportInfo().supportsSchemas() + ? "PLAN (\"SYSTEM\".\"RDB$DATABASE\" NATURAL)" + : "PLAN (RDB$DATABASE NATURAL)"; + + assertEquals(expected, executionPlan, "Unexpected plan for prepared statement"); } @Test @@ -428,9 +432,17 @@ public void test_GetExplainedExecutionPlan_withStatementPrepared() throws Except String executionPlan = statement.getExplainedExecutionPlan(); - assertEquals(""" - Select Expression - -> Table "RDB$DATABASE" Full Scan""", executionPlan, "Unexpected plan for prepared statement"); + //@formatter:off + String expected = getDefaultSupportInfo().supportsSchemas() + ? """ + Select Expression + -> Table "SYSTEM"."RDB$DATABASE" Full Scan""" + : """ + Select Expression + -> Table "RDB$DATABASE" Full Scan"""; + //@formatter:on + + assertEquals(expected, executionPlan, "Unexpected plan for prepared statement"); } @Test diff --git a/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java index 99e8dab98..dd4e8b2c1 100644 --- a/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java @@ -261,7 +261,9 @@ void testPrepare_INSERT_returnGeneratedKeys_nonExistentTable() { assertThat(exception, allOf( errorCode(equalTo(errorCode)), sqlState(equalTo("42S02")), - fbMessageContains(errorCode, "TABLE_NON_EXISTENT"))); + anyOf( + fbMessageContains(errorCode, "TABLE_NON_EXISTENT"), + fbMessageContains(errorCode, "\"TABLE_NON_EXISTENT\"")))); } /** @@ -377,7 +379,9 @@ void testPrepare_INSERT_columnNames_nonExistentColumn() { assertThat(exception, allOf( errorCode(equalTo(ISCConstants.isc_dsql_field_err)), sqlState(equalTo("42S22")), - message(containsString("Column unknown; NON_EXISTENT")))); + anyOf( + message(containsString("Column unknown; NON_EXISTENT")), + message(containsString("Column unknown; \"NON_EXISTENT\""))))); } // TODO In the current implementation executeUpdate uses almost identical logic as execute, decide to test separately or not diff --git a/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java index 1baf8526f..086a17ac0 100644 --- a/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java @@ -302,7 +302,9 @@ void testExecute_INSERT_returnGeneratedKeys_nonExistentTable() throws Exception assertThat(exception, allOf( errorCode(equalTo(errorCode)), sqlState(equalTo("42S02")), - fbMessageContains(errorCode, "TABLE_NON_EXISTENT"))); + anyOf( + fbMessageContains(errorCode, "TABLE_NON_EXISTENT"), + fbMessageContains(errorCode, "\"TABLE_NON_EXISTENT\"")))); } } @@ -497,7 +499,9 @@ void testExecute_INSERT_columnNames_nonExistentColumn() throws Exception { assertThat(exception, allOf( errorCode(equalTo(ISCConstants.isc_dsql_field_err)), sqlState(equalTo("42S22")), - message(containsString("Column unknown; NON_EXISTENT")))); + anyOf( + message(containsString("Column unknown; NON_EXISTENT")), + message(containsString("Column unknown; \"NON_EXISTENT\""))))); } } diff --git a/src/test/org/firebirdsql/jdbc/FBStatementTest.java b/src/test/org/firebirdsql/jdbc/FBStatementTest.java index 48d215f87..9928cd794 100644 --- a/src/test/org/firebirdsql/jdbc/FBStatementTest.java +++ b/src/test/org/firebirdsql/jdbc/FBStatementTest.java @@ -402,7 +402,9 @@ void testEscapeProcessingDisabled() throws SQLException { SQLException exception = assertThrows(SQLException.class, () -> stmt.executeQuery(testQuery)); assertThat(exception, allOf( - message(containsString("Column unknown; {FN")), + anyOf( + message(containsString("Column unknown; {FN")), + message(containsString("Column unknown; \"{FN\""))), sqlStateEquals("42S22"))); } } @@ -1076,7 +1078,9 @@ void psqlExceptionWithParametersRendering() throws Exception { end """)); assertThat(sqle, message(allOf( - startsWith("exception 1; EX_PARAM; something wrong in PARAMETER_1"), + anyOf( + startsWith("exception 1; EX_PARAM; something wrong in PARAMETER_1"), + startsWith("exception 1; \"PUBLIC\".\"EX_PARAM\"; something wrong in PARAMETER_1")), // The exception parameter value should not be repeated after the formatted message not(containsString("something wrong in PARAMETER_1; PARAMETER_1"))))); } From a3c597965e2dfced7199a13a288d3d2be129e82c Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 19 Jun 2025 10:55:49 +0200 Subject: [PATCH 03/64] #882 Implement to DatabaseMetaData.getSchemas return schemas --- src/docs/asciidoc/release_notes.adoc | 15 + .../firebirdsql/jdbc/FBDatabaseMetaData.java | 10 +- .../firebirdsql/jdbc/metadata/GetSchemas.java | 92 +++++- .../extension/UsesDatabaseExtension.java | 6 +- .../jdbc/FBDatabaseMetaDataSchemasTest.java | 275 ++++++++++++++++++ 5 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataSchemasTest.java diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 9d95fd6e5..c99699374 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -508,6 +508,21 @@ Artificial testing on local WiFi with small blobs (200 bytes) shows a 30,000-45, This optimization was backported to Jaybird 5.0.8 and Jaybird 6.0.2. +[#schemas] +=== Schema support + +Firebird 6.0 introduces schemas, and Jaybird 7 provides support for schemas as defined in the JDBC specification. + +Changes include: + +* `DatabaseMetaData` +** `getSchemas()` returns all defined schemas +** `getSchemas(String catalog, String schemaPattern)` returns all schemas matching the `LIKE` pattern `schemaPattern`, with the following caveats +*** `catalog` non-empty will return no rows; +we recommend to always use `null` for `catalog`. +*** `schemaPattern` empty will return no rows (there are no schemas with an empty name); +use `null` or `"%"` to match all schemas + // TODO add major changes [#other-fixes-and-changes] diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 40d560f8a..0fbacf302 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1263,6 +1263,11 @@ public ResultSet getSchemas() throws SQLException { return getSchemas(null, null); } + @Override + public ResultSet getSchemas(String catalog, String schemaPattern) throws SQLException { + return GetSchemas.create(getDbMetadataMediator()).getSchemas(catalog, schemaPattern); + } + /** * {@inheritDoc} *

@@ -1765,11 +1770,6 @@ public ResultSet getFunctions(String catalog, String schemaPattern, String funct return GetFunctions.create(getDbMetadataMediator()).getFunctions(catalog, functionNamePattern); } - @Override - public ResultSet getSchemas(String catalog, String schemaPattern) throws SQLException { - return GetSchemas.create(getDbMetadataMediator()).getSchemas(); - } - @Override public boolean isWrapperFor(Class iface) throws SQLException { return iface != null && iface.isAssignableFrom(FBDatabaseMetaData.class); diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java index a4206c29e..f3594c6de 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java @@ -4,13 +4,14 @@ package org.firebirdsql.jdbc.metadata; import org.firebirdsql.gds.ng.fields.RowDescriptor; +import org.firebirdsql.gds.ng.fields.RowValue; import org.firebirdsql.jdbc.DbMetadataMediator; -import org.firebirdsql.jdbc.FBResultSet; +import org.firebirdsql.jdbc.DbMetadataMediator.MetadataQuery; +import org.firebirdsql.util.FirebirdSupportInfo; import java.sql.ResultSet; import java.sql.SQLException; -import static java.util.Collections.emptyList; import static org.firebirdsql.gds.ISCConstants.SQL_VARYING; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.OBJECT_NAME_LENGTH; @@ -20,23 +21,100 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetSchemas { +public abstract class GetSchemas extends AbstractMetadataMethod { private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(2) .at(0).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "TABLE_SCHEM", "TABLESCHEMAS").addField() .at(1).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "TABLE_CATALOG", "TABLESCHEMAS").addField() .toRowDescriptor(); - private GetSchemas() { + private GetSchemas(DbMetadataMediator mediator) { + super(ROW_DESCRIPTOR, mediator); } - public ResultSet getSchemas() throws SQLException { - return new FBResultSet(ROW_DESCRIPTOR, emptyList()); + public ResultSet getSchemas(String catalog, String schemaPattern) throws SQLException { + if (!(catalog == null || catalog.isEmpty()) || "".equals(schemaPattern)) { + // matching schema name not possible + return createEmpty(); + } + MetadataQuery metadataQuery = createGetSchemasQuery(schemaPattern); + return createMetaDataResultSet(metadataQuery); } + @Override + final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { + return valueBuilder + .at(0).setString(rs.getString("TABLE_SCHEM")) + .toRowValue(true); + } + + abstract MetadataQuery createGetSchemasQuery(String schemaPattern); + @SuppressWarnings("unused") public static GetSchemas create(DbMetadataMediator mediator) { - return new GetSchemas(); + FirebirdSupportInfo firebirdSupportInfo = mediator.getFirebirdSupportInfo(); + // NOTE: Indirection through static method prevents unnecessary classloading + if (firebirdSupportInfo.isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else { + return FB5.createInstance(mediator); + } + } + + /** + * Implementation for Firebird 5.0 and older. + */ + private static final class FB5 extends GetSchemas { + + private FB5(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetSchemas createInstance(DbMetadataMediator mediator) { + return new FB5(mediator); + } + + @Override + public ResultSet getSchemas(String catalog, String schemaPattern) throws SQLException { + return createEmpty(); + } + + @Override + MetadataQuery createGetSchemasQuery(String schemaPattern) { + throw new UnsupportedOperationException("This method should not get called for Firebird 5.0 and older"); + } + + } + + /** + * Implementation for Firebird 6.0 and higher. + */ + private static final class FB6 extends GetSchemas { + + private static final String GET_SCHEMAS_FRAGMENT_6 = """ + select RDB$SCHEMA_NAME as TABLE_SCHEM + from "SYSTEM".RDB$SCHEMAS + """; + + private static final String GET_SCHEMAS_ORDER_BY_6 = "\norder by RDB$SCHEMA_NAME"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetSchemas createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetSchemasQuery(String schemaPattern) { + var schemaClause = new Clause("RDB$SCHEMA_NAME", schemaPattern); + String sql = GET_SCHEMAS_FRAGMENT_6 + + (schemaClause.hasCondition() ? "\nwhere " + schemaClause.getCondition(false) : "") + + GET_SCHEMAS_ORDER_BY_6; + return new MetadataQuery(sql, Clause.parameters(schemaClause)); + } + } } diff --git a/src/test/org/firebirdsql/common/extension/UsesDatabaseExtension.java b/src/test/org/firebirdsql/common/extension/UsesDatabaseExtension.java index b7b782b4c..2c886833b 100644 --- a/src/test/org/firebirdsql/common/extension/UsesDatabaseExtension.java +++ b/src/test/org/firebirdsql/common/extension/UsesDatabaseExtension.java @@ -11,9 +11,10 @@ import org.junit.jupiter.api.extension.ExtensionContext; import java.sql.SQLException; -import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import static java.util.Collections.emptyList; import static org.firebirdsql.common.FBTestProperties.createFBManager; @@ -40,7 +41,7 @@ public abstract class UsesDatabaseExtension { private final boolean initialCreate; private FBManager fbManager = null; private final List initStatements; - private final List databasesToDrop = new ArrayList<>(); + private final Set databasesToDrop = new HashSet<>(); private UsesDatabaseExtension(boolean initialCreate) { this(initialCreate, emptyList()); @@ -72,6 +73,7 @@ void sharedAfter() { } catch (Exception e){ System.getLogger(getClass().getName()).log(System.Logger.Level.ERROR, "Exception dropping DBs", e); } finally { + databasesToDrop.clear(); try { if (!(fbManager == null || fbManager.getState().equals("Stopped"))) { fbManager.stop(); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataSchemasTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataSchemasTest.java new file mode 100644 index 000000000..faf3b0cca --- /dev/null +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataSchemasTest.java @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jdbc; + +import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeFeatureMissing; +import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; +import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNoNextRow; + +/** + * Tests for {@link FBDatabaseMetaData} for schema related metadata. + */ +class FBDatabaseMetaDataSchemasTest { + + @RegisterExtension + static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll(); + + private static final MetadataResultSetDefinition getSchemasDefinition = + new MetadataResultSetDefinition(SchemaMetaData.class); + public static final List DEFAULT_SCHEMAS = List.of("PUBLIC", "SYSTEM"); + + private static Connection con; + private static DatabaseMetaData dbmd; + + @BeforeAll + static void setupAll() throws SQLException { + con = getConnectionViaDriverManager(); + dbmd = con.getMetaData(); + } + + @AfterEach + void cleanupAdditionalSchemas() throws SQLException { + if (!getDefaultSupportInfo().supportsSchemas()) return; + var schemasToDrop = new HashSet(); + try (ResultSet schemas = dbmd.getSchemas()) { + while (schemas.next()) { + String schemaName = schemas.getString("TABLE_SCHEM"); + if (DEFAULT_SCHEMAS.contains(schemaName)) continue; + schemasToDrop.add(schemaName); + } + } + if (schemasToDrop.isEmpty()) return; + try (var stmt = con.createStatement()) { + con.setAutoCommit(false); + for (String schemaName : schemasToDrop) { + stmt.addBatch("drop schema " + stmt.enquoteIdentifier(schemaName, false)); + } + stmt.executeBatch(); + } finally { + con.setAutoCommit(true); + } + } + + @AfterAll + static void tearDownAll() throws SQLException { + try { + con.close(); + } finally { + con = null; + dbmd = null; + } + } + + /** + * Tests the ordinal positions and types for the metadata columns of getSchemas(...). + */ + @Test + void testSchemaMetaDataColumns() throws Exception { + try (ResultSet columns = dbmd.getSchemas(null, "doesnotexist")) { + getSchemasDefinition.validateResultSetColumns(columns); + } + } + + @Test + void getSchemas_noSchemaSupport_noRows() throws Exception { + requireNoSchemaSupport(); + ResultSet schemas = dbmd.getSchemas(); + assertNoNextRow(schemas); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = "%") + void getSchemas_string_string_noSchemaSupport_noRows(String schemaPattern) throws Exception { + requireNoSchemaSupport(); + validateSchemaMetaDataNoRow(null, schemaPattern); + } + + @Test + void getSchemas_string_string_emptySchemaPattern_noRows() throws Exception { + // No rows expected with and without schema support + validateSchemaMetaDataNoRow(null, ""); + } + + @Test + void getSchemas_schemaSupport_defaults() throws Exception { + requireSchemaSupport(); + try (ResultSet schemas = dbmd.getSchemas()) { + // calling getSchemas() is equivalent to calling getSchemas(null, null) + validateSchemaMetaData(null, schemas, DEFAULT_SCHEMAS); + } + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = "%") + void getSchemas_string_string_schemaSupport_defaults(String schemaPattern) throws Exception { + requireSchemaSupport(); + validateSchemaMetaData(null, schemaPattern, List.of("PUBLIC", "SYSTEM")); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + schemaPattern, expectedSchema + PUBLIC, PUBLIC + SYSTEM, SYSTEM + PUB%, PUBLIC + SYS%, SYSTEM + PUBL_C, PUBLIC + S_STEM, SYSTEM + """) + void getSchemas_string_string_schemaSupport_singleSchemaExpected(String schemaPattern, String expectedSchemaName) + throws Exception { + requireSchemaSupport(); + validateSchemaMetaData(null, schemaPattern, List.of(expectedSchemaName)); + } + + @ParameterizedTest + @NullAndEmptySource + void getSchemas_string_string_schemaSupport_catalogNullOrEmpty_defaults(String catalog) throws Exception { + requireSchemaSupport(); + validateSchemaMetaData(catalog, "%", DEFAULT_SCHEMAS); + } + + @Test + void getSchemas_string_string_catalogNonEmpty_noRows() throws Exception { + // Should return no rows with or without schema support + validateSchemaMetaDataNoRow("NON_EMPTY", null); + } + + @Test + void getSchema_string_string_schemaSupport_returnsUserDefinedSchemas() throws Exception { + requireSchemaSupport(); + try (var stmt = con.createStatement()) { + con.setAutoCommit(false); + for (String schema : List.of("ABC", "QRS", "TUV")) { + stmt.execute("create schema " + schema); + } + } finally { + con.setAutoCommit(true); + } + validateSchemaMetaData("", "%", List.of("ABC", "PUBLIC", "QRS", "SYSTEM", "TUV")); + } + + private static void requireNoSchemaSupport() { + assumeFeatureMissing(FirebirdSupportInfo::supportsSchemas, "Test requires no schema support"); + } + + private static void requireSchemaSupport() { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + } + + /** + * Helper method for test methods that retrieve metadata expecting no results. + * + * @param schemaPattern + * pattern of the schema name + */ + private void validateSchemaMetaDataNoRow(String catalog, String schemaPattern) throws SQLException { + try (ResultSet schemas = dbmd.getSchemas(catalog, schemaPattern)) { + assertNoNextRow(schemas, "Expected empty result set for requesting " + schemaPattern); + } + } + + /** + * Helper method for test methods that retrieve metadata expecting schemas. + * + * @param schemaPattern + * pattern of the schema name + * @param expectedSchemaNames + * expected schema names in order of appearance + */ + private void validateSchemaMetaData(String catalog, String schemaPattern, List expectedSchemaNames) + throws SQLException { + try (ResultSet schemas = dbmd.getSchemas(catalog, schemaPattern)) { + validateSchemaMetaData(schemaPattern, schemas, expectedSchemaNames); + } + } + + /** + * Helper method for test methods that retrieve metadata expecting schemas. + * + * @param schemaPattern + * pattern of the schema name (for diagnostics only) + * @param schemas + * schema result set as returned by one of the database metadata {@code getSchema} methods + * @param expectedSchemaNames + * expected schema names in order of appearance + */ + private static void validateSchemaMetaData(String schemaPattern, ResultSet schemas, + List expectedSchemaNames) throws SQLException { + for (String expectedSchemaName : expectedSchemaNames) { + assertNextRow(schemas, + "Pattern '%s', expected row for schema name %s".formatted(schemaPattern, expectedSchemaName)); + Map valueRules = getDefaultValueValidationRules(); + valueRules.put(SchemaMetaData.TABLE_SCHEM, expectedSchemaName); + getSchemasDefinition.validateRowValues(schemas, valueRules); + } + assertNoNextRow(schemas, "Expected no more schema names for pattern " + schemaPattern); + } + + private static final Map DEFAULT_COLUMN_VALUES; + static { + Map defaults = new EnumMap<>(SchemaMetaData.class); + defaults.put(SchemaMetaData.TABLE_CATALOG, null); + + DEFAULT_COLUMN_VALUES = Collections.unmodifiableMap(defaults); + } + + private static Map getDefaultValueValidationRules() { + return new EnumMap<>(DEFAULT_COLUMN_VALUES); + } + + /** + * Columns defined for the getTables() metadata. + */ + private enum SchemaMetaData implements MetaDataInfo { + TABLE_SCHEM(1, String.class), + TABLE_CATALOG(2, String.class), + ; + + private final int position; + private final Class columnClass; + + SchemaMetaData(int position, Class columnClass) { + this.position = position; + this.columnClass = columnClass; + } + + @Override + public int getPosition() { + return position; + } + + @Override + public Class getColumnClass() { + return columnClass; + } + } +} From 7deb6ed741afe9375e1c6f4adbaaf4135bcbd06c Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 19 Jun 2025 11:23:45 +0200 Subject: [PATCH 04/64] #882 Implement DatabaseMetaData informational methods to report schema support --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 17 ++++++------ .../jdbc/FBDatabaseMetaDataTest.java | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 0fbacf302..b346877ff 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -764,12 +764,11 @@ public boolean supportsLimitedOuterJoins() throws SQLException { /** * {@inheritDoc} * - * @return the vendor term, always {@code null} because schemas are not supported by database server (see JDBC CTS - * for details). + * @return the vendor term; for Firebird 5.0 and older always {@code null} because schemas are not supported */ @Override public String getSchemaTerm() throws SQLException { - return null; + return firebirdSupportInfo.supportsSchemas() ? "SCHEMA" : null; } @Override @@ -814,27 +813,27 @@ public String getCatalogSeparator() throws SQLException { @Override public boolean supportsSchemasInDataManipulation() throws SQLException { - return false; + return firebirdSupportInfo.supportsSchemas(); } @Override public boolean supportsSchemasInProcedureCalls() throws SQLException { - return false; + return firebirdSupportInfo.supportsSchemas(); } @Override public boolean supportsSchemasInTableDefinitions() throws SQLException { - return false; + return firebirdSupportInfo.supportsSchemas(); } @Override public boolean supportsSchemasInIndexDefinitions() throws SQLException { - return false; + return firebirdSupportInfo.supportsSchemas(); } @Override public boolean supportsSchemasInPrivilegeDefinitions() throws SQLException { - return false; + return firebirdSupportInfo.supportsSchemas(); } /** @@ -1031,7 +1030,7 @@ public int getMaxIndexLength() throws SQLException { @Override public int getMaxSchemaNameLength() throws SQLException { - return 0; //No schemas + return firebirdSupportInfo.supportsSchemas() ? getMaxObjectNameLength() : 0; } /** diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java index 25032199e..ddadb9088 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java @@ -9,6 +9,7 @@ import org.firebirdsql.common.DdlHelper; import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -809,6 +810,32 @@ void testGetIdentifierQuoteString_dialect3Db(String connectionDialect, String ex } } + @Test + void testGetSchemaTerm() throws Exception { + final String expected = getDefaultSupportInfo().supportsSchemas() ? "SCHEMA" : null; + assertEquals(expected, dmd.getSchemaTerm(), "schemaTerm"); + } + + @Test + void testGetMaxSchemaNameLength() throws Exception { + FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); + final int expected = getDefaultSupportInfo().supportsSchemas() + ? supportInfo.maxIdentifierLengthCharacters() : 0; + assertEquals(expected, dmd.getMaxSchemaNameLength(), "maxSchemaNameLength"); + } + + @Test + void testSupportsSchemasInXXX() { + final boolean expected = getDefaultSupportInfo().supportsSchemas(); + assertAll( + () -> assertEquals(expected, dmd.supportsSchemasInDataManipulation(), "DataManipulation"), + () -> assertEquals(expected, dmd.supportsSchemasInIndexDefinitions(), "IndexDefinitions"), + () -> assertEquals(expected, dmd.supportsSchemasInPrivilegeDefinitions(), "PrivilegeDefinitions"), + () -> assertEquals(expected, dmd.supportsSchemasInProcedureCalls(), "ProcedureCalls"), + () -> assertEquals(expected, dmd.supportsSchemasInTableDefinitions(), "TableDefinitions") + ); + } + @SuppressWarnings("SameParameterValue") private void createPackage(String packageName, String procedureName) throws Exception { try (Statement stmt = connection.createStatement()) { From 740ce4da7cd89690ca543ef8a2e3d7b52c8f6d0d Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 19 Jun 2025 12:54:40 +0200 Subject: [PATCH 05/64] Use isNullOrEmpty --- src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java | 7 ++++--- src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java index f3594c6de..d878190e8 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2023 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -13,6 +13,7 @@ import java.sql.SQLException; import static org.firebirdsql.gds.ISCConstants.SQL_VARYING; +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.OBJECT_NAME_LENGTH; /** @@ -33,7 +34,7 @@ private GetSchemas(DbMetadataMediator mediator) { } public ResultSet getSchemas(String catalog, String schemaPattern) throws SQLException { - if (!(catalog == null || catalog.isEmpty()) || "".equals(schemaPattern)) { + if (!isNullOrEmpty(catalog) || "".equals(schemaPattern)) { // matching schema name not possible return createEmpty(); } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java index ddadb9088..278080ab1 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java @@ -2,7 +2,7 @@ SPDX-FileCopyrightText: Copyright 2001-2002 David Jencks SPDX-FileCopyrightText: Copyright 2002-2010 Roman Rokytskyy SPDX-FileCopyrightText: Copyright 2002-2003 Blas Rodriguez Somoza - SPDX-FileCopyrightText: Copyright 2011-2023 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc; From 286eea0444870ffd9ab7d967f28a93b654901698 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 19 Jun 2025 13:15:26 +0200 Subject: [PATCH 06/64] #882 Schema support for DatabaseMetaData.getProcedures --- src/docs/asciidoc/release_notes.adoc | 6 +- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 5 +- .../jdbc/metadata/GetProcedures.java | 127 ++++++++++++++++-- .../firebirdsql/jdbc/metadata/NameHelper.java | 62 +++++++-- .../FBDatabaseMetaDataProceduresTest.java | 27 ++-- .../jdbc/metadata/NameHelperTest.java | 18 ++- 6 files changed, 204 insertions(+), 41 deletions(-) diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index c99699374..f820b919b 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -516,10 +516,14 @@ Firebird 6.0 introduces schemas, and Jaybird 7 provides support for schemas as d Changes include: * `DatabaseMetaData` +** `getProcedures` now uses the `schemaPattern` to filter by schema, with the following caveats +*** `schemaPattern` empty will return no rows on Firebird 6.0 and higher (all procedures are in a schema); +use `null` or `"%"` to match all schemas +*** `catalog` is (still) ignored if `useCatalogAsPackage` is `false` ** `getSchemas()` returns all defined schemas ** `getSchemas(String catalog, String schemaPattern)` returns all schemas matching the `LIKE` pattern `schemaPattern`, with the following caveats *** `catalog` non-empty will return no rows; -we recommend to always use `null` for `catalog`. +we recommend to always use `null` for `catalog` *** `schemaPattern` empty will return no rows (there are no schemas with an empty name); use `null` or `"%"` to match all schemas diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index b346877ff..0915d2b93 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -8,7 +8,7 @@ SPDX-FileCopyrightText: Copyright 2005 Michael Romankiewicz SPDX-FileCopyrightText: Copyright 2005 Steven Jardine SPDX-FileCopyrightText: Copyright 2007 Gabriel Reid - SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc; @@ -1203,7 +1203,8 @@ public boolean dataDefinitionIgnoredInTransactions() throws SQLException { @Override public ResultSet getProcedures(String catalog, String schemaPattern, String procedureNamePattern) throws SQLException { - return GetProcedures.create(getDbMetadataMediator()).getProcedures(catalog, procedureNamePattern); + return GetProcedures.create(getDbMetadataMediator()) + .getProcedures(catalog, schemaPattern, procedureNamePattern); } /** diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java index 22d0f7b5e..22e5f8bff 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -33,10 +33,12 @@ public abstract class GetProcedures extends AbstractMetadataMethod { private static final String PROCEDURES = "PROCEDURES"; private static final String COLUMN_PROCEDURE_NAME = "RDB$PROCEDURE_NAME"; + private static final String COLUMN_SCHEMA_NAME = "RDB$SCHEMA_NAME"; + private static final String COLUMN_PACKAGE_NAME = "RDB$PACKAGE_NAME"; private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(9) .at(0).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "PROCEDURE_CAT", PROCEDURES).addField() - .at(1).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "PROCEDURE_SCHEM", "ROCEDURES").addField() + .at(1).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "PROCEDURE_SCHEM", PROCEDURES).addField() .at(2).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "PROCEDURE_NAME", PROCEDURES).addField() .at(3).simple(SQL_VARYING, 31, "FUTURE1", PROCEDURES).addField() .at(4).simple(SQL_VARYING, 31, "FUTURE2", PROCEDURES).addField() @@ -55,34 +57,42 @@ private GetProcedures(DbMetadataMediator mediator) { /** * @see DatabaseMetaData#getProcedures(String, String, String) */ - public final ResultSet getProcedures(String catalog, String procedureNamePattern) throws SQLException { + public final ResultSet getProcedures(String catalog, String schemaPattern, String procedureNamePattern) + throws SQLException { if ("".equals(procedureNamePattern)) { return createEmpty(); } - MetadataQuery metadataQuery = createGetProceduresQuery(catalog, procedureNamePattern); + MetadataQuery metadataQuery = createGetProceduresQuery(catalog, schemaPattern, procedureNamePattern); return createMetaDataResultSet(metadataQuery); } @Override final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { String catalog = rs.getString("PROCEDURE_CAT"); + String schema = rs.getString("PROCEDURE_SCHEM"); String procedureName = rs.getString("PROCEDURE_NAME"); return valueBuilder .at(0).setString(catalog) + .at(1).setString(schema) .at(2).setString(procedureName) .at(6).setString(rs.getString("REMARKS")) .at(7).setShort(rs.getShort("PROCEDURE_TYPE") == 0 ? procedureNoResult : procedureReturnsResult) - .at(8).setString(toSpecificName(catalog, procedureName)) + .at(8).setString(toSpecificName(catalog, schema, procedureName)) .toRowValue(true); } - abstract MetadataQuery createGetProceduresQuery(String catalog, String procedureNamePattern); + abstract MetadataQuery createGetProceduresQuery(String catalog, String schemaPattern, String procedureNamePattern); public static GetProcedures create(DbMetadataMediator mediator) { FirebirdSupportInfo firebirdSupportInfo = mediator.getFirebirdSupportInfo(); // NOTE: Indirection through static method prevents unnecessary classloading - if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { + if (firebirdSupportInfo.isVersionEqualOrAbove(6)) { + if (mediator.isUseCatalogAsPackage()) { + return FB6CatalogAsPackage.createInstance(mediator); + } + return FB6.createInstance(mediator); + }else if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { if (mediator.isUseCatalogAsPackage()) { return FB3CatalogAsPackage.createInstance(mediator); } @@ -98,6 +108,7 @@ private static final class FB2_5 extends GetProcedures { private static final String GET_PROCEDURES_FRAGMENT_2_5 = """ select null as PROCEDURE_CAT, + null as PROCEDURE_SCHEM, RDB$PROCEDURE_NAME as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE @@ -114,7 +125,7 @@ private static GetProcedures createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetProceduresQuery(String catalog, String procedureNamePattern) { + MetadataQuery createGetProceduresQuery(String catalog, String schemaPattern, String procedureNamePattern) { Clause procedureNameClause = new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern); String queryText = GET_PROCEDURES_FRAGMENT_2_5 + procedureNameClause.getCondition("\nwhere ", "") @@ -128,6 +139,7 @@ private static final class FB3 extends GetProcedures { private static final String GET_PROCEDURES_FRAGMENT_3 = """ select null as PROCEDURE_CAT, + null as PROCEDURE_SCHEM, trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE @@ -146,7 +158,7 @@ private static GetProcedures createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetProceduresQuery(String catalog, String procedureNamePattern) { + MetadataQuery createGetProceduresQuery(String catalog, String schemaPattern, String procedureNamePattern) { Clause procedureNameClause = new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern); String queryText = GET_PROCEDURES_FRAGMENT_3 + procedureNameClause.getCondition("\nand ", "") @@ -160,12 +172,12 @@ private static final class FB3CatalogAsPackage extends GetProcedures { private static final String GET_PROCEDURES_FRAGMENT_3_W_PKG = """ select coalesce(trim(trailing from RDB$PACKAGE_NAME), '') as PROCEDURE_CAT, + null as PROCEDURE_SCHEM, trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE from RDB$PROCEDURES"""; - // NOTE: Including RDB$PACKAGE_NAME so index can be used to sort private static final String GET_PROCEDURES_ORDER_BY_3_W_PKG = "\norder by RDB$PACKAGE_NAME nulls first, RDB$PROCEDURE_NAME"; @@ -178,16 +190,16 @@ private static GetProcedures createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetProceduresQuery(String catalog, String procedureNamePattern) { + MetadataQuery createGetProceduresQuery(String catalog, String schemaPattern, String procedureNamePattern) { var clauses = new ArrayList(2); if (catalog != null) { // To quote from the JDBC API: "" retrieves those without a catalog; null means that the catalog name // should not be used to narrow the search if (catalog.isEmpty()) { - clauses.add(Clause.isNullClause("RDB$PACKAGE_NAME")); + clauses.add(Clause.isNullClause(COLUMN_PACKAGE_NAME)); } else { // Exact matches only - clauses.add(Clause.equalsClause("RDB$PACKAGE_NAME", catalog)); + clauses.add(Clause.equalsClause(COLUMN_PACKAGE_NAME, catalog)); } } clauses.add(new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern)); @@ -201,4 +213,91 @@ MetadataQuery createGetProceduresQuery(String catalog, String procedureNamePatte return new MetadataQuery(sql, Clause.parameters(clauses)); } } + + private static final class FB6 extends GetProcedures { + + private static final String GET_PROCEDURES_FRAGMENT_6 = """ + select + null as PROCEDURE_CAT, + trim(trailing from RDB$SCHEMA_NAME) as PROCEDURE_SCHEM, + trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, + RDB$DESCRIPTION as REMARKS, + RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE + from "SYSTEM".RDB$PROCEDURES + where RDB$PACKAGE_NAME is null"""; + + // NOTE: Including RDB$PACKAGE_NAME so index can be used to sort + private static final String GET_PROCEDURES_ORDER_BY_6 = + "\norder by RDB$SCHEMA_NAME, RDB$PACKAGE_NAME, RDB$PROCEDURE_NAME"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetProcedures createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetProceduresQuery(String catalog, String schemaPattern, String procedureNamePattern) { + var schemaNameClause = new Clause(COLUMN_SCHEMA_NAME, schemaPattern); + var procedureNameClause = new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern); + //@formatter:off + String queryText = GET_PROCEDURES_FRAGMENT_6 + + (Clause.anyCondition(schemaNameClause, procedureNameClause) + ? "\nand " + Clause.conjunction(schemaNameClause, procedureNameClause) + : "") + + GET_PROCEDURES_ORDER_BY_6; + //@formatter:on + return new MetadataQuery(queryText, Clause.parameters(schemaNameClause, procedureNameClause)); + } + } + + private static final class FB6CatalogAsPackage extends GetProcedures { + + private static final String GET_PROCEDURES_FRAGMENT_6_W_PKG = """ + select + coalesce(trim(trailing from RDB$PACKAGE_NAME), '') as PROCEDURE_CAT, + trim(trailing from RDB$SCHEMA_NAME) as PROCEDURE_SCHEM, + trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, + RDB$DESCRIPTION as REMARKS, + RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE + from "SYSTEM".RDB$PROCEDURES"""; + + private static final String GET_PROCEDURES_ORDER_BY_6_W_PKG = + "\norder by RDB$PACKAGE_NAME nulls first, RDB$SCHEMA_NAME, RDB$PROCEDURE_NAME"; + + private FB6CatalogAsPackage(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetProcedures createInstance(DbMetadataMediator mediator) { + return new FB6CatalogAsPackage(mediator); + } + + @Override + MetadataQuery createGetProceduresQuery(String catalog, String schemaPattern, String procedureNamePattern) { + var clauses = new ArrayList(3); + clauses.add(new Clause(COLUMN_SCHEMA_NAME, schemaPattern)); + if (catalog != null) { + // To quote from the JDBC API: "" retrieves those without a catalog; null means that the catalog name + // should not be used to narrow the search + if (catalog.isEmpty()) { + clauses.add(Clause.isNullClause(COLUMN_PACKAGE_NAME)); + } else { + // Exact matches only + clauses.add(Clause.equalsClause(COLUMN_PACKAGE_NAME, catalog)); + } + } + clauses.add(new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern)); + //@formatter:off + String sql = GET_PROCEDURES_FRAGMENT_6_W_PKG + + (Clause.anyCondition(clauses) + ? "\nwhere " + Clause.conjunction(clauses) + : "") + + GET_PROCEDURES_ORDER_BY_6_W_PKG; + //@formatter:on + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + } } diff --git a/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java b/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java index ec807a550..ef7651e3d 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java +++ b/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java @@ -1,8 +1,12 @@ -// SPDX-FileCopyrightText: Copyright 2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2023-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; import org.firebirdsql.jdbc.QuoteStrategy; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; /** * Helper methods for generating object names. @@ -10,35 +14,75 @@ * @author Mark Rotteveel * @since 6 */ +@NullMarked final class NameHelper { private NameHelper() { // no instances } + // TODO Remove once all metadata methods have been rewritten to support schemas + @Deprecated + static String toSpecificName(@Nullable String catalog, String routineName) { + return toSpecificName(catalog, null, routineName); + } + /** * Generates a name for the {@code SPECIFIC_NAME} column of {@code getFunctions}, {@code getFunctionColumns}, * {@code getProcedures} and {@code getProcedureColumns}. + *

+ * The specific name is generated as follows: + *

+ *
    + *
  • + *

    For Firebird versions without schema support

    + *
      + *
    • for non-packaged routines, the {@code routineName}
    • + *
    • for packaged routines, both {@code catalog} (package name) and {@code routineName} are transformed to + * quoted identifiers and separated by {@code .} (period)
    • + *
    + *
  • + *
  • + *

    For Firebird versions with schema support

    + *
      + *
    • for non-packaged routines, both {@code schema} and {@code routineName} are transformed to + * quoted identifiers and separated by {@code .} (period)
    • + *
    • for packaged routines, {@code catalog} (package name), {@code schema} and {@code routineName} are + * transformed to quoted identifiers and separated by {@code .} (period)
    • + *
    + *
  • + *
* * @param catalog * generally {@code null}, or — when {@code useCatalogAsPackage = true} — an empty string (no * package) or a package name + * @param schema + * schema name, or {@code null} for Firebird versions without schema support, empty string is handled same + * as {@code null} * @param routineName * name of the routine (procedure or function) - * @return specific name: for non-packaged routines the {@code routineName}, of packaged routines, both - * {@code catalog} (package name) and {@code routineName} are transformed to quoted identifiers and separated by - * {@code .} (period) + * @return specific name */ - static String toSpecificName(String catalog, String routineName) { - if (catalog == null || catalog.isEmpty()) { + static String toSpecificName(@Nullable String catalog, @Nullable String schema, String routineName) { + if (isNullOrEmpty(catalog) && isNullOrEmpty(schema)) { return routineName; } var quoteStrategy = QuoteStrategy.DIALECT_3; - // 5: 4 quotes + 1 separator - var sb = new StringBuilder(catalog.length() + routineName.length() + 5); - quoteStrategy.appendQuoted(catalog, sb).append('.'); + // 8: 6 quotes + 2 separators + var sb = new StringBuilder(length(catalog) + length(schema) + routineName.length() + 8); + if (!isNullOrEmpty(schema)) { + quoteStrategy.appendQuoted(schema, sb).append('.'); + } + // this order assumes the catalog actually represents the package name + if (!isNullOrEmpty(catalog)) { + quoteStrategy.appendQuoted(catalog, sb).append('.'); + } quoteStrategy.appendQuoted(routineName, sb); return sb.toString(); } + private static int length(@Nullable String value) { + return value != null ? value.length() : 0; + } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java index ad4e593bd..3f56d38b2 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2012-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -37,6 +37,8 @@ */ class FBDatabaseMetaDataProceduresTest { + // TODO Add tests for filtering by schema + private static final String CREATE_NORMAL_PROC_NO_RETURN = """ CREATE PROCEDURE normal_proc_no_return ( param1 VARCHAR(100)) @@ -302,10 +304,11 @@ private void validateProcedures(ResultSet procedures, List ex } static boolean isIgnoredProcedure(String specificName) { - class Ignored { + final class Ignored { // Skipping procedures from system packages (when testing with useCatalogAsPackage=true) private static final List PREFIXES_TO_IGNORE = - List.of("\"RDB$BLOB_UTIL\".", "\"RDB$PROFILER\".", "\"RDB$TIME_ZONE_UTIL\".", "\"RDB$SQL\"."); + List.of("\"SYSTEM\".\"RDB$", "\"RDB$BLOB_UTIL\".", "\"RDB$PROFILER\".", "\"RDB$TIME_ZONE_UTIL\".", + "\"RDB$SQL\"."); } return Ignored.PREFIXES_TO_IGNORE.stream().anyMatch(specificName::startsWith); } @@ -322,7 +325,7 @@ static Map modifyForUseCatalogAsPackage(ProcedureTest static { Map defaults = new EnumMap<>(ProcedureMetaData.class); defaults.put(ProcedureMetaData.PROCEDURE_CAT, null); - defaults.put(ProcedureMetaData.PROCEDURE_SCHEM, null); + defaults.put(ProcedureMetaData.PROCEDURE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(ProcedureMetaData.FUTURE1, null); defaults.put(ProcedureMetaData.FUTURE2, null); defaults.put(ProcedureMetaData.FUTURE3, null); @@ -369,13 +372,18 @@ public Class getColumnClass() { } } + private static String ifSchemaElse(String forSchema, String withoutSchema) { + return getDefaultSupportInfo().supportsSchemas() ? forSchema : withoutSchema; + } + private enum ProcedureTestData { NORMAL_PROC_NO_RETURN("normal_proc_no_return", List.of(CREATE_NORMAL_PROC_NO_RETURN)) { @Override Map getSpecificValidationRules(Map rules) { rules.put(ProcedureMetaData.PROCEDURE_NAME, "NORMAL_PROC_NO_RETURN"); rules.put(ProcedureMetaData.PROCEDURE_TYPE, DatabaseMetaData.procedureNoResult); - rules.put(ProcedureMetaData.SPECIFIC_NAME, "NORMAL_PROC_NO_RETURN"); + rules.put(ProcedureMetaData.SPECIFIC_NAME, + ifSchemaElse("\"PUBLIC\".\"NORMAL_PROC_NO_RETURN\"", "NORMAL_PROC_NO_RETURN")); return rules; } }, @@ -386,7 +394,8 @@ Map getSpecificValidationRules(Map getSpecificValidationRules(Map getSpecificValidationRules(Map rules) { rules.put(ProcedureMetaData.PROCEDURE_NAME, "quoted_proc_no_return"); rules.put(ProcedureMetaData.PROCEDURE_TYPE, DatabaseMetaData.procedureNoResult); - rules.put(ProcedureMetaData.SPECIFIC_NAME, "quoted_proc_no_return"); + rules.put(ProcedureMetaData.SPECIFIC_NAME, + ifSchemaElse("\"PUBLIC\".\"quoted_proc_no_return\"", "quoted_proc_no_return")); return rules; } }, @@ -407,7 +417,8 @@ Map getSpecificValidationRules(Map, ROUTINE, ROUTINE - PACKAGE, ROUTINE, "PACKAGE"."ROUTINE" - WITH"DOUBLE, DOUBLE"QUOTE, "WITH""DOUBLE"."DOUBLE""QUOTE" + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + catalog, schema, routineName, expectedSpecificName + , , ROUTINE, ROUTINE + , PUBLIC, ROUTINE, "PUBLIC"."ROUTINE" + PACKAGE, , ROUTINE, "PACKAGE"."ROUTINE" + PACKAGE, PUBLIC, ROUTINE, "PUBLIC"."PACKAGE"."ROUTINE" + WITH"DOUBLE, , DOUBLE"QUOTE, "WITH""DOUBLE"."DOUBLE""QUOTE" + WITH"DOUBLE, PUBLIC, DOUBLE"QUOTE, "PUBLIC"."WITH""DOUBLE"."DOUBLE""QUOTE" """, nullValues = "") - void testToSpecificName(String catalog, String routineName, String expectedResult) { - assertEquals(expectedResult, NameHelper.toSpecificName(catalog, routineName)); + void testToSpecificName(String catalog, String schema, String routineName, String expectedResult) { + assertEquals(expectedResult, NameHelper.toSpecificName(catalog, schema, routineName)); } } \ No newline at end of file From 44cccc2c80630bf10929e31f52e35435b042b47b Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 19 Jun 2025 17:09:40 +0200 Subject: [PATCH 07/64] #882 Use SYSTEM unquoted due to dialect 1 support --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 6 ++++-- src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java | 4 ++-- src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java | 6 ++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index e94008965..467553bb6 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -72,8 +72,8 @@ The following changes are made to Jaybird to support schemas when connecting to * Connection property `searchPath` (alias `search_path`, `isc_dpb_search_path`) to configure the default session search path. + On Firebird 5.0 and older, this will be silently ignored. -* In internal queries in Jaybird, and fully qualified object names, we'll always use the quoted identifier `"SYSTEM"`, because `SYSTEM` is SQL:2023 reserved word. -* `Connection.getSchema()` will return the result of `select CURRENT_SCHEMA from "SYSTEM".RDB$DATABASE`; +* In internal queries in Jaybird, and fully qualified object names, we'll use the regular -- unquoted -- identifier `SYSTEM`, even though `SYSTEM` is a SQL:2023 reserved word, to preserve dialect 1 compatibility. +* `Connection.getSchema()` will return the result of `select CURRENT_SCHEMA from SYSTEM.RDB$DATABASE`; the connection will not store this value * `Connection.setSchema(String)` will query the current search path, if not previously called, it will prepend the schema name to the search path, otherwise it will _replace_ the previously prepended schema name. The schema name is stored _only_ for this replacement operation (i.e. it will not be returned by `getSchema`!) @@ -96,6 +96,8 @@ Jaybird cannot honour this requirement for plain `Statement`, as schema resoluti *** Or just accept it as a breaking change? * TODO: Define effects for management API +Note to self: use `// TODO Add schema support` in places that you identify need to get/improve schema support, while working on schema support elsewhere + [appendix] == License Notice diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java index 22e5f8bff..d27deef10 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java @@ -223,7 +223,7 @@ private static final class FB6 extends GetProcedures { trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE - from "SYSTEM".RDB$PROCEDURES + from SYSTEM.RDB$PROCEDURES where RDB$PACKAGE_NAME is null"""; // NOTE: Including RDB$PACKAGE_NAME so index can be used to sort @@ -262,7 +262,7 @@ private static final class FB6CatalogAsPackage extends GetProcedures { trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE - from "SYSTEM".RDB$PROCEDURES"""; + from SYSTEM.RDB$PROCEDURES"""; private static final String GET_PROCEDURES_ORDER_BY_6_W_PKG = "\norder by RDB$PACKAGE_NAME nulls first, RDB$SCHEMA_NAME, RDB$PROCEDURE_NAME"; diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java index d878190e8..514a924d0 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java @@ -7,7 +7,6 @@ import org.firebirdsql.gds.ng.fields.RowValue; import org.firebirdsql.jdbc.DbMetadataMediator; import org.firebirdsql.jdbc.DbMetadataMediator.MetadataQuery; -import org.firebirdsql.util.FirebirdSupportInfo; import java.sql.ResultSet; import java.sql.SQLException; @@ -53,9 +52,8 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr @SuppressWarnings("unused") public static GetSchemas create(DbMetadataMediator mediator) { - FirebirdSupportInfo firebirdSupportInfo = mediator.getFirebirdSupportInfo(); // NOTE: Indirection through static method prevents unnecessary classloading - if (firebirdSupportInfo.isVersionEqualOrAbove(6)) { + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { return FB6.createInstance(mediator); } else { return FB5.createInstance(mediator); @@ -94,7 +92,7 @@ private static final class FB6 extends GetSchemas { private static final String GET_SCHEMAS_FRAGMENT_6 = """ select RDB$SCHEMA_NAME as TABLE_SCHEM - from "SYSTEM".RDB$SCHEMAS + from SYSTEM.RDB$SCHEMAS """; private static final String GET_SCHEMAS_ORDER_BY_6 = "\norder by RDB$SCHEMA_NAME"; From d34af6183c90a2b322eaed8135b15c1efa68ea1b Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sat, 21 Jun 2025 11:10:42 +0200 Subject: [PATCH 08/64] #882 Schema support for getBestRowIdentifier --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 9 +- .../org/firebirdsql/jdbc/FBRowUpdater.java | 5 +- .../jdbc/metadata/GetBestRowIdentifier.java | 170 +++++++++--- .../firebirdsql/common/FBTestProperties.java | 13 + ...DatabaseMetaDataBestRowIdentifierTest.java | 244 ++++++++++++++++++ .../jdbc/FBDatabaseMetaDataTest.java | 34 --- 6 files changed, 396 insertions(+), 79 deletions(-) create mode 100644 src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataBestRowIdentifierTest.java diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 0915d2b93..2af73d71c 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1372,14 +1372,19 @@ public ResultSet getTablePrivileges(String catalog, String schemaPattern, String /** * {@inheritDoc} *

- * Jaybird considers the primary key (scoped as {@code bestRowSession} as the best identifier for all scopes. - * Pseudo column {@code RDB$DB_KEY} (scoped as {@code bestRowTransaction} is considered the second-best alternative + * Jaybird considers the primary key (scoped as {@code bestRowSession}) as the best identifier for all scopes. + * Pseudo column {@code RDB$DB_KEY} (scoped as {@code bestRowTransaction}) is considered the second-best alternative * for scopes {@code bestRowTemporary} and {@code bestRowTransaction} if {@code table} has no primary key. *

*

* Jaybird currently considers {@code RDB$DB_KEY} to be {@link DatabaseMetaData#bestRowTransaction} even if the * dbkey_scope is set to 1 (session). This may change in the future. See also {@link #getRowIdLifetime()}. *

+ *

+ * On Firebird 6.0 and higher, passing {@code null} for {@code schema} will return columns of all + * tables with name {@code name}. It is recommended to always specify a non-{@code null} {@code schema} on + * Firebird 6.0 and higher. + *

*/ @Override public ResultSet getBestRowIdentifier(String catalog, String schema, String table, int scope, boolean nullable) diff --git a/src/main/org/firebirdsql/jdbc/FBRowUpdater.java b/src/main/org/firebirdsql/jdbc/FBRowUpdater.java index 37060e979..5a55715c8 100644 --- a/src/main/org/firebirdsql/jdbc/FBRowUpdater.java +++ b/src/main/org/firebirdsql/jdbc/FBRowUpdater.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: Copyright 2005-2011 Roman Rokytskyy -// SPDX-FileCopyrightText: Copyright 2012-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -250,8 +250,9 @@ private static List deriveKeyColumns(String tableName, RowDescr */ private static List keyColumnsOfBestRowIdentifier(String tableName, RowDescriptor rowDescriptor, DatabaseMetaData dbmd) throws SQLException { + // TODO Add schema support try (ResultSet bestRowIdentifier = dbmd - .getBestRowIdentifier("", "", tableName, DatabaseMetaData.bestRowTransaction, true)) { + .getBestRowIdentifier("", null, tableName, DatabaseMetaData.bestRowTransaction, true)) { int bestRowIdentifierColumnCount = 0; List keyColumns = new ArrayList<>(); while (bestRowIdentifier.next()) { diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java index d631431df..6960432cd 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -41,7 +41,7 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetBestRowIdentifier extends AbstractMetadataMethod { +public abstract class GetBestRowIdentifier extends AbstractMetadataMethod { private static final String ROWIDENTIFIER = "ROWIDENTIFIER"; @@ -56,31 +56,6 @@ public final class GetBestRowIdentifier extends AbstractMetadataMethod { .at(7).simple(SQL_SHORT, 0, "PSEUDO_COLUMN", ROWIDENTIFIER).addField() .toRowDescriptor(); - //@formatter:off - private static final String GET_BEST_ROW_IDENT_START = - "select\n" - + " RF.RDB$FIELD_NAME as COLUMN_NAME,\n" - + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" - + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" - + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" - + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" - + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" - + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" - + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + "\n" - + "from RDB$RELATION_CONSTRAINTS RC\n" - + "inner join RDB$INDEX_SEGMENTS IDX\n" - + " on IDX.RDB$INDEX_NAME = RC.RDB$INDEX_NAME\n" - + "inner join RDB$RELATION_FIELDS RF\n" - + " on RF.RDB$FIELD_NAME = IDX.RDB$FIELD_NAME and RF.RDB$RELATION_NAME = RC.RDB$RELATION_NAME\n" - + "inner join RDB$FIELDS F\n" - + " on F.RDB$FIELD_NAME = RF.RDB$FIELD_SOURCE\n" - + "where RC.RDB$CONSTRAINT_TYPE = 'PRIMARY KEY'\n" - + "and "; - - private static final String GET_BEST_ROW_IDENT_END = - "\norder by IDX.RDB$FIELD_POSITION"; - //@formatter:on - private GetBestRowIdentifier(DbMetadataMediator mediator) { super(ROW_DESCRIPTOR, mediator); } @@ -93,7 +68,7 @@ public ResultSet getBestRowIdentifier(String catalog, String schema, String tabl } RowValueBuilder valueBuilder = new RowValueBuilder(ROW_DESCRIPTOR); - List rows = getPrimaryKeyIdentifier(table, valueBuilder); + List rows = getPrimaryKeyIdentifier(schema, table, valueBuilder); // if no primary key exists, add RDB$DB_KEY as pseudo-column if (rows.isEmpty()) { @@ -105,8 +80,8 @@ public ResultSet getBestRowIdentifier(String catalog, String schema, String tabl return createEmpty(); } - try (ResultSet pseudoColumns = - dbmd.getPseudoColumns(catalog, schema, escapeWildcards(table), "RDB$DB\\_KEY")) { + try (ResultSet pseudoColumns = dbmd.getPseudoColumns( + catalog, escapeWildcards(schema), escapeWildcards(table), "RDB$DB\\_KEY")) { if (!pseudoColumns.next()) { return createEmpty(); } @@ -118,7 +93,7 @@ public ResultSet getBestRowIdentifier(String catalog, String schema, String tabl .at(1).setString("RDB$DB_KEY") .at(2).setInt(Types.ROWID) .at(3).setString(getDataTypeName(char_type, 0, CS_BINARY)) - .at(4).setInt(pseudoColumns.getInt(8)) + .at(4).setInt(pseudoColumns.getInt(6)) .at(5).set(null) .at(6).set(null) .at(7).setShort(DatabaseMetaData.bestRowPseudo) @@ -132,8 +107,10 @@ public ResultSet getBestRowIdentifier(String catalog, String schema, String tabl /** * Get primary key of the table as best row identifier. * + * @param schema + * name of the schema * @param table - * name of the table. + * name of the table * @param valueBuilder * builder for row values * @return list of result set values, when empty, no primary key has been defined for a table or the table does not @@ -141,13 +118,9 @@ public ResultSet getBestRowIdentifier(String catalog, String schema, String tabl * @throws SQLException * if something went wrong. */ - private List getPrimaryKeyIdentifier(String table, RowValueBuilder valueBuilder) throws SQLException { - Clause tableClause = Clause.equalsClause("RC.RDB$RELATION_NAME", table); - String sql = GET_BEST_ROW_IDENT_START - + tableClause.getCondition(false) - + GET_BEST_ROW_IDENT_END; - - MetadataQuery metadataQuery = new MetadataQuery(sql, Clause.parameters(tableClause)); + private List getPrimaryKeyIdentifier(String schema, String table, RowValueBuilder valueBuilder) + throws SQLException { + MetadataQuery metadataQuery = createGetPrimaryKeyIdentifierQuery(schema, table); try (ResultSet rs = mediator.performMetaDataQuery(metadataQuery)) { List rows = new ArrayList<>(); while (rs.next()) { @@ -157,6 +130,8 @@ private List getPrimaryKeyIdentifier(String table, RowValueBuilder val } } + abstract MetadataQuery createGetPrimaryKeyIdentifierQuery(String schema, String table); + @Override RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { TypeMetadata typeMetadata = TypeMetadata.builder(mediator.getFirebirdSupportInfo()) @@ -176,6 +151,119 @@ RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQ } public static GetBestRowIdentifier create(DbMetadataMediator mediator) { - return new GetBestRowIdentifier(mediator); + // NOTE: Indirection through static method prevents unnecessary classloading + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else { + return FB5.createInstance(mediator); + } } + + /** + * Implementation for Firebird 5.0 and older. + */ + private static final class FB5 extends GetBestRowIdentifier { + + //@formatter:off + private static final String GET_BEST_ROW_IDENT_START = """ + select + RF.RDB$FIELD_NAME as COLUMN_NAME, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + "\n" + """ + from RDB$RELATION_CONSTRAINTS RC + inner join RDB$INDEX_SEGMENTS IDX + on IDX.RDB$INDEX_NAME = RC.RDB$INDEX_NAME + inner join RDB$RELATION_FIELDS RF + on RF.RDB$FIELD_NAME = IDX.RDB$FIELD_NAME and RF.RDB$RELATION_NAME = RC.RDB$RELATION_NAME + inner join RDB$FIELDS F + on F.RDB$FIELD_NAME = RF.RDB$FIELD_SOURCE + where RC.RDB$CONSTRAINT_TYPE = 'PRIMARY KEY' + and\s"""; + //@formatter:on + + private static final String GET_BEST_ROW_IDENT_END = "\norder by IDX.RDB$FIELD_POSITION"; + + + private FB5(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetBestRowIdentifier createInstance(DbMetadataMediator mediator) { + return new FB5(mediator); + } + + @Override + MetadataQuery createGetPrimaryKeyIdentifierQuery(String schema, String table) { + Clause tableClause = Clause.equalsClause("RC.RDB$RELATION_NAME", table); + String sql = GET_BEST_ROW_IDENT_START + + tableClause.getCondition(false) + + GET_BEST_ROW_IDENT_END; + + return new MetadataQuery(sql, Clause.parameters(tableClause)); + } + } + + /** + * Implementation for Firebird 6.0 and higher. + */ + private static final class FB6 extends GetBestRowIdentifier { + + //@formatter:off + private static final String GET_BEST_ROW_IDENT_START_6 = """ + select + RF.RDB$FIELD_NAME as COLUMN_NAME, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + "\n" + """ + from SYSTEM.RDB$RELATION_CONSTRAINTS RC + inner join SYSTEM.RDB$INDEX_SEGMENTS IDX + on IDX.RDB$SCHEMA_NAME = RC.RDB$SCHEMA_NAME and IDX.RDB$INDEX_NAME = RC.RDB$INDEX_NAME + inner join SYSTEM.RDB$RELATION_FIELDS RF + on RF.RDB$FIELD_NAME = IDX.RDB$FIELD_NAME and RF.RDB$SCHEMA_NAME = RC.RDB$SCHEMA_NAME and RF.RDB$RELATION_NAME = RC.RDB$RELATION_NAME + inner join SYSTEM.RDB$FIELDS F + on F.RDB$SCHEMA_NAME = RF.RDB$FIELD_SOURCE_SCHEMA_NAME and F.RDB$FIELD_NAME = RF.RDB$FIELD_SOURCE + where RC.RDB$CONSTRAINT_TYPE = 'PRIMARY KEY' + and\s"""; + //@formatter:on + + // The order by schema name is to ensure a consistent order when this is called with schema = null, as that will + // not narrow the search by schema, so can return columns of multiple same named tables in different schemas. + private static final String GET_BEST_ROW_IDENT_END_6 = "\norder by RC.RDB$SCHEMA_NAME, IDX.RDB$FIELD_POSITION"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetBestRowIdentifier createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetPrimaryKeyIdentifierQuery(String schema, String table) { + List clauses = new ArrayList<>(2); + if (schema != null) { + // NOTE: empty string will return no rows as required ("" retrieves those without a schema) + clauses.add(Clause.equalsClause("RC.RDB$SCHEMA_NAME", schema)); + } + clauses.add(Clause.equalsClause("RC.RDB$RELATION_NAME", table)); + String sql = GET_BEST_ROW_IDENT_START_6 + + Clause.conjunction(clauses) + + GET_BEST_ROW_IDENT_END_6; + + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + + } + } diff --git a/src/test/org/firebirdsql/common/FBTestProperties.java b/src/test/org/firebirdsql/common/FBTestProperties.java index f6d8077b3..d211e1d86 100644 --- a/src/test/org/firebirdsql/common/FBTestProperties.java +++ b/src/test/org/firebirdsql/common/FBTestProperties.java @@ -366,6 +366,19 @@ public static void defaultDatabaseTearDown(FBManager fbManager) throws Exception } } + /** + * If schema support is available, returns {@code forSchema}, otherwise returns {@code withoutSchema}. + * + * @param forSchema + * value to return when schema support is available + * @param withoutSchema + * value to return when schema support is not available + * @return {@code forSchema} if schema support is available, otherwise {@code withoutSchema} + */ + public static T ifSchemaElse(T forSchema, T withoutSchema) { + return getDefaultSupportInfo().supportsSchemas() ? forSchema : withoutSchema; + } + private FBTestProperties() { // No instantiation } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataBestRowIdentifierTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataBestRowIdentifierTest.java new file mode 100644 index 000000000..f55ce737c --- /dev/null +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataBestRowIdentifierTest.java @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jdbc; + +import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; +import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNoNextRow; + +class FBDatabaseMetaDataBestRowIdentifierTest { + + @RegisterExtension + static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( + getInitStatements()); + + private static final String TABLE_BEST_ROW_PK = """ + create table BEST_ROW_PK ( + C1 integer constraint PK_BEST_ROW_PK primary key + )"""; + + private static final String TABLE_BEST_ROW_NO_PK = """ + create table BEST_ROW_NO_PK ( + C1 integer not null + )"""; + + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + + private static final String TABLE_BEST_ROW_PK_OTHER_SCHEMA = """ + create table OTHER_SCHEMA.BEST_ROW_PK ( + ID1 integer not null, + ID2 bigint not null, + constraint PK_BEST_ROW_PK primary key (ID1, ID2) + )"""; + + private static final MetadataResultSetDefinition getBestRowIdentifierDefinition = + new MetadataResultSetDefinition(BestRowIdentifierMetaData.class); + + private static Connection con; + private static DatabaseMetaData dbmd; + + @BeforeAll + static void setupAll() throws SQLException { + con = getConnectionViaDriverManager(); + dbmd = con.getMetaData(); + } + + @AfterAll + static void tearDownAll() throws SQLException { + try { + con.close(); + } finally { + con = null; + dbmd = null; + } + } + + private static List getInitStatements() { + var statements = new ArrayList<>( + List.of(TABLE_BEST_ROW_PK, + TABLE_BEST_ROW_NO_PK)); + if (getDefaultSupportInfo().supportsSchemas()) { + statements.add(CREATE_OTHER_SCHEMA); + statements.add(TABLE_BEST_ROW_PK_OTHER_SCHEMA); + } + return statements; + } + + /** + * Tests the ordinal positions and types for the metadata columns of getBestRowIdentifier(...). + */ + @Test + void testSchemaMetaDataColumns() throws Exception { + try (ResultSet columns = dbmd.getBestRowIdentifier("", "", "", DatabaseMetaData.bestRowTransaction, true)) { + getBestRowIdentifierDefinition.validateResultSetColumns(columns); + } + } + + @Test + void testGetBestRowIdentifier() throws Exception { + for (int scope : new int[] { DatabaseMetaData.bestRowTemporary, DatabaseMetaData.bestRowTransaction, + DatabaseMetaData.bestRowSession }) { + try (ResultSet rs = dbmd.getBestRowIdentifier("", ifSchemaElse("PUBLIC", ""), "BEST_ROW_PK", scope, true)) { + validate(rs, rules_BEST_ROW_PK()); + } + } + + for (int scope : new int[] { DatabaseMetaData.bestRowTemporary, DatabaseMetaData.bestRowTransaction }) { + try (ResultSet rs = dbmd.getBestRowIdentifier( + "", ifSchemaElse("PUBLIC", ""), "BEST_ROW_NO_PK", scope, true)) { + validate(rs, rules_BEST_ROW_NO_PK()); + } + } + + try (ResultSet rs = dbmd.getBestRowIdentifier( + "", ifSchemaElse("PUBLIC", ""), "BEST_ROW_NO_PK", DatabaseMetaData.bestRowSession, true)) { + validate(rs, List.of()); + } + } + + @Test + void testGetBestRowIdentifier_otherSchema() throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + for (int scope : new int[] { DatabaseMetaData.bestRowTemporary, DatabaseMetaData.bestRowTransaction, + DatabaseMetaData.bestRowSession }) { + try (ResultSet rs = dbmd.getBestRowIdentifier("", "OTHER_SCHEMA", "BEST_ROW_PK", scope, true)) { + validate(rs, rules_BEST_ROW_PK_OTHER_SCHEMA()); + } + } + } + + @Test + void testGetBestRowIdentifier_allSchemas() throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + /* JDBC specifies that "null means that the schema name should not be used to narrow the search". This seems + like useless behaviour to me for this method (you don't know to which table the columns are actually referring), + but let's verify it (our implementation returns the columns of all tables with the same name, ordered by schema + and field position). */ + for (int scope : new int[] { DatabaseMetaData.bestRowTemporary, DatabaseMetaData.bestRowTransaction, + DatabaseMetaData.bestRowSession }) { + try (ResultSet rs = dbmd.getBestRowIdentifier("", null, "BEST_ROW_PK", scope, true)) { + var combinedRules = new ArrayList>(); + combinedRules.addAll(rules_BEST_ROW_PK_OTHER_SCHEMA()); + combinedRules.addAll(rules_BEST_ROW_PK()); + validate(rs, combinedRules); + } + } + } + + private static void validate(ResultSet rs, List> rules) throws SQLException { + for (Map rowRule : rules) { + assertNextRow(rs); + getBestRowIdentifierDefinition.validateRowValues(rs, rowRule); + } + assertNoNextRow(rs); + } + + private static List> rules_BEST_ROW_PK() { + Map rules = getDefaultValueValidationRules(); + rules.put(BestRowIdentifierMetaData.SCOPE, DatabaseMetaData.bestRowSession); + rules.put(BestRowIdentifierMetaData.COLUMN_NAME, "C1"); + rules.put(BestRowIdentifierMetaData.DATA_TYPE, Types.INTEGER); + rules.put(BestRowIdentifierMetaData.TYPE_NAME, "INTEGER"); + rules.put(BestRowIdentifierMetaData.COLUMN_SIZE, 10); + rules.put(BestRowIdentifierMetaData.DECIMAL_DIGITS, 0); + rules.put(BestRowIdentifierMetaData.PSEUDO_COLUMN, DatabaseMetaData.bestRowNotPseudo); + return List.of(rules); + } + + private static List> rules_BEST_ROW_NO_PK() { + Map rules = getDefaultValueValidationRules(); + rules.put(BestRowIdentifierMetaData.SCOPE, DatabaseMetaData.bestRowTransaction); + rules.put(BestRowIdentifierMetaData.COLUMN_NAME, "RDB$DB_KEY"); + rules.put(BestRowIdentifierMetaData.DATA_TYPE, Types.ROWID); + rules.put(BestRowIdentifierMetaData.TYPE_NAME, "CHAR"); + rules.put(BestRowIdentifierMetaData.COLUMN_SIZE, 8); + rules.put(BestRowIdentifierMetaData.DECIMAL_DIGITS, null); + rules.put(BestRowIdentifierMetaData.PSEUDO_COLUMN, DatabaseMetaData.bestRowPseudo); + return List.of(rules); + } + + private static List> rules_BEST_ROW_PK_OTHER_SCHEMA() { + Map rulesRow1 = getDefaultValueValidationRules(); + rulesRow1.put(BestRowIdentifierMetaData.SCOPE, DatabaseMetaData.bestRowSession); + rulesRow1.put(BestRowIdentifierMetaData.COLUMN_NAME, "ID1"); + rulesRow1.put(BestRowIdentifierMetaData.DATA_TYPE, Types.INTEGER); + rulesRow1.put(BestRowIdentifierMetaData.TYPE_NAME, "INTEGER"); + rulesRow1.put(BestRowIdentifierMetaData.COLUMN_SIZE, 10); + rulesRow1.put(BestRowIdentifierMetaData.DECIMAL_DIGITS, 0); + rulesRow1.put(BestRowIdentifierMetaData.PSEUDO_COLUMN, DatabaseMetaData.bestRowNotPseudo); + + Map rulesRow2 = getDefaultValueValidationRules(); + rulesRow2.put(BestRowIdentifierMetaData.SCOPE, DatabaseMetaData.bestRowSession); + rulesRow2.put(BestRowIdentifierMetaData.COLUMN_NAME, "ID2"); + rulesRow2.put(BestRowIdentifierMetaData.DATA_TYPE, Types.BIGINT); + rulesRow2.put(BestRowIdentifierMetaData.TYPE_NAME, "BIGINT"); + rulesRow2.put(BestRowIdentifierMetaData.COLUMN_SIZE, 19); + rulesRow2.put(BestRowIdentifierMetaData.DECIMAL_DIGITS, 0); + rulesRow2.put(BestRowIdentifierMetaData.PSEUDO_COLUMN, DatabaseMetaData.bestRowNotPseudo); + return List.of(rulesRow1, rulesRow2); + } + + private static final Map DEFAULT_COLUMN_VALUES; + static { + var defaults = new EnumMap<>(BestRowIdentifierMetaData.class); + defaults.put(BestRowIdentifierMetaData.BUFFER_LENGTH, null); + + DEFAULT_COLUMN_VALUES = Collections.unmodifiableMap(defaults); + } + + private static Map getDefaultValueValidationRules() { + return new EnumMap<>(DEFAULT_COLUMN_VALUES); + } + + private enum BestRowIdentifierMetaData implements MetaDataInfo { + SCOPE(1, Short.class), + COLUMN_NAME(2, String.class), + DATA_TYPE(3, Integer.class), + TYPE_NAME(4, String.class), + COLUMN_SIZE(5, Integer.class), + BUFFER_LENGTH(6, Integer.class), + DECIMAL_DIGITS(7, Short.class), + PSEUDO_COLUMN(8, Short.class), + ; + + private final int position; + private final Class columnClass; + + BestRowIdentifierMetaData(int position, Class columnClass) { + this.position = position; + this.columnClass = columnClass; + } + + @Override + public int getPosition() { + return position; + } + + @Override + public Class getColumnClass() { + return columnClass; + } + } + +} diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java index 278080ab1..cfda01771 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java @@ -612,40 +612,6 @@ private void createProcedure(String procedureName, boolean returnsData) throws E } } - @Test - void testGetBestRowIdentifier() throws Exception { - createTable("best_row_pk"); - createTable("best_row_no_pk", null); - - for (int scope : new int[] { DatabaseMetaData.bestRowTemporary, DatabaseMetaData.bestRowTransaction, - DatabaseMetaData.bestRowTransaction }) { - try (ResultSet rs = dmd.getBestRowIdentifier("", "", "BEST_ROW_PK", scope, true)) { - assertTrue(rs.next(), "Should have rows"); - assertEquals("C1", rs.getString(2), "Column name should be C1"); - assertEquals("INTEGER", rs.getString(4), "Column type should be INTEGER"); - assertEquals(DatabaseMetaData.bestRowSession, rs.getInt(1), "Scope should be bestRowSession"); - assertEquals(DatabaseMetaData.bestRowNotPseudo, rs.getInt(8), - "Pseudo column should be bestRowNotPseudo"); - assertFalse(rs.next(), "Should have only one row"); - } - } - - for (int scope : new int[] { DatabaseMetaData.bestRowTemporary, DatabaseMetaData.bestRowTransaction }) { - try (ResultSet rs = dmd.getBestRowIdentifier("", "", "BEST_ROW_NO_PK", scope, true)) { - assertTrue(rs.next(), "Should have rows"); - assertEquals("RDB$DB_KEY", rs.getString(2), "Column name should be RDB$DB_KEY"); - assertEquals(DatabaseMetaData.bestRowTransaction, rs.getInt(1), "Scope should be bestRowTransaction"); - assertEquals(DatabaseMetaData.bestRowPseudo, rs.getInt(8), - "Pseudo column should be bestRowPseudo"); - assertFalse(rs.next(), "Should have only one row"); - } - } - - try (ResultSet rs = dmd.getBestRowIdentifier("", "", "BEST_ROW_NO_PK", DatabaseMetaData.bestRowSession, true)) { - assertFalse(rs.next(), "Should have no rows"); - } - } - @Test void testGetVersionColumns() throws Exception { ResultSet rs = dmd.getVersionColumns(null, null, null); From 05edd2d4cd48aaf9c48ddf19df8c523af2ba86d3 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sat, 21 Jun 2025 13:33:00 +0200 Subject: [PATCH 09/64] #882 Schema support for getColumnPrivileges --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 10 +- .../jdbc/metadata/GetBestRowIdentifier.java | 6 +- .../jdbc/metadata/GetColumnPrivileges.java | 164 +++++++++++++----- .../jdbc/metadata/GetProcedures.java | 4 +- ...BDatabaseMetaDataColumnPrivilegesTest.java | 31 +++- 5 files changed, 157 insertions(+), 58 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 2af73d71c..fcc6762e4 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1337,14 +1337,20 @@ public ResultSet getColumns(String catalog, String schemaPattern, String tableNa *

*

* NOTE: This implementation returns all privileges, not just applicable to the current user. It is - * unclear if this complies with the JDBC requirements. This may change in the future to only return only privileges + * unclear if this complies with the JDBC requirements. This may change in the future to only return privileges * applicable to the current user, user {@code PUBLIC} and — maybe — active roles. *

+ *

+ * Contrary to specified in the JDBC API, the result set is ordered by {@code TABLE_SCHEM}, {@code COLUMN_NAME}, + * {@code PRIVILEGE}, and {@code GRANTEE} (JDBC specifies ordering by {@code COLUMN_NAME} and {@code PRIVILEGE}). + * This only makes a difference when specifying {@code null} for {@code schema} (search all schemas) and there are + * multiples tables with the same {@code name}. + *

*/ @Override public ResultSet getColumnPrivileges(String catalog, String schema, String table, String columnNamePattern) throws SQLException { - return GetColumnPrivileges.create(getDbMetadataMediator()).getColumnPrivileges(table, columnNamePattern); + return GetColumnPrivileges.create(getDbMetadataMediator()).getColumnPrivileges(schema, table, columnNamePattern); } /** diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java index 6960432cd..5ff6c7670 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java @@ -22,6 +22,7 @@ import static org.firebirdsql.gds.ISCConstants.SQL_LONG; import static org.firebirdsql.gds.ISCConstants.SQL_SHORT; import static org.firebirdsql.gds.ISCConstants.SQL_VARYING; +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.OBJECT_NAME_LENGTH; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.char_type; import static org.firebirdsql.jdbc.metadata.MetadataPattern.escapeWildcards; @@ -63,7 +64,7 @@ private GetBestRowIdentifier(DbMetadataMediator mediator) { @SuppressWarnings("unused") public ResultSet getBestRowIdentifier(String catalog, String schema, String table, int scope, boolean nullable) throws SQLException { - if (table == null || table.isEmpty()) { + if (isNullOrEmpty(table)) { return createEmpty(); } @@ -204,7 +205,6 @@ MetadataQuery createGetPrimaryKeyIdentifierQuery(String schema, String table) { String sql = GET_BEST_ROW_IDENT_START + tableClause.getCondition(false) + GET_BEST_ROW_IDENT_END; - return new MetadataQuery(sql, Clause.parameters(tableClause)); } } @@ -217,7 +217,7 @@ private static final class FB6 extends GetBestRowIdentifier { //@formatter:off private static final String GET_BEST_ROW_IDENT_START_6 = """ select - RF.RDB$FIELD_NAME as COLUMN_NAME, + trim(trailing from RF.RDB$FIELD_NAME) as COLUMN_NAME, """ + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java b/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java index f57bac6fd..0416d1ba4 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -10,8 +10,11 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; import static org.firebirdsql.gds.ISCConstants.SQL_VARYING; +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.OBJECT_NAME_LENGTH; import static org.firebirdsql.jdbc.metadata.PrivilegeMapping.mapPrivilege; @@ -27,7 +30,7 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetColumnPrivileges extends AbstractMetadataMethod { +public abstract class GetColumnPrivileges extends AbstractMetadataMethod { private static final String COLUMNPRIV = "COLUMNPRIV"; private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(9) @@ -42,33 +45,6 @@ public final class GetColumnPrivileges extends AbstractMetadataMethod { .at(8).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "JB_GRANTEE_TYPE", COLUMNPRIV).addField() .toRowDescriptor(); - //@formatter:off - private static final String GET_COLUMN_PRIVILEGES_START = - "select distinct \n" - + " RF.RDB$RELATION_NAME as TABLE_NAME, \n" - + " RF.RDB$FIELD_NAME as COLUMN_NAME, \n" - + " UP.RDB$GRANTOR as GRANTOR, \n" - + " UP.RDB$USER as GRANTEE, \n" - + " UP.RDB$PRIVILEGE as PRIVILEGE, \n" - + " UP.RDB$GRANT_OPTION as IS_GRANTABLE,\n" - + " T.RDB$TYPE_NAME as JB_GRANTEE_TYPE\n" - + "from RDB$RELATION_FIELDS RF\n" - + "inner join RDB$USER_PRIVILEGES UP\n" - + " on UP.RDB$RELATION_NAME = RF.RDB$RELATION_NAME \n" - + " and (UP.RDB$FIELD_NAME is null or UP.RDB$FIELD_NAME = RF.RDB$FIELD_NAME) \n" - + "left join RDB$TYPES T\n" - + " on T.RDB$FIELD_NAME = 'RDB$OBJECT_TYPE' and T.RDB$TYPE = UP.RDB$USER_TYPE \n" - // Other privileges don't make sense for column privileges - + "where UP.RDB$PRIVILEGE in ('A', 'D', 'I', 'R', 'S', 'U')\n" - // Only tables and views - + "and UP.RDB$OBJECT_TYPE in (0, 1)\n" - + "and "; - - // NOTE: Sort by user is not defined in JDBC, but we do this to ensure a consistent order for tests - private static final String GET_COLUMN_PRIVILEGES_END = - "\norder by RF.RDB$FIELD_NAME, UP.RDB$PRIVILEGE, UP.RDB$USER"; - //@formatter:on - GetColumnPrivileges(DbMetadataMediator mediator) { super(ROW_DESCRIPTOR, mediator); } @@ -76,26 +52,21 @@ public final class GetColumnPrivileges extends AbstractMetadataMethod { /** * @see java.sql.DatabaseMetaData#getColumnPrivileges(String, String, String, String) */ - public ResultSet getColumnPrivileges(String table, String columnNamePattern) throws SQLException { - if (table == null || "".equals(columnNamePattern)) { + public ResultSet getColumnPrivileges(String schema, String table, String columnNamePattern) throws SQLException { + if (isNullOrEmpty(table) || "".equals(columnNamePattern)) { return createEmpty(); } - Clause tableClause = Clause.equalsClause("RF.RDB$RELATION_NAME", table); - Clause columnNameClause = new Clause("RF.RDB$FIELD_NAME", columnNamePattern); - - String sql = GET_COLUMN_PRIVILEGES_START - + tableClause.getCondition(columnNameClause.hasCondition()) - + columnNameClause.getCondition(false) - + GET_COLUMN_PRIVILEGES_END; - MetadataQuery metadataQuery = new MetadataQuery(sql, Clause.parameters(tableClause, columnNameClause)); + MetadataQuery metadataQuery = createMetadataQuery(schema, table, columnNamePattern); return createMetaDataResultSet(metadataQuery); } + abstract MetadataQuery createMetadataQuery(String schema, String table, String columnNamePattern); + @Override RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { return valueBuilder .at(0).set(null) - .at(1).set(null) + .at(1).setString(rs.getString("TABLE_SCHEM")) .at(2).setString(rs.getString("TABLE_NAME")) .at(3).setString(rs.getString("COLUMN_NAME")) .at(4).setString(rs.getString("GRANTOR")) @@ -107,6 +78,115 @@ RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQ } public static GetColumnPrivileges create(DbMetadataMediator mediator) { - return new GetColumnPrivileges(mediator); + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else { + return FB5.createInstance(mediator); + } + } + + /** + * Implementation for Firebird 5.0 and older. + */ + private static final class FB5 extends GetColumnPrivileges { + + private static final String GET_COLUMN_PRIVILEGES_START_5 = """ + select distinct + cast(null as char(1)) as TABLE_SCHEM, + RF.RDB$RELATION_NAME as TABLE_NAME, + RF.RDB$FIELD_NAME as COLUMN_NAME, + UP.RDB$GRANTOR as GRANTOR, + UP.RDB$USER as GRANTEE, + UP.RDB$PRIVILEGE as PRIVILEGE, + UP.RDB$GRANT_OPTION as IS_GRANTABLE, + T.RDB$TYPE_NAME as JB_GRANTEE_TYPE + from RDB$RELATION_FIELDS RF + inner join RDB$USER_PRIVILEGES UP + on UP.RDB$RELATION_NAME = RF.RDB$RELATION_NAME + and (UP.RDB$FIELD_NAME is null or UP.RDB$FIELD_NAME = RF.RDB$FIELD_NAME) + left join RDB$TYPES T + on T.RDB$FIELD_NAME = 'RDB$OBJECT_TYPE' and T.RDB$TYPE = UP.RDB$USER_TYPE + where UP.RDB$PRIVILEGE in ('A', 'D', 'I', 'R', 'S', 'U') -- privileges relevant for columns + and UP.RDB$OBJECT_TYPE in (0, 1) -- only tables and views + and\s"""; + + // NOTE: Sort by user is not defined in JDBC, but we do this to ensure a consistent order for tests + private static final String GET_COLUMN_PRIVILEGES_END_5 = + "\norder by RF.RDB$FIELD_NAME, UP.RDB$PRIVILEGE, UP.RDB$USER"; + + private FB5(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetColumnPrivileges createInstance(DbMetadataMediator mediator) { + return new FB5(mediator); + } + + @Override + MetadataQuery createMetadataQuery(String schema, String table, String columnNamePattern) { + var clauses = List.of( + Clause.equalsClause("RF.RDB$RELATION_NAME", table), + new Clause("RF.RDB$FIELD_NAME", columnNamePattern)); + String sql = GET_COLUMN_PRIVILEGES_START_5 + + Clause.conjunction(clauses) + + GET_COLUMN_PRIVILEGES_END_5; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + } + + /** + * Implementation for Firebird 6.0 and higher. + */ + private static final class FB6 extends GetColumnPrivileges { + + private static final String GET_COLUMN_PRIVILEGES_START_6 = """ + select distinct + trim(trailing from RF.RDB$SCHEMA_NAME) as TABLE_SCHEM, + trim(trailing from RF.RDB$RELATION_NAME) as TABLE_NAME, + trim(trailing from RF.RDB$FIELD_NAME) as COLUMN_NAME, + trim(trailing from UP.RDB$GRANTOR) as GRANTOR, + trim(trailing from UP.RDB$USER) as GRANTEE, + UP.RDB$PRIVILEGE as PRIVILEGE, + UP.RDB$GRANT_OPTION as IS_GRANTABLE, + T.RDB$TYPE_NAME as JB_GRANTEE_TYPE + from SYSTEM.RDB$RELATION_FIELDS RF + inner join SYSTEM.RDB$USER_PRIVILEGES UP + on UP.RDB$RELATION_SCHEMA_NAME = RF.RDB$SCHEMA_NAME and UP.RDB$RELATION_NAME = RF.RDB$RELATION_NAME + and (UP.RDB$FIELD_NAME is null or UP.RDB$FIELD_NAME = RF.RDB$FIELD_NAME) + left join SYSTEM.RDB$TYPES T + on T.RDB$FIELD_NAME = 'RDB$OBJECT_TYPE' and T.RDB$TYPE = UP.RDB$USER_TYPE + where UP.RDB$PRIVILEGE in ('A', 'D', 'I', 'R', 'S', 'U') -- privileges relevant for columns + and UP.RDB$OBJECT_TYPE in (0, 1) -- only tables and views + and\s"""; + + // NOTE: Sort by user and schema is not defined in JDBC, but we do this to ensure a consistent order for tests + private static final String GET_COLUMN_PRIVILEGES_END_6 = + "\norder by RF.RDB$FIELD_NAME, UP.RDB$PRIVILEGE, UP.RDB$USER, RF.RDB$SCHEMA_NAME"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetColumnPrivileges createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createMetadataQuery(String schema, String table, String columnNamePattern) { + var clauses = new ArrayList(3); + if (schema != null) { + // NOTE: empty string will return no rows as required ("" retrieves those without a schema) + clauses.add(Clause.equalsClause("RF.RDB$SCHEMA_NAME", schema)); + } + clauses.add(Clause.equalsClause("RF.RDB$RELATION_NAME", table)); + clauses.add(new Clause("RF.RDB$FIELD_NAME", columnNamePattern)); + String sql = GET_COLUMN_PRIVILEGES_START_6 + + Clause.conjunction(clauses) + + GET_COLUMN_PRIVILEGES_END_6; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + + } + } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java index d27deef10..8f87e009d 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java @@ -107,8 +107,8 @@ private static final class FB2_5 extends GetProcedures { private static final String GET_PROCEDURES_FRAGMENT_2_5 = """ select - null as PROCEDURE_CAT, - null as PROCEDURE_SCHEM, + cast(null as char(1)) as PROCEDURE_CAT, + cast(null as char(1)) as PROCEDURE_SCHEM, RDB$PROCEDURE_NAME as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java index 616f6a9f0..9fba1fa3b 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2022-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -24,6 +24,7 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -35,6 +36,8 @@ */ class FBDatabaseMetaDataColumnPrivilegesTest { + // TODO Add schema support: tests involving other schema + private static final String SYSDBA = "SYSDBA"; private static final String USER1 = "USER1"; private static final String user2 = getDefaultSupportInfo().supportsCaseSensitiveUserNames() ? "user2" : "USER2"; @@ -125,7 +128,9 @@ void testColumnPrivileges_TBL1_all(String allPattern) throws Exception { createRule("TBL1", "val3", USER1, false, "UPDATE"), createRule("TBL1", "val3", user2, false, "UPDATE")); - validateExpectedColumnPrivileges("TBL1", allPattern, rules); + validateExpectedColumnPrivileges(ifSchemaElse("PUBLIC", ""), "TBL1", allPattern, rules); + // schema = null should also find it: + validateExpectedColumnPrivileges(null, "TBL1", allPattern, rules); } @Test @@ -155,7 +160,9 @@ void testColumnPrivileges_TBL1_COL_wildcard() throws Exception { createRule("TBL1", "COL2", SYSDBA, true, "UPDATE"), createRule("TBL1", "COL2", USER1, false, "UPDATE")); - validateExpectedColumnPrivileges("TBL1", "COL%", rules); + validateExpectedColumnPrivileges(ifSchemaElse("PUBLIC", ""), "TBL1", "COL%", rules); + // schema = null should also find it: + validateExpectedColumnPrivileges(null, "TBL1", "COL%", rules); } @Test @@ -181,13 +188,19 @@ void testColumnPrivileges_tbl2_all() throws Exception { createRule("tbl2", "val3", user2, true, "SELECT"), createRule("tbl2", "val3", SYSDBA, true, "UPDATE")); - validateExpectedColumnPrivileges("tbl2", "%", rules); + validateExpectedColumnPrivileges(ifSchemaElse("PUBLIC", ""), "tbl2", "%", rules); } - private Map createRule(String tableName, String columnName, String grantee, + private Map createRule(String table, String columnName, String grantee, boolean grantable, String privilege) { + return createRule(ifSchemaElse("PUBLIC", null), table, columnName, grantee, grantable, privilege); + } + + private Map createRule(String schema, String table, String columnName, + String grantee, boolean grantable, String privilege) { Map rules = getDefaultValueValidationRules(); - rules.put(ColumnPrivilegesMetadata.TABLE_NAME, tableName); + rules.put(ColumnPrivilegesMetadata.TABLE_SCHEM, schema); + rules.put(ColumnPrivilegesMetadata.TABLE_NAME, table); rules.put(ColumnPrivilegesMetadata.COLUMN_NAME, columnName); rules.put(ColumnPrivilegesMetadata.GRANTEE, grantee); rules.put(ColumnPrivilegesMetadata.PRIVILEGE, privilege); @@ -195,9 +208,9 @@ private Map createRule(String tableName, Strin return rules; } - private void validateExpectedColumnPrivileges(String tableName, String columnNamePattern, + private void validateExpectedColumnPrivileges(String schema, String table, String columnNamePattern, List> expectedColumnPrivileges) throws SQLException { - try (ResultSet columnPrivileges = dbmd.getColumnPrivileges(null, null, tableName, columnNamePattern)) { + try (ResultSet columnPrivileges = dbmd.getColumnPrivileges(null, schema, table, columnNamePattern)) { int privilegeCount = 0; while (columnPrivileges.next()) { if (privilegeCount < expectedColumnPrivileges.size()) { @@ -215,7 +228,7 @@ private void validateExpectedColumnPrivileges(String tableName, String columnNam static { Map defaults = new EnumMap<>(ColumnPrivilegesMetadata.class); defaults.put(ColumnPrivilegesMetadata.TABLE_CAT, null); - defaults.put(ColumnPrivilegesMetadata.TABLE_SCHEM, null); + defaults.put(ColumnPrivilegesMetadata.TABLE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(ColumnPrivilegesMetadata.GRANTOR, SYSDBA); defaults.put(ColumnPrivilegesMetadata.JB_GRANTEE_TYPE, "USER"); From 76992340e53b9f2ced707a7133c0b25ca20ac5e3 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sat, 21 Jun 2025 14:15:35 +0200 Subject: [PATCH 10/64] #882 Schema support for getColumns --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 3 +- .../firebirdsql/jdbc/metadata/GetColumns.java | 183 ++++++++++++------ .../jdbc/FBDatabaseMetaDataColumnsTest.java | 95 +++++---- .../FBDatabaseMetaDataProceduresTest.java | 2 +- 4 files changed, 175 insertions(+), 108 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index fcc6762e4..72e8edb2c 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1319,7 +1319,8 @@ public String[] getTableTypeNames() throws SQLException { @Override public ResultSet getColumns(String catalog, String schemaPattern, String tableNamePattern, String columnNamePattern) throws SQLException { - return GetColumns.create(getDbMetadataMediator()).getColumns(tableNamePattern, columnNamePattern); + return GetColumns.create(getDbMetadataMediator()) + .getColumns(schemaPattern, tableNamePattern, columnNamePattern); } /** diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetColumns.java index 39ee3d221..4cb1c46f4 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetColumns.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -12,6 +12,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; +import java.util.List; import java.util.Objects; import static java.sql.DatabaseMetaData.columnNoNulls; @@ -78,13 +79,13 @@ private GetColumns(DbMetadataMediator mediator) { * @see java.sql.DatabaseMetaData#getColumns(String, String, String, String) * @see org.firebirdsql.jdbc.FBDatabaseMetaData#getColumns(String, String, String, String) */ - public final ResultSet getColumns(String tableNamePattern, String columnNamePattern) throws SQLException { + public final ResultSet getColumns(String schemaPattern, String tableNamePattern, String columnNamePattern) throws SQLException { if ("".equals(tableNamePattern) || "".equals(columnNamePattern)) { // Matching table name or column not possible return createEmpty(); } - MetadataQuery metadataQuery = createGetColumnsQuery(tableNamePattern, columnNamePattern); + MetadataQuery metadataQuery = createGetColumnsQuery(schemaPattern, tableNamePattern, columnNamePattern); return createMetaDataResultSet(metadataQuery); } @@ -98,6 +99,7 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr boolean isComputed = rs.getBoolean("IS_COMPUTED"); boolean isIdentity = rs.getBoolean("IS_IDENTITY"); return valueBuilder + .at(1).setString(rs.getString("SCHEMA_NAME")) .at(2).setString(rs.getString("RELATION_NAME")) .at(3).setString(rs.getString("FIELD_NAME")) .at(4).setInt(typeMetadata.getJdbcType()) @@ -138,12 +140,15 @@ private String getIsAutoIncrementValue(boolean isIdentity, TypeMetadata typeMeta }; } - abstract MetadataQuery createGetColumnsQuery(String tableNamePattern, String columnNamePattern); + abstract MetadataQuery createGetColumnsQuery(String schemaPattern, String tableNamePattern, + String columnNamePattern); public static GetColumns create(DbMetadataMediator mediator) { FirebirdSupportInfo firebirdSupportInfo = mediator.getFirebirdSupportInfo(); // NOTE: Indirection through static method prevents unnecessary classloading - if (firebirdSupportInfo.isVersionEqualOrAbove(3, 0)) { + if (firebirdSupportInfo.isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { return FB3.createInstance(mediator); } else { return FB2_5.createInstance(mediator); @@ -153,29 +158,29 @@ public static GetColumns create(DbMetadataMediator mediator) { @SuppressWarnings("java:S101") private static class FB2_5 extends GetColumns { - //@formatter:off - private static final String GET_COLUMNS_FRAGMENT_2_5 = - "select\n" - + " RF.RDB$RELATION_NAME as RELATION_NAME,\n" - + " RF.RDB$FIELD_NAME as FIELD_NAME,\n" - + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" - + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" - + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" - + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" - + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" - + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" - + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" - + " RF.RDB$DESCRIPTION as REMARKS,\n" - + " coalesce(RF.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as DEFAULT_SOURCE,\n" - + " RF.RDB$FIELD_POSITION + 1 as FIELD_POSITION,\n" - + " iif(coalesce(RF.RDB$NULL_FLAG, 0) + coalesce(F.RDB$NULL_FLAG, 0) = 0, 'T', 'F') as IS_NULLABLE,\n" - + " iif(F.RDB$COMPUTED_BLR is not NULL, 'T', 'F') as IS_COMPUTED,\n" - + " 'F' as IS_IDENTITY,\n" - + " cast(NULL as VARCHAR(10)) as JB_IDENTITY_TYPE\n" - + "from RDB$RELATION_FIELDS RF inner join RDB$FIELDS F on RF.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"; + private static final String GET_COLUMNS_FRAGMENT_2_5 = """ + select + cast(null as char(1)) AS SCHEMA_NAME, + RF.RDB$RELATION_NAME as RELATION_NAME, + RF.RDB$FIELD_NAME as FIELD_NAME, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" + """ + RF.RDB$DESCRIPTION as REMARKS, + coalesce(RF.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as DEFAULT_SOURCE, + RF.RDB$FIELD_POSITION + 1 as FIELD_POSITION, + iif(coalesce(RF.RDB$NULL_FLAG, 0) + coalesce(F.RDB$NULL_FLAG, 0) = 0, 'T', 'F') as IS_NULLABLE, + iif(F.RDB$COMPUTED_BLR is not NULL, 'T', 'F') as IS_COMPUTED, + 'F' as IS_IDENTITY, + cast(NULL as VARCHAR(10)) as JB_IDENTITY_TYPE + from RDB$RELATION_FIELDS RF inner join RDB$FIELDS F on RF.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"""; private static final String GET_COLUMNS_ORDER_BY_2_5 = "\norder by RF.RDB$RELATION_NAME, RF.RDB$FIELD_POSITION"; - //@formatter:on private FB2_5(DbMetadataMediator mediator) { super(mediator); @@ -186,44 +191,43 @@ private static GetColumns createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetColumnsQuery(String tableNamePattern, String columnNamePattern) { - Clause tableNameClause = new Clause("RF.RDB$RELATION_NAME", tableNamePattern); - Clause columnNameClause = new Clause("RF.RDB$FIELD_NAME", columnNamePattern); + MetadataQuery createGetColumnsQuery(String schemaPattern, String tableNamePattern, String columnNamePattern) { + var clauses = List.of( + new Clause("RF.RDB$RELATION_NAME", tableNamePattern), + new Clause("RF.RDB$FIELD_NAME", columnNamePattern)); String sql = GET_COLUMNS_FRAGMENT_2_5 - + (Clause.anyCondition(tableNameClause, columnNameClause) - ? "\nwhere " + tableNameClause.getCondition(columnNameClause.hasCondition()) - + columnNameClause.getCondition(false) - : "") + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + GET_COLUMNS_ORDER_BY_2_5; - return new MetadataQuery(sql, Clause.parameters(tableNameClause, columnNameClause)); + return new MetadataQuery(sql, Clause.parameters(clauses)); } + } private static class FB3 extends GetColumns { - //@formatter:off - private static final String GET_COLUMNS_FRAGMENT_3 = - "select\n" - + " trim(trailing from RF.RDB$RELATION_NAME) as RELATION_NAME,\n" - + " trim(trailing from RF.RDB$FIELD_NAME) as FIELD_NAME,\n" - + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" - + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" - + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" - + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" - + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" - + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" - + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" - + " RF.RDB$DESCRIPTION as REMARKS,\n" - + " coalesce(RF.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as DEFAULT_SOURCE,\n" - + " RF.RDB$FIELD_POSITION + 1 as FIELD_POSITION,\n" - + " (coalesce(RF.RDB$NULL_FLAG, 0) + coalesce(F.RDB$NULL_FLAG, 0) = 0) as IS_NULLABLE,\n" - + " (F.RDB$COMPUTED_BLR is not NULL) as IS_COMPUTED,\n" - + " (RF.RDB$IDENTITY_TYPE IS NOT NULL) as IS_IDENTITY,\n" - + " trim(trailing from decode(RF.RDB$IDENTITY_TYPE, 0, 'ALWAYS', 1, 'BY DEFAULT')) as JB_IDENTITY_TYPE\n" - + "from RDB$RELATION_FIELDS RF inner join RDB$FIELDS F on RF.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"; + private static final String GET_COLUMNS_FRAGMENT_3 = """ + select + null AS SCHEMA_NAME, + trim(trailing from RF.RDB$RELATION_NAME) as RELATION_NAME, + trim(trailing from RF.RDB$FIELD_NAME) as FIELD_NAME, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" + """ + RF.RDB$DESCRIPTION as REMARKS, + coalesce(RF.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as DEFAULT_SOURCE, + RF.RDB$FIELD_POSITION + 1 as FIELD_POSITION, + (coalesce(RF.RDB$NULL_FLAG, 0) + coalesce(F.RDB$NULL_FLAG, 0) = 0) as IS_NULLABLE, + (F.RDB$COMPUTED_BLR is not NULL) as IS_COMPUTED, + (RF.RDB$IDENTITY_TYPE IS NOT NULL) as IS_IDENTITY, + trim(trailing from decode(RF.RDB$IDENTITY_TYPE, 0, 'ALWAYS', 1, 'BY DEFAULT')) as JB_IDENTITY_TYPE + from RDB$RELATION_FIELDS RF inner join RDB$FIELDS F on RF.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"""; private static final String GET_COLUMNS_ORDER_BY_3 = "\norder by RF.RDB$RELATION_NAME, RF.RDB$FIELD_POSITION"; - //@formatter:on private FB3(DbMetadataMediator mediator) { super(mediator); @@ -234,16 +238,67 @@ private static GetColumns createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetColumnsQuery(String tableNamePattern, String columnNamePattern) { - Clause tableNameClause = new Clause("RF.RDB$RELATION_NAME", tableNamePattern); - Clause columnNameClause = new Clause("RF.RDB$FIELD_NAME", columnNamePattern); + MetadataQuery createGetColumnsQuery(String schemaPattern, String tableNamePattern, String columnNamePattern) { + var clauses = List.of( + new Clause("RF.RDB$RELATION_NAME", tableNamePattern), + new Clause("RF.RDB$FIELD_NAME", columnNamePattern)); String sql = GET_COLUMNS_FRAGMENT_3 - + (Clause.anyCondition(tableNameClause, columnNameClause) - ? "\nwhere " + tableNameClause.getCondition(columnNameClause.hasCondition()) - + columnNameClause.getCondition(false) - : "") + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + GET_COLUMNS_ORDER_BY_3; - return new MetadataQuery(sql, Clause.parameters(tableNameClause, columnNameClause)); + return new MetadataQuery(sql, Clause.parameters(clauses)); } + } + + private static class FB6 extends GetColumns { + + private static final String GET_COLUMNS_FRAGMENT_6 = """ + select + trim(trailing from RF.RDB$SCHEMA_NAME) AS SCHEMA_NAME, + trim(trailing from RF.RDB$RELATION_NAME) as RELATION_NAME, + trim(trailing from RF.RDB$FIELD_NAME) as FIELD_NAME, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" + """ + RF.RDB$DESCRIPTION as REMARKS, + coalesce(RF.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as DEFAULT_SOURCE, + RF.RDB$FIELD_POSITION + 1 as FIELD_POSITION, + (coalesce(RF.RDB$NULL_FLAG, 0) + coalesce(F.RDB$NULL_FLAG, 0) = 0) as IS_NULLABLE, + (F.RDB$COMPUTED_BLR is not NULL) as IS_COMPUTED, + (RF.RDB$IDENTITY_TYPE IS NOT NULL) as IS_IDENTITY, + trim(trailing from decode(RF.RDB$IDENTITY_TYPE, 0, 'ALWAYS', 1, 'BY DEFAULT')) as JB_IDENTITY_TYPE + from SYSTEM.RDB$RELATION_FIELDS RF + inner join SYSTEM.RDB$FIELDS F + on RF.RDB$FIELD_SOURCE_SCHEMA_NAME = F.RDB$SCHEMA_NAME and RF.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"""; + + private static final String GET_COLUMNS_ORDER_BY_6 = + "\norder by RF.RDB$SCHEMA_NAME, RF.RDB$RELATION_NAME, RF.RDB$FIELD_POSITION"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetColumns createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetColumnsQuery(String schemaPattern, String tableNamePattern, String columnNamePattern) { + var clauses = List.of( + new Clause("RF.RDB$SCHEMA_NAME", schemaPattern), + new Clause("RF.RDB$RELATION_NAME", tableNamePattern), + new Clause("RF.RDB$FIELD_NAME", columnNamePattern)); + String sql = GET_COLUMNS_FRAGMENT_6 + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + + GET_COLUMNS_ORDER_BY_6; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + + } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java index 5482ecae6..01f384573 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java @@ -17,6 +17,8 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.jaybird.util.StringUtils.trimToNull; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -28,6 +30,8 @@ */ class FBDatabaseMetaDataColumnsTest { + // TODO Add schema support: tests involving other schema + private static final String TEST_TABLE = "TEST_COLUMN_METADATA"; private static final String CREATE_DOMAIN_WITH_DEFAULT = @@ -175,7 +179,7 @@ void testIntegerColumn() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 1); validationRules.put(ColumnMetaData.IS_AUTOINCREMENT, ""); - validate(TEST_TABLE, "COL_INTEGER", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_INTEGER", validationRules); } /** @@ -193,7 +197,7 @@ void testInteger_DefaultNullColumn() throws Exception { validationRules.put(ColumnMetaData.IS_AUTOINCREMENT, ""); validationRules.put(ColumnMetaData.COLUMN_DEF, "NULL"); - validate(TEST_TABLE, "COL_INTEGER_DEFAULT_NULL", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_INTEGER_DEFAULT_NULL", validationRules); } /** @@ -210,7 +214,7 @@ void testInteger_Default999Column() throws Exception { validationRules.put(ColumnMetaData.IS_AUTOINCREMENT, ""); validationRules.put(ColumnMetaData.COLUMN_DEF, "999"); - validate(TEST_TABLE, "COL_INTEGER_DEFAULT_999", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_INTEGER_DEFAULT_999", validationRules); } /** @@ -229,7 +233,7 @@ void testInteger_NotNullColumn() throws Exception { validationRules.put(ColumnMetaData.NULLABLE, DatabaseMetaData.columnNoNulls); validationRules.put(ColumnMetaData.IS_NULLABLE, "NO"); - validate(TEST_TABLE, "COL_INTEGER_NOT_NULL", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_INTEGER_NOT_NULL", validationRules); } /** @@ -247,7 +251,7 @@ void testBigintColumn() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 2); validationRules.put(ColumnMetaData.IS_AUTOINCREMENT, ""); - validate(TEST_TABLE, "COL_BIGINT", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_BIGINT", validationRules); } /** @@ -264,7 +268,7 @@ void testSmallintColumn() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 3); validationRules.put(ColumnMetaData.IS_AUTOINCREMENT, ""); - validate(TEST_TABLE, "COL_SMALLINT", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_SMALLINT", validationRules); } /** @@ -281,7 +285,7 @@ void testDoublePrecisionColumn() throws Exception { validationRules.put(ColumnMetaData.NUM_PREC_RADIX, supportsFloatBinaryPrecision ? 2 : 10); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 4); - validate(TEST_TABLE, "COL_DOUBLE", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_DOUBLE", validationRules); } /** @@ -298,7 +302,7 @@ void testFloatColumn() throws Exception { validationRules.put(ColumnMetaData.NUM_PREC_RADIX, supportsFloatBinaryPrecision ? 2 : 10); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 5); - validate(TEST_TABLE, "COL_FLOAT", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_FLOAT", validationRules); } /** @@ -326,7 +330,7 @@ void testDecimalColumn(String columnName, int expectedSize, int expectedDecDigit validationRules.put(ColumnMetaData.IS_AUTOINCREMENT, ""); } - validate(TEST_TABLE, columnName, validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, columnName, validationRules); } /** @@ -354,7 +358,7 @@ void testNumericColumn(String columnName, int expectedSize, int expectedDecDigit validationRules.put(ColumnMetaData.IS_AUTOINCREMENT, ""); } - validate(TEST_TABLE, columnName, validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, columnName, validationRules); } /** @@ -370,7 +374,7 @@ void testDateColumn() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, 10); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 18); - validate(TEST_TABLE, "COL_DATE", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_DATE", validationRules); } /** @@ -385,7 +389,7 @@ void testTimeColumn() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, 13); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 19); - validate(TEST_TABLE, "COL_TIME", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_TIME", validationRules); } /** @@ -400,7 +404,7 @@ void testTimestampColumn() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, 24); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 20); - validate(TEST_TABLE, "COL_TIMESTAMP", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_TIMESTAMP", validationRules); } /** @@ -416,7 +420,7 @@ void testChar10_UTF8Column() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 21); validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 40); - validate(TEST_TABLE, "COL_CHAR_10_UTF8", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_CHAR_10_UTF8", validationRules); } /** @@ -432,7 +436,7 @@ void testChar10_ISO8859_1Column() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 22); validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 10); - validate(TEST_TABLE, "COL_CHAR_10_ISO8859_1", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_CHAR_10_ISO8859_1", validationRules); } /** @@ -448,7 +452,7 @@ void testChar10_OCTETSColumn() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 23); validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 10); - validate(TEST_TABLE, "COL_CHAR_10_OCTETS", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_CHAR_10_OCTETS", validationRules); } /** @@ -464,7 +468,7 @@ void testVarchar10_UTF8Column() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 24); validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 40); - validate(TEST_TABLE, "COL_VARCHAR_10_UTF8", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_VARCHAR_10_UTF8", validationRules); } /** @@ -480,7 +484,7 @@ void testVarchar10_ISO8859_1Column() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 25); validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 10); - validate(TEST_TABLE, "COL_VARCHAR_10_ISO8859_1", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_VARCHAR_10_ISO8859_1", validationRules); } /** @@ -496,7 +500,7 @@ void testVarchar10_OCTETSColumn() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 26); validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 10); - validate(TEST_TABLE, "COL_VARCHAR_10_OCTETS", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_VARCHAR_10_OCTETS", validationRules); } /** @@ -518,7 +522,7 @@ void testVarchar_Default(String columnName, int expectedPosition, String expecte validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 100); validationRules.put(ColumnMetaData.COLUMN_DEF, expectedDefault); - validate(TEST_TABLE, columnName, validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, columnName, validationRules); } /** @@ -534,7 +538,7 @@ void testVarchar_Generated() throws Exception { validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 200); validationRules.put(ColumnMetaData.IS_GENERATEDCOLUMN, "YES"); - validate(TEST_TABLE, "COL_VARCHAR_GENERATED", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_VARCHAR_GENERATED", validationRules); } /** @@ -552,7 +556,7 @@ void testVarchar_NotNullColumn() throws Exception { validationRules.put(ColumnMetaData.NULLABLE, DatabaseMetaData.columnNoNulls); validationRules.put(ColumnMetaData.IS_NULLABLE, "NO"); - validate(TEST_TABLE, "COL_VARCHAR_NOT_NULL", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_VARCHAR_NOT_NULL", validationRules); } /** @@ -567,7 +571,7 @@ void testTextBlob_UTF8Column() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, null); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 27); - validate(TEST_TABLE, "COL_BLOB_TEXT_UTF8", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_BLOB_TEXT_UTF8", validationRules); } /** @@ -582,7 +586,7 @@ void testTextBlob_ISO8859_1Column() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, null); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 28); - validate(TEST_TABLE, "COL_BLOB_TEXT_ISO8859_1", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_BLOB_TEXT_ISO8859_1", validationRules); } /** @@ -597,7 +601,7 @@ void testBlobColumn() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, null); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 29); - validate(TEST_TABLE, "COL_BLOB_BINARY", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_BLOB_BINARY", validationRules); } /** @@ -614,7 +618,7 @@ void testDomainWithDefaultColumn() throws Exception { validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 100); validationRules.put(ColumnMetaData.COLUMN_DEF, "'this is a default'"); - validate(TEST_TABLE, "COL_DOMAIN_WITH_DEFAULT", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_DOMAIN_WITH_DEFAULT", validationRules); } /** @@ -631,7 +635,7 @@ void testDomainWithDefaultOverriddenColumn() throws Exception { validationRules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 100); validationRules.put(ColumnMetaData.COLUMN_DEF, "'overridden default'"); - validate(TEST_TABLE, "COL_DOMAIN_W_DEFAULT_OVERRIDDEN", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_DOMAIN_W_DEFAULT_OVERRIDDEN", validationRules); } @Test @@ -644,7 +648,7 @@ void testBooleanColumn() throws Exception { validationRules.put(ColumnMetaData.ORDINAL_POSITION, 40); validationRules.put(ColumnMetaData.NUM_PREC_RADIX, 2); - validate(TEST_TABLE, "COL_BOOLEAN", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_BOOLEAN", validationRules); } @Test @@ -656,7 +660,7 @@ void testDecfloat16Column() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, 16); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 41); - validate(TEST_TABLE, "COL_DECFLOAT16", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_DECFLOAT16", validationRules); } @Test @@ -668,7 +672,7 @@ void testDecfloat34Column() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, 34); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 42); - validate(TEST_TABLE, "COL_DECFLOAT34", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_DECFLOAT34", validationRules); } /** @@ -686,7 +690,7 @@ void testNumeric25_20Column() throws Exception { validationRules.put(ColumnMetaData.DECIMAL_DIGITS, 20); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 43); - validate(TEST_TABLE, "COL_NUMERIC25_20", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_NUMERIC25_20", validationRules); } /** @@ -704,7 +708,7 @@ void testDecimal30_5Column() throws Exception { validationRules.put(ColumnMetaData.DECIMAL_DIGITS, 5); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 44); - validate(TEST_TABLE, "COL_DECIMAL30_5", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_DECIMAL30_5", validationRules); } @Test @@ -717,7 +721,7 @@ void testTimeWithTimezoneColumn() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, 19); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 45); - validate(TEST_TABLE, "COL_TIMETZ", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_TIMETZ", validationRules); } @Test @@ -730,7 +734,7 @@ void testTimestampWithTimezoneColumn() throws Exception { validationRules.put(ColumnMetaData.COLUMN_SIZE, 30); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 46); - validate(TEST_TABLE, "COL_TIMESTAMPTZ", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_TIMESTAMPTZ", validationRules); } @Test @@ -744,25 +748,32 @@ void testInt128Column() throws Exception { validationRules.put(ColumnMetaData.DECIMAL_DIGITS, 0); validationRules.put(ColumnMetaData.ORDINAL_POSITION, 47); - validate(TEST_TABLE, "COL_INT128", validationRules); + validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_INT128", validationRules); } // TODO: Add more extensive tests of patterns /** * Method to validate the column metadata for a single column of a table (does not support quoted identifiers). - * - * @param tableName Name of the able - * @param columnName Name of the column - * @param validationRules Map of validationRules + * + * @param schema + * Name of the schema + * @param tableName + * Name of the table + * @param columnName + * Name of the column + * @param validationRules + * Map of validationRules */ @SuppressWarnings("SameParameterValue") - private void validate(String tableName, String columnName, Map validationRules) throws Exception { + private void validate(String schema, String tableName, String columnName, + Map validationRules) throws Exception { + validationRules.put(ColumnMetaData.TABLE_SCHEM, trimToNull(schema)); validationRules.put(ColumnMetaData.TABLE_NAME, tableName); validationRules.put(ColumnMetaData.COLUMN_NAME, columnName); getColumnsDefinition.checkValidationRulesComplete(validationRules); - try (ResultSet columns = dbmd.getColumns(null, null, tableName, columnName)) { + try (ResultSet columns = dbmd.getColumns(null, schema, tableName, columnName)) { assertTrue(columns.next(), "Expected row in column metadata"); getColumnsDefinition.validateRowValues(columns, validationRules); assertFalse(columns.next(), "Expected only one row in resultset"); @@ -773,7 +784,7 @@ private void validate(String tableName, String columnName, Map defaults = new EnumMap<>(ColumnMetaData.class); defaults.put(ColumnMetaData.TABLE_CAT, null); - defaults.put(ColumnMetaData.TABLE_SCHEM, null); + defaults.put(ColumnMetaData.TABLE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(ColumnMetaData.BUFFER_LENGTH, null); defaults.put(ColumnMetaData.DECIMAL_DIGITS, null); defaults.put(ColumnMetaData.NUM_PREC_RADIX, 10); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java index 3f56d38b2..e53d880d2 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java @@ -37,7 +37,7 @@ */ class FBDatabaseMetaDataProceduresTest { - // TODO Add tests for filtering by schema + // TODO Add schema support: tests involving other schema private static final String CREATE_NORMAL_PROC_NO_RETURN = """ CREATE PROCEDURE normal_proc_no_return From 1d30a6d4182ff1e4b335b732d8081de39069a0d8 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sat, 21 Jun 2025 15:56:32 +0200 Subject: [PATCH 11/64] #882 Schema support for getImportedKeys/getExportedKeys/getCrossReference --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 9 +- .../jdbc/metadata/AbstractKeysMethod.java | 6 +- .../jdbc/metadata/GetBestRowIdentifier.java | 2 +- .../jdbc/metadata/GetColumnPrivileges.java | 2 +- .../jdbc/metadata/GetCrossReference.java | 138 +++++++++++++++--- .../jdbc/metadata/GetExportedKeys.java | 126 +++++++++++++--- .../jdbc/metadata/GetImportedKeys.java | 127 +++++++++++++--- .../FBDatabaseMetaDataAbstractKeysTest.java | 15 +- .../FBDatabaseMetaDataCrossReferenceTest.java | 12 +- .../FBDatabaseMetaDataExportedKeysTest.java | 17 ++- .../FBDatabaseMetaDataImportedKeysTest.java | 17 ++- .../FBDatabaseMetaDataProceduresTest.java | 5 +- 12 files changed, 394 insertions(+), 82 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 72e8edb2c..15a25c8da 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1443,7 +1443,7 @@ public ResultSet getPrimaryKeys(String catalog, String schema, String table) thr */ @Override public ResultSet getImportedKeys(String catalog, String schema, String table) throws SQLException { - return GetImportedKeys.create(getDbMetadataMediator()).getImportedKeys(table); + return GetImportedKeys.create(getDbMetadataMediator()).getImportedKeys(schema, table); } /** @@ -1458,7 +1458,7 @@ public ResultSet getImportedKeys(String catalog, String schema, String table) th */ @Override public ResultSet getExportedKeys(String catalog, String schema, String table) throws SQLException { - return GetExportedKeys.create(getDbMetadataMediator()).getExportedKeys(table); + return GetExportedKeys.create(getDbMetadataMediator()).getExportedKeys(schema, table); } /** @@ -1473,9 +1473,10 @@ public ResultSet getExportedKeys(String catalog, String schema, String table) th */ @Override public ResultSet getCrossReference( - String primaryCatalog, String primarySchema, String primaryTable, + String parentCatalog, String parentSchema, String parentTable, String foreignCatalog, String foreignSchema, String foreignTable) throws SQLException { - return GetCrossReference.create(getDbMetadataMediator()).getCrossReference(primaryTable, foreignTable); + return GetCrossReference.create(getDbMetadataMediator()) + .getCrossReference(parentSchema, parentTable, foreignSchema, foreignTable); } @Override diff --git a/src/main/org/firebirdsql/jdbc/metadata/AbstractKeysMethod.java b/src/main/org/firebirdsql/jdbc/metadata/AbstractKeysMethod.java index a263b9e2a..6f08fde7c 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/AbstractKeysMethod.java +++ b/src/main/org/firebirdsql/jdbc/metadata/AbstractKeysMethod.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -51,8 +51,10 @@ abstract class AbstractKeysMethod extends AbstractMetadataMethod { @Override final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { return valueBuilder + .at(1).setString(rs.getString("PKTABLE_SCHEM")) .at(2).setString(rs.getString("PKTABLE_NAME")) .at(3).setString(rs.getString("PKCOLUMN_NAME")) + .at(5).setString(rs.getString("FKTABLE_SCHEM")) .at(6).setString(rs.getString("FKTABLE_NAME")) .at(7).setString(rs.getString("FKCOLUMN_NAME")) .at(8).setShort(rs.getShort("KEY_SEQ")) diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java index 5ff6c7670..870307cae 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java @@ -62,7 +62,7 @@ private GetBestRowIdentifier(DbMetadataMediator mediator) { } @SuppressWarnings("unused") - public ResultSet getBestRowIdentifier(String catalog, String schema, String table, int scope, boolean nullable) + public final ResultSet getBestRowIdentifier(String catalog, String schema, String table, int scope, boolean nullable) throws SQLException { if (isNullOrEmpty(table)) { return createEmpty(); diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java b/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java index 0416d1ba4..1912e254e 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java @@ -52,7 +52,7 @@ public abstract class GetColumnPrivileges extends AbstractMetadataMethod { /** * @see java.sql.DatabaseMetaData#getColumnPrivileges(String, String, String, String) */ - public ResultSet getColumnPrivileges(String schema, String table, String columnNamePattern) throws SQLException { + public final ResultSet getColumnPrivileges(String schema, String table, String columnNamePattern) throws SQLException { if (isNullOrEmpty(table) || "".equals(columnNamePattern)) { return createEmpty(); } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java b/src/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java index 7c2a8dd4f..47369e451 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -8,6 +8,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; @@ -17,12 +18,44 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetCrossReference extends AbstractKeysMethod { +public abstract class GetCrossReference extends AbstractKeysMethod { - private static final String GET_CROSS_KEYS_START = """ + private GetCrossReference(DbMetadataMediator mediator) { + super(mediator); + } + + public final ResultSet getCrossReference(String parentSchema, String parentTable, + String foreignSchema, String foreignTable) throws SQLException { + if (isNullOrEmpty(parentTable) || isNullOrEmpty(foreignTable)) { + return createEmpty(); + } + MetadataQuery metadataQuery = createGetCrossReferenceQuery(parentSchema, parentTable, foreignSchema, foreignTable); + return createMetaDataResultSet(metadataQuery); + } + + abstract MetadataQuery createGetCrossReferenceQuery(String parentSchema, String parentTable, + String foreignSchema, String foreignTable); + + public static GetCrossReference create(DbMetadataMediator mediator) { + // NOTE: Indirection through static method prevents unnecessary classloading + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else { + return FB5.createInstance(mediator); + } + } + + /** + * Implementation for Firebird 5.0 and older. + */ + private static final class FB5 extends GetCrossReference { + + private static final String GET_CROSS_KEYS_START_5 = """ select + cast(null as char(1)) AS PKTABLE_SCHEM, PK.RDB$RELATION_NAME as PKTABLE_NAME, ISP.RDB$FIELD_NAME as PKCOLUMN_NAME, + cast(null as char(1)) AS FKTABLE_SCHEM, FK.RDB$RELATION_NAME as FKTABLE_NAME, ISF.RDB$FIELD_NAME as FKCOLUMN_NAME, ISP.RDB$FIELD_POSITION + 1 as KEY_SEQ, @@ -43,27 +76,92 @@ public final class GetCrossReference extends AbstractKeysMethod { on ISF.RDB$INDEX_NAME = FK.RDB$INDEX_NAME and ISP.RDB$FIELD_POSITION = ISF.RDB$FIELD_POSITION where\s"""; - private static final String GET_CROSS_KEYS_END = "\norder by FK.RDB$RELATION_NAME, ISP.RDB$FIELD_POSITION"; + private static final String GET_CROSS_KEYS_END_5 = "\norder by FK.RDB$RELATION_NAME, ISP.RDB$FIELD_POSITION"; - private GetCrossReference(DbMetadataMediator mediator) { - super(mediator); - } + private FB5(DbMetadataMediator mediator) { + super(mediator); + } - public ResultSet getCrossReference(String primaryTable, String foreignTable) throws SQLException { - if (isNullOrEmpty(primaryTable) || isNullOrEmpty(foreignTable)) { - return createEmpty(); + private static GetCrossReference createInstance(DbMetadataMediator mediator) { + return new FB5(mediator); + } + + @Override + MetadataQuery createGetCrossReferenceQuery(String parentSchema, String parentTable, + String foreignSchema, String foreignTable) { + Clause parentTableClause = Clause.equalsClause("PK.RDB$RELATION_NAME", parentTable); + Clause foreignTableCause = Clause.equalsClause("FK.RDB$RELATION_NAME", foreignTable); + String sql = GET_CROSS_KEYS_START_5 + + Clause.conjunction(parentTableClause, foreignTableCause) + + GET_CROSS_KEYS_END_5; + return new MetadataQuery(sql, Clause.parameters(parentTableClause, foreignTableCause)); } - Clause primaryTableClause = Clause.equalsClause("PK.RDB$RELATION_NAME", primaryTable); - Clause foreignTableCause = Clause.equalsClause("FK.RDB$RELATION_NAME", foreignTable); - String sql = GET_CROSS_KEYS_START - + Clause.conjunction(primaryTableClause, foreignTableCause) - + GET_CROSS_KEYS_END; - MetadataQuery metadataQuery = new MetadataQuery(sql, Clause.parameters(primaryTableClause, foreignTableCause)); - return createMetaDataResultSet(metadataQuery); } - public static GetCrossReference create(DbMetadataMediator mediator) { - return new GetCrossReference(mediator); + /** + * Implementation for Firebird 6.0 and higher. + */ + private static final class FB6 extends GetCrossReference { + + private static final String GET_CROSS_KEYS_START_6 = """ + select + trim(trailing from PK.RDB$SCHEMA_NAME) as PKTABLE_SCHEM, + trim(trailing from PK.RDB$RELATION_NAME) as PKTABLE_NAME, + trim(trailing from ISP.RDB$FIELD_NAME) as PKCOLUMN_NAME, + trim(trailing from FK.RDB$SCHEMA_NAME) as FKTABLE_SCHEM, + trim(trailing from FK.RDB$RELATION_NAME) as FKTABLE_NAME, + trim(trailing from ISF.RDB$FIELD_NAME) as FKCOLUMN_NAME, + ISP.RDB$FIELD_POSITION + 1 as KEY_SEQ, + RC.RDB$UPDATE_RULE as UPDATE_RULE, + RC.RDB$DELETE_RULE as DELETE_RULE, + trim(trailing from PK.RDB$CONSTRAINT_NAME) as PK_NAME, + trim(trailing from FK.RDB$CONSTRAINT_NAME) as FK_NAME, + trim(trailing from PK.RDB$INDEX_NAME) as JB_PK_INDEX_NAME, + trim(trailing from FK.RDB$INDEX_NAME) as JB_FK_INDEX_NAME + from SYSTEM.RDB$RELATION_CONSTRAINTS PK + inner join SYSTEM.RDB$REF_CONSTRAINTS RC + on PK.RDB$SCHEMA_NAME = RC.RDB$CONST_SCHEMA_NAME_UQ and PK.RDB$CONSTRAINT_NAME = RC.RDB$CONST_NAME_UQ + inner join SYSTEM.RDB$RELATION_CONSTRAINTS FK + on FK.RDB$SCHEMA_NAME = RC.RDB$SCHEMA_NAME and FK.RDB$CONSTRAINT_NAME = RC.RDB$CONSTRAINT_NAME + inner join SYSTEM.RDB$INDEX_SEGMENTS ISP + on ISP.RDB$SCHEMA_NAME = PK.RDB$SCHEMA_NAME and ISP.RDB$INDEX_NAME = PK.RDB$INDEX_NAME + inner join SYSTEM.RDB$INDEX_SEGMENTS ISF + on ISF.RDB$SCHEMA_NAME = FK.RDB$SCHEMA_NAME and ISF.RDB$INDEX_NAME = FK.RDB$INDEX_NAME + and ISP.RDB$FIELD_POSITION = ISF.RDB$FIELD_POSITION + where\s"""; + + private static final String GET_CROSS_KEYS_END_6 = + "\norder by FK.RDB$SCHEMA_NAME, FK.RDB$RELATION_NAME, ISP.RDB$FIELD_POSITION"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetCrossReference createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetCrossReferenceQuery(String parentSchema, String parentTable, + String foreignSchema, String foreignTable) { + var clauses = new ArrayList(4); + if (parentSchema != null) { + // NOTE: empty string will return no rows as required ("" retrieves those without a schema) + clauses.add(Clause.equalsClause("PK.RDB$SCHEMA_NAME", parentSchema)); + } + clauses.add(Clause.equalsClause("PK.RDB$RELATION_NAME", parentTable)); + if (foreignSchema != null) { + // NOTE: empty string will return no rows as required ("" retrieves those without a schema) + clauses.add(Clause.equalsClause("FK.RDB$SCHEMA_NAME", foreignSchema)); + } + clauses.add(Clause.equalsClause("FK.RDB$RELATION_NAME", foreignTable)); + String sql = GET_CROSS_KEYS_START_6 + + Clause.conjunction(clauses) + + GET_CROSS_KEYS_END_6; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + } + } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetExportedKeys.java b/src/main/org/firebirdsql/jdbc/metadata/GetExportedKeys.java index 357013984..0434e8853 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetExportedKeys.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetExportedKeys.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -8,6 +8,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; @@ -17,12 +18,42 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetExportedKeys extends AbstractKeysMethod { +public abstract class GetExportedKeys extends AbstractKeysMethod { - private static final String GET_EXPORTED_KEYS_START = """ + private GetExportedKeys(DbMetadataMediator mediator) { + super(mediator); + } + + public final ResultSet getExportedKeys(String schema, String table) throws SQLException { + if (isNullOrEmpty(table)) { + return createEmpty(); + } + MetadataQuery metadataQuery = createGetExportedKeysQuery(schema, table); + return createMetaDataResultSet(metadataQuery); + } + + abstract MetadataQuery createGetExportedKeysQuery(String schema, String table); + + public static GetExportedKeys create(DbMetadataMediator mediator) { + // NOTE: Indirection through static method prevents unnecessary classloading + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else { + return FB5.createInstance(mediator); + } + } + + /** + * Implementation for Firebird 5.0 and older. + */ + private static final class FB5 extends GetExportedKeys { + + private static final String GET_EXPORTED_KEYS_START_5 = """ select + cast(null as char(1)) as PKTABLE_SCHEM, PK.RDB$RELATION_NAME as PKTABLE_NAME, ISP.RDB$FIELD_NAME as PKCOLUMN_NAME, + cast(null as char(1)) as FKTABLE_SCHEM, FK.RDB$RELATION_NAME as FKTABLE_NAME, ISF.RDB$FIELD_NAME as FKCOLUMN_NAME, ISP.RDB$FIELD_POSITION + 1 as KEY_SEQ, @@ -43,26 +74,83 @@ public final class GetExportedKeys extends AbstractKeysMethod { on ISF.RDB$INDEX_NAME = FK.RDB$INDEX_NAME and ISP.RDB$FIELD_POSITION = ISF.RDB$FIELD_POSITION where\s"""; - private static final String GET_EXPORTED_KEYS_END = "\norder by FK.RDB$RELATION_NAME, ISP.RDB$FIELD_POSITION"; + private static final String GET_EXPORTED_KEYS_END_5 = "\norder by FK.RDB$RELATION_NAME, ISP.RDB$FIELD_POSITION"; - private GetExportedKeys(DbMetadataMediator mediator) { - super(mediator); - } + private FB5(DbMetadataMediator mediator) { + super(mediator); + } - public ResultSet getExportedKeys(String table) throws SQLException { - if (isNullOrEmpty(table)) { - return createEmpty(); + private static GetExportedKeys createInstance(DbMetadataMediator mediator) { + return new FB5(mediator); + } + + @Override + MetadataQuery createGetExportedKeysQuery(String schema, String table) { + Clause tableClause = Clause.equalsClause("PK.RDB$RELATION_NAME", table); + String sql = GET_EXPORTED_KEYS_START_5 + + tableClause.getCondition(false) + + GET_EXPORTED_KEYS_END_5; + return new MetadataQuery(sql, Clause.parameters(tableClause)); } - Clause tableClause = Clause.equalsClause("PK.RDB$RELATION_NAME", table); - String sql = GET_EXPORTED_KEYS_START - + tableClause.getCondition(false) - + GET_EXPORTED_KEYS_END; - MetadataQuery metadataQuery = new MetadataQuery(sql, Clause.parameters(tableClause)); - return createMetaDataResultSet(metadataQuery); } - public static GetExportedKeys create(DbMetadataMediator mediator) { - return new GetExportedKeys(mediator); + /** + * Implementation for Firebird 6.0 and higher. + */ + private static final class FB6 extends GetExportedKeys { + + private static final String GET_EXPORTED_KEYS_START_6 = """ + select + trim(trailing from PK.RDB$SCHEMA_NAME) as PKTABLE_SCHEM, + trim(trailing from PK.RDB$RELATION_NAME) as PKTABLE_NAME, + trim(trailing from ISP.RDB$FIELD_NAME) as PKCOLUMN_NAME, + trim(trailing from FK.RDB$SCHEMA_NAME) as FKTABLE_SCHEM, + trim(trailing from FK.RDB$RELATION_NAME) as FKTABLE_NAME, + trim(trailing from ISF.RDB$FIELD_NAME) as FKCOLUMN_NAME, + ISP.RDB$FIELD_POSITION + 1 as KEY_SEQ, + RC.RDB$UPDATE_RULE as UPDATE_RULE, + RC.RDB$DELETE_RULE as DELETE_RULE, + trim(trailing from PK.RDB$CONSTRAINT_NAME) as PK_NAME, + trim(trailing from FK.RDB$CONSTRAINT_NAME) as FK_NAME, + trim(trailing from PK.RDB$INDEX_NAME) as JB_PK_INDEX_NAME, + trim(trailing from FK.RDB$INDEX_NAME) as JB_FK_INDEX_NAME + from SYSTEM.RDB$RELATION_CONSTRAINTS PK + inner join SYSTEM.RDB$REF_CONSTRAINTS RC + on PK.RDB$SCHEMA_NAME = RC.RDB$CONST_SCHEMA_NAME_UQ and PK.RDB$CONSTRAINT_NAME = RC.RDB$CONST_NAME_UQ + inner join SYSTEM.RDB$RELATION_CONSTRAINTS FK + on FK.RDB$SCHEMA_NAME = RC.RDB$SCHEMA_NAME and FK.RDB$CONSTRAINT_NAME = RC.RDB$CONSTRAINT_NAME + inner join SYSTEM.RDB$INDEX_SEGMENTS ISP + on ISP.RDB$SCHEMA_NAME = PK.RDB$SCHEMA_NAME and ISP.RDB$INDEX_NAME = PK.RDB$INDEX_NAME + inner join SYSTEM.RDB$INDEX_SEGMENTS ISF + on ISF.RDB$SCHEMA_NAME = FK.RDB$SCHEMA_NAME and ISF.RDB$INDEX_NAME = FK.RDB$INDEX_NAME + and ISP.RDB$FIELD_POSITION = ISF.RDB$FIELD_POSITION + where\s"""; + + private static final String GET_EXPORTED_KEYS_END_6 = + "\norder by FK.RDB$SCHEMA_NAME, FK.RDB$RELATION_NAME, ISP.RDB$FIELD_POSITION"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetExportedKeys createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetExportedKeysQuery(String schema, String table) { + var clauses = new ArrayList(2); + if (schema != null) { + // NOTE: empty string will return no rows as required ("" retrieves those without a schema) + clauses.add(Clause.equalsClause("PK.RDB$SCHEMA_NAME", schema)); + } + clauses.add(Clause.equalsClause("PK.RDB$RELATION_NAME", table)); + String sql = GET_EXPORTED_KEYS_START_6 + + Clause.conjunction(clauses) + + GET_EXPORTED_KEYS_END_6; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + } } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java b/src/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java index fd3343a3e..8c264b6fb 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -8,6 +8,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; @@ -17,12 +18,42 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetImportedKeys extends AbstractKeysMethod { +public abstract class GetImportedKeys extends AbstractKeysMethod { - private static final String GET_IMPORTED_KEYS_START = """ + private GetImportedKeys(DbMetadataMediator mediator) { + super(mediator); + } + + public final ResultSet getImportedKeys(String schema, String table) throws SQLException { + if (isNullOrEmpty(table)) { + return createEmpty(); + } + MetadataQuery metadataQuery = createGetImportedKeysQuery(schema, table); + return createMetaDataResultSet(metadataQuery); + } + + abstract MetadataQuery createGetImportedKeysQuery(String schema, String table); + + public static GetImportedKeys create(DbMetadataMediator mediator) { + // NOTE: Indirection through static method prevents unnecessary classloading + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else { + return FB5.createInstance(mediator); + } + } + + /** + * Implementation for Firebird 5.0 and older. + */ + private static final class FB5 extends GetImportedKeys { + + private static final String GET_IMPORTED_KEYS_START_5 = """ select + cast(null as char(1)) as PKTABLE_SCHEM, PK.RDB$RELATION_NAME as PKTABLE_NAME, ISP.RDB$FIELD_NAME as PKCOLUMN_NAME, + cast(null as char(1)) as FKTABLE_SCHEM, FK.RDB$RELATION_NAME as FKTABLE_NAME, ISF.RDB$FIELD_NAME as FKCOLUMN_NAME, ISP.RDB$FIELD_POSITION + 1 as KEY_SEQ, @@ -43,26 +74,84 @@ public final class GetImportedKeys extends AbstractKeysMethod { on ISF.RDB$INDEX_NAME = FK.RDB$INDEX_NAME and ISP.RDB$FIELD_POSITION = ISF.RDB$FIELD_POSITION where\s"""; - private static final String GET_IMPORTED_KEYS_END = "\norder by PK.RDB$RELATION_NAME, ISP.RDB$FIELD_POSITION"; + private static final String GET_IMPORTED_KEYS_END_5 = "\norder by PK.RDB$RELATION_NAME, ISP.RDB$FIELD_POSITION"; - private GetImportedKeys(DbMetadataMediator mediator) { - super(mediator); - } + private FB5(DbMetadataMediator mediator) { + super(mediator); + } - public ResultSet getImportedKeys(String table) throws SQLException { - if (isNullOrEmpty(table)) { - return createEmpty(); + private static GetImportedKeys createInstance(DbMetadataMediator mediator) { + return new FB5(mediator); + } + + @Override + MetadataQuery createGetImportedKeysQuery(String schema, String table) { + Clause tableClause = Clause.equalsClause("FK.RDB$RELATION_NAME", table); + String sql = GET_IMPORTED_KEYS_START_5 + + tableClause.getCondition(false) + + GET_IMPORTED_KEYS_END_5; + return new MetadataQuery(sql, Clause.parameters(tableClause)); } - Clause tableClause = Clause.equalsClause("FK.RDB$RELATION_NAME", table); - String sql = GET_IMPORTED_KEYS_START - + tableClause.getCondition(false) - + GET_IMPORTED_KEYS_END; - MetadataQuery metadataQuery = new MetadataQuery(sql, Clause.parameters(tableClause)); - return createMetaDataResultSet(metadataQuery); } - public static GetImportedKeys create(DbMetadataMediator mediator) { - return new GetImportedKeys(mediator); + /** + * Implementation for Firebird 6.0 and higher. + */ + private static final class FB6 extends GetImportedKeys { + + private static final String GET_IMPORTED_KEYS_START_6 = """ + select + trim(trailing from PK.RDB$SCHEMA_NAME) as PKTABLE_SCHEM, + trim(trailing from PK.RDB$RELATION_NAME) as PKTABLE_NAME, + trim(trailing from ISP.RDB$FIELD_NAME) as PKCOLUMN_NAME, + trim(trailing from FK.RDB$SCHEMA_NAME) as FKTABLE_SCHEM, + trim(trailing from FK.RDB$RELATION_NAME) as FKTABLE_NAME, + trim(trailing from ISF.RDB$FIELD_NAME) as FKCOLUMN_NAME, + ISP.RDB$FIELD_POSITION + 1 as KEY_SEQ, + RC.RDB$UPDATE_RULE as UPDATE_RULE, + RC.RDB$DELETE_RULE as DELETE_RULE, + trim(trailing from PK.RDB$CONSTRAINT_NAME) as PK_NAME, + trim(trailing from FK.RDB$CONSTRAINT_NAME) as FK_NAME, + trim(trailing from PK.RDB$INDEX_NAME) as JB_PK_INDEX_NAME, + trim(trailing from FK.RDB$INDEX_NAME) as JB_FK_INDEX_NAME + from SYSTEM.RDB$RELATION_CONSTRAINTS PK + inner join SYSTEM.RDB$REF_CONSTRAINTS RC + on PK.RDB$SCHEMA_NAME = RC.RDB$CONST_SCHEMA_NAME_UQ and PK.RDB$CONSTRAINT_NAME = RC.RDB$CONST_NAME_UQ + inner join SYSTEM.RDB$RELATION_CONSTRAINTS FK + on FK.RDB$SCHEMA_NAME = RC.RDB$SCHEMA_NAME and FK.RDB$CONSTRAINT_NAME = RC.RDB$CONSTRAINT_NAME + inner join SYSTEM.RDB$INDEX_SEGMENTS ISP + on ISP.RDB$SCHEMA_NAME = PK.RDB$SCHEMA_NAME and ISP.RDB$INDEX_NAME = PK.RDB$INDEX_NAME + inner join SYSTEM.RDB$INDEX_SEGMENTS ISF + on ISF.RDB$SCHEMA_NAME = FK.RDB$SCHEMA_NAME and ISF.RDB$INDEX_NAME = FK.RDB$INDEX_NAME + and ISP.RDB$FIELD_POSITION = ISF.RDB$FIELD_POSITION + where\s"""; + + private static final String GET_IMPORTED_KEYS_END_6 = + "\norder by PK.RDB$SCHEMA_NAME, PK.RDB$RELATION_NAME, ISP.RDB$FIELD_POSITION"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetImportedKeys createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetImportedKeysQuery(String schema, String table) { + var clauses = new ArrayList(2); + if (schema != null) { + // NOTE: empty string will return no rows as required ("" retrieves those without a schema) + clauses.add(Clause.equalsClause("FK.RDB$SCHEMA_NAME", schema)); + } + clauses.add(Clause.equalsClause("FK.RDB$RELATION_NAME", table)); + String sql = GET_IMPORTED_KEYS_START_6 + + Clause.conjunction(clauses) + + GET_IMPORTED_KEYS_END_6; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java index a1714d1df..c25a1d6ab 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2024-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -19,8 +19,10 @@ import static java.util.Collections.unmodifiableMap; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNoNextRow; +import static org.firebirdsql.jaybird.util.StringUtils.trimToNull; /** * Base test class for subclasses of {@code org.firebirdsql.jdbc.metadata.AbstractKeysMethod}. @@ -180,9 +182,18 @@ protected void validateExpectedKeys(ResultSet keys, List createKeysTestData(String pkTable, String pkColumn, String fkTable, String fkColumn, int keySeq, int updateRule, int deleteRule, String pkName, String fkName, String pkIndexName, String fkIndexName) { + return createKeysTestData(ifSchemaElse("PUBLIC", null), pkTable, pkColumn, ifSchemaElse("PUBLIC", null), + fkTable, fkColumn, keySeq, updateRule, deleteRule, pkName, fkName, pkIndexName, fkIndexName); + } + + protected static Map createKeysTestData(String pkSchema, String pkTable, String pkColumn, + String fkSchema, String fkTable, String fkColumn, int keySeq, int updateRule, int deleteRule, + String pkName, String fkName, String pkIndexName, String fkIndexName) { Map rules = getDefaultValidationRules(); + rules.put(KeysMetaData.PKTABLE_SCHEM, trimToNull(pkSchema)); rules.put(KeysMetaData.PKTABLE_NAME, pkTable); rules.put(KeysMetaData.PKCOLUMN_NAME, pkColumn); + rules.put(KeysMetaData.FKTABLE_SCHEM, trimToNull(fkSchema)); rules.put(KeysMetaData.FKTABLE_NAME, fkTable); rules.put(KeysMetaData.FKCOLUMN_NAME, fkColumn); rules.put(KeysMetaData.KEY_SEQ, (short) keySeq); @@ -207,6 +218,8 @@ private static Object constraintNameValidation(String constraintName) { static { var defaults = new EnumMap<>(KeysMetaData.class); Arrays.stream(KeysMetaData.values()).forEach(key -> defaults.put(key, null)); + defaults.put(KeysMetaData.PKTABLE_SCHEM, ifSchemaElse("PUBLIC", null)); + defaults.put(KeysMetaData.FKTABLE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(KeysMetaData.UPDATE_RULE, DatabaseMetaData.importedKeyNoAction); defaults.put(KeysMetaData.DELETE_RULE, DatabaseMetaData.importedKeyNoAction); defaults.put(KeysMetaData.DEFERRABILITY, DatabaseMetaData.importedKeyNotDeferrable); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java index 8320c5ce2..041bc72a6 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2024-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -12,6 +12,8 @@ import java.util.Map; import java.util.stream.Stream; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; + /** * Tests for {@link FBDatabaseMetaData#getCrossReference(String, String, String, String, String, String)}. * @@ -19,9 +21,12 @@ */ class FBDatabaseMetaDataCrossReferenceTest extends FBDatabaseMetaDataAbstractKeysTest { + // TODO Add schema support: tests involving other schema + @Test void testCrossReferenceMetaDataColumns() throws Exception { - try (ResultSet crossReference = dbmd.getCrossReference(null, null, "doesnotexit", null, null, "doesnotexist")) { + try (ResultSet crossReference = dbmd.getCrossReference( + null, null, "doesnotexist", null, null, "doesnotexist")) { keysDefinition.validateResultSetColumns(crossReference); } } @@ -30,7 +35,8 @@ void testCrossReferenceMetaDataColumns() throws Exception { @MethodSource void testCrossReference(String parentTable, String foreignTable, List> expectedKeys) throws Exception { - try (ResultSet crossReference = dbmd.getCrossReference(null, null, parentTable, null, null, foreignTable)) { + try (ResultSet crossReference = dbmd.getCrossReference(null, ifSchemaElse("PUBLIC", ""), parentTable, null, + ifSchemaElse("PUBLIC", ""), foreignTable)) { validateExpectedKeys(crossReference, expectedKeys); } } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataExportedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataExportedKeysTest.java index e1f0f3ebd..3b4217ebe 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataExportedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataExportedKeysTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2024-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -14,6 +14,8 @@ import java.util.Map; import java.util.stream.Stream; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; + /** * Tests for {@link java.sql.DatabaseMetaData#getExportedKeys(String, String, String)}. * @@ -21,6 +23,8 @@ */ class FBDatabaseMetaDataExportedKeysTest extends FBDatabaseMetaDataAbstractKeysTest { + // TODO Add schema support: tests involving other schema + @Test void testExportedKeysMetaDataColumns() throws Exception { try (ResultSet exportedKeys = dbmd.getExportedKeys(null, null, "doesnotexit")) { @@ -30,8 +34,8 @@ void testExportedKeysMetaDataColumns() throws Exception { @ParameterizedTest @MethodSource - void testExportedKeys(String table, List> expectedKeys) throws Exception { - try (ResultSet exportedKeys = dbmd.getExportedKeys(null, null, table)) { + void testExportedKeys(String schema, String table, List> expectedKeys) throws Exception { + try (ResultSet exportedKeys = dbmd.getExportedKeys(null, schema, table)) { validateExpectedKeys(exportedKeys, expectedKeys); } } @@ -46,7 +50,12 @@ static Stream testExportedKeys() { } private static Arguments exportedKeysTestCase(String table, List> expectedKeys) { - return Arguments.of(table, expectedKeys); + return exportedKeysTestCase(ifSchemaElse("PUBLIC", ""), table, expectedKeys); + } + + private static Arguments exportedKeysTestCase(String schema, String table, + List> expectedKeys) { + return Arguments.of(schema, table, expectedKeys); } @SuppressWarnings("SameParameterValue") diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java index ad64ea532..296cd3b42 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2024-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -12,6 +12,8 @@ import java.util.Map; import java.util.stream.Stream; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; + /** * Tests for {@link java.sql.DatabaseMetaData#getImportedKeys(String, String, String)}. * @@ -19,6 +21,8 @@ */ class FBDatabaseMetaDataImportedKeysTest extends FBDatabaseMetaDataAbstractKeysTest { + // TODO Add schema support: tests involving other schema + @Test void testExportedKeysMetaDataColumns() throws Exception { try (ResultSet importedKeys = dbmd.getImportedKeys(null, null, "doesnotexit")) { @@ -28,8 +32,8 @@ void testExportedKeysMetaDataColumns() throws Exception { @ParameterizedTest @MethodSource - void testImportedKeys(String table, List> expectedKeys) throws Exception { - try (ResultSet importedKeys = dbmd.getImportedKeys(null, null, table)) { + void testImportedKeys(String schema, String table, List> expectedKeys) throws Exception { + try (ResultSet importedKeys = dbmd.getImportedKeys(null, schema, table)) { validateExpectedKeys(importedKeys, expectedKeys); } } @@ -47,7 +51,12 @@ static Stream testImportedKeys() { } private static Arguments importedKeysTestCase(String table, List> expectedKeys) { - return Arguments.of(table, expectedKeys); + return importedKeysTestCase(ifSchemaElse("PUBLIC", ""), table, expectedKeys); + } + + private static Arguments importedKeysTestCase(String schema, String table, + List> expectedKeys) { + return Arguments.of(schema, table, expectedKeys); } } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java index e53d880d2..293416ab0 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java @@ -26,6 +26,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultPropertiesForConnection; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -372,10 +373,6 @@ public Class getColumnClass() { } } - private static String ifSchemaElse(String forSchema, String withoutSchema) { - return getDefaultSupportInfo().supportsSchemas() ? forSchema : withoutSchema; - } - private enum ProcedureTestData { NORMAL_PROC_NO_RETURN("normal_proc_no_return", List.of(CREATE_NORMAL_PROC_NO_RETURN)) { @Override From 69effebb07ec51dde743c38a3ce638524135e9b6 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 22 Jun 2025 13:10:17 +0200 Subject: [PATCH 12/64] #882 Schema support for getFunctionColumns --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 2 +- .../jdbc/metadata/GetFunctionColumns.java | 389 +++++++++++++----- ...FBDatabaseMetaDataFunctionColumnsTest.java | 24 +- .../jdbc/FBDatabaseMetaDataFunctionsTest.java | 7 +- 4 files changed, 297 insertions(+), 125 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 15a25c8da..043d311db 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1742,7 +1742,7 @@ public ResultSet getClientInfoProperties() throws SQLException { public ResultSet getFunctionColumns(String catalog, String schemaPattern, String functionNamePattern, String columnNamePattern) throws SQLException { return GetFunctionColumns.create(getDbMetadataMediator()) - .getFunctionColumns(catalog, functionNamePattern, columnNamePattern); + .getFunctionColumns(catalog, schemaPattern, functionNamePattern, columnNamePattern); } /** diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java index 1b4860dc5..c7c464109 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2019-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2019-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -13,6 +13,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.List; import static java.sql.DatabaseMetaData.functionColumnIn; import static java.sql.DatabaseMetaData.functionNoNulls; @@ -73,14 +74,15 @@ private GetFunctionColumns(DbMetadataMediator mediator) { /** * @see java.sql.DatabaseMetaData#getFunctionColumns(String, String, String, String) */ - public final ResultSet getFunctionColumns(String catalog, String functionNamePattern, String columnNamePattern) - throws SQLException { + public final ResultSet getFunctionColumns(String catalog, String schemaPattern, String functionNamePattern, + String columnNamePattern) throws SQLException { if ("".equals(functionNamePattern) || "".equals(columnNamePattern)) { // Matching function name or column name not possible return createEmpty(); } - MetadataQuery metadataQuery = createGetFunctionColumnsQuery(catalog, functionNamePattern, columnNamePattern); + MetadataQuery metadataQuery = createGetFunctionColumnsQuery(catalog, schemaPattern, functionNamePattern, + columnNamePattern); return createMetaDataResultSet(metadataQuery); } @@ -90,12 +92,13 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .fromCurrentRow(rs) .build(); String catalog = rs.getString("FUNCTION_CAT"); + String schema = rs.getString("FUNCTION_SCHEM"); String functionName = rs.getString("FUNCTION_NAME"); int ordinalPosition = rs.getInt("ORDINAL_POSITION"); boolean nullable = rs.getBoolean("IS_NULLABLE"); return valueBuilder .at(0).setString(catalog) - .at(1).set(null) + .at(1).setString(schema) .at(2).setString(functionName) .at(3).setString(rs.getString("COLUMN_NAME")) .at(4).setShort(ordinalPosition == 0 ? functionReturn : functionColumnIn) @@ -111,17 +114,22 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .at(13).setInt(typeMetadata.getCharOctetLength()) .at(14).setInt(ordinalPosition) .at(15).setString(nullable ? "YES" : "NO") - .at(16).setString(toSpecificName(catalog, functionName)) + .at(16).setString(toSpecificName(catalog, schema, functionName)) .toRowValue(false); } - abstract MetadataQuery createGetFunctionColumnsQuery(String catalog, String functionNamePattern, - String columnNamePattern); + abstract MetadataQuery createGetFunctionColumnsQuery(String catalog, String schemaPattern, + String functionNamePattern, String columnNamePattern); public static GetFunctionColumns create(DbMetadataMediator mediator) { FirebirdSupportInfo firebirdSupportInfo = mediator.getFirebirdSupportInfo(); // NOTE: Indirection through static method prevents unnecessary classloading - if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { + if (firebirdSupportInfo.isVersionEqualOrAbove(6)) { + if (mediator.isUseCatalogAsPackage()) { + return FB6CatalogAsPackage.createInstance(mediator); + } + return FB6.createInstance(mediator); + } else if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { if (mediator.isUseCatalogAsPackage()) { return FB3CatalogAsPackage.createInstance(mediator); } @@ -135,31 +143,33 @@ public static GetFunctionColumns create(DbMetadataMediator mediator) { private static final class FB2_5 extends GetFunctionColumns { //@formatter:off - private static final String GET_FUNCTION_COLUMNS_FRAGMENT_2_5 = - "select\n" - + " null as FUNCTION_CAT,\n" - + " FUN.RDB$FUNCTION_NAME as FUNCTION_NAME,\n" - // Firebird 2.5 and earlier have no parameter name: derive one - + " 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION as COLUMN_NAME,\n" - + " FUNA.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" - + " FUNA.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" - + " FUNA.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" - + " FUNA.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" - + " FUNA.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" - + " FUNA.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" - + " FUNA.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" - + " case\n" - + " when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then 0\n" - + " else FUNA.RDB$ARGUMENT_POSITION\n" - + " end as ORDINAL_POSITION,\n" - + " case FUNA.RDB$MECHANISM\n" - + " when 0 then 'F'\n" - + " when 1 then 'F'\n" - + " else 'T'\n" - + " end as IS_NULLABLE\n" - + "from RDB$FUNCTIONS FUN\n" - + "inner join RDB$FUNCTION_ARGUMENTS FUNA\n" - + " on FUNA.RDB$FUNCTION_NAME = FUN.RDB$FUNCTION_NAME"; + private static final String GET_FUNCTION_COLUMNS_FRAGMENT_2_5 = """ + select + cast(null as char(1)) as FUNCTION_CAT, + cast(null as char(1)) as FUNCTION_SCHEM, + FUN.RDB$FUNCTION_NAME as FUNCTION_NAME, + -- Firebird 2.5 and earlier have no parameter name: derive one + 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION as COLUMN_NAME, + """ + + " FUNA.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " FUNA.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " FUNA.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " FUNA.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " FUNA.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " FUNA.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " FUNA.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" + """ + case + when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then 0 + else FUNA.RDB$ARGUMENT_POSITION + end as ORDINAL_POSITION, + case FUNA.RDB$MECHANISM + when 0 then 'F' + when 1 then 'F' + else 'T' + end as IS_NULLABLE + from RDB$FUNCTIONS FUN + inner join RDB$FUNCTION_ARGUMENTS FUNA + on FUNA.RDB$FUNCTION_NAME = FUN.RDB$FUNCTION_NAME"""; //@formatter:on private static final String GET_FUNCTION_COLUMNS_ORDER_BY_2_5 = """ @@ -178,53 +188,54 @@ private static GetFunctionColumns createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetFunctionColumnsQuery(String catalog, String functionNamePattern, + MetadataQuery createGetFunctionColumnsQuery(String catalog, String schemaPattern, String functionNamePattern, String columnNamePattern) { - Clause functionNameClause = new Clause("FUN.RDB$FUNCTION_NAME", functionNamePattern); - Clause columnNameClause = new Clause("'PARAM_' || FUNA.RDB$ARGUMENT_POSITION", columnNamePattern); + var clauses = List.of( + new Clause("FUN.RDB$FUNCTION_NAME", functionNamePattern), + new Clause("'PARAM_' || FUNA.RDB$ARGUMENT_POSITION", columnNamePattern)); String query = GET_FUNCTION_COLUMNS_FRAGMENT_2_5 - + (anyCondition(functionNameClause, columnNameClause) - ? "\nwhere " + functionNameClause.getCondition(columnNameClause.hasCondition()) - + columnNameClause.getCondition(false) - : "") + + (anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + GET_FUNCTION_COLUMNS_ORDER_BY_2_5; - return new MetadataQuery(query, Clause.parameters(functionNameClause, columnNameClause)); + return new MetadataQuery(query, Clause.parameters(clauses)); } + } private static final class FB3 extends GetFunctionColumns { //@formatter:off - private static final String GET_FUNCTION_COLUMNS_FRAGMENT_3 = - "select\n" - + " null as FUNCTION_CAT,\n" - + " trim(trailing from FUN.RDB$FUNCTION_NAME) as FUNCTION_NAME,\n" - + " -- legacy UDF and return value have no parameter name: derive one\n" - + " coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION) as COLUMN_NAME,\n" - + " coalesce(FUNA.RDB$FIELD_TYPE, F.RDB$FIELD_TYPE) as " + FIELD_TYPE + ",\n" - + " coalesce(FUNA.RDB$FIELD_SUB_TYPE, F.RDB$FIELD_SUB_TYPE) as " + FIELD_SUB_TYPE + ",\n" - + " coalesce(FUNA.RDB$FIELD_PRECISION, F.RDB$FIELD_PRECISION) as " + FIELD_PRECISION + ",\n" - + " coalesce(FUNA.RDB$FIELD_SCALE, F.RDB$FIELD_SCALE) as " + FIELD_SCALE + ",\n" - + " coalesce(FUNA.RDB$FIELD_LENGTH, F.RDB$FIELD_LENGTH) as " + FIELD_LENGTH + ",\n" - + " coalesce(FUNA.RDB$CHARACTER_LENGTH, F.RDB$CHARACTER_LENGTH) as " + CHAR_LEN + ",\n" - + " coalesce(FUNA.RDB$CHARACTER_SET_ID, F.RDB$CHARACTER_SET_ID) as " + CHARSET_ID + ",\n" - + " case\n" - + " when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then 0\n" - + " else FUNA.RDB$ARGUMENT_POSITION\n" - + " end as ORDINAL_POSITION,\n" - + " case \n" - + " when coalesce(FUNA.RDB$NULL_FLAG, F.RDB$NULL_FLAG) = 1 then false\n" - + " when FUNA.RDB$MECHANISM = 0 then false\n" - + " when FUNA.RDB$MECHANISM = 1 then false\n" - + " else true\n" - + " end as IS_NULLABLE\n" - + "from RDB$FUNCTIONS FUN\n" - + "inner join RDB$FUNCTION_ARGUMENTS FUNA\n" - + " on FUNA.RDB$FUNCTION_NAME = FUN.RDB$FUNCTION_NAME \n" - + " and FUNA.RDB$PACKAGE_NAME is not distinct from FUN.RDB$PACKAGE_NAME\n" - + "left join RDB$FIELDS F\n" - + " on F.RDB$FIELD_NAME = FUNA.RDB$FIELD_SOURCE\n" - + "where FUN.RDB$PACKAGE_NAME is null"; + private static final String GET_FUNCTION_COLUMNS_FRAGMENT_3 = """ + select + null as FUNCTION_CAT, + null as FUNCTION_SCHEM, + trim(trailing from FUN.RDB$FUNCTION_NAME) as FUNCTION_NAME, + -- legacy UDF and return value have no parameter name: derive one + trim(trailing from coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION)) as COLUMN_NAME, + """ + + " coalesce(FUNA.RDB$FIELD_TYPE, F.RDB$FIELD_TYPE) as " + FIELD_TYPE + ",\n" + + " coalesce(FUNA.RDB$FIELD_SUB_TYPE, F.RDB$FIELD_SUB_TYPE) as " + FIELD_SUB_TYPE + ",\n" + + " coalesce(FUNA.RDB$FIELD_PRECISION, F.RDB$FIELD_PRECISION) as " + FIELD_PRECISION + ",\n" + + " coalesce(FUNA.RDB$FIELD_SCALE, F.RDB$FIELD_SCALE) as " + FIELD_SCALE + ",\n" + + " coalesce(FUNA.RDB$FIELD_LENGTH, F.RDB$FIELD_LENGTH) as " + FIELD_LENGTH + ",\n" + + " coalesce(FUNA.RDB$CHARACTER_LENGTH, F.RDB$CHARACTER_LENGTH) as " + CHAR_LEN + ",\n" + + " coalesce(FUNA.RDB$CHARACTER_SET_ID, F.RDB$CHARACTER_SET_ID) as " + CHARSET_ID + ",\n" + """ + case + when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then 0 + else FUNA.RDB$ARGUMENT_POSITION + end as ORDINAL_POSITION, + case + when coalesce(FUNA.RDB$NULL_FLAG, F.RDB$NULL_FLAG) = 1 then false + when FUNA.RDB$MECHANISM = 0 then false + when FUNA.RDB$MECHANISM = 1 then false + else true + end as IS_NULLABLE + from RDB$FUNCTIONS FUN + inner join RDB$FUNCTION_ARGUMENTS FUNA + on FUNA.RDB$FUNCTION_NAME = FUN.RDB$FUNCTION_NAME + and FUNA.RDB$PACKAGE_NAME is not distinct from FUN.RDB$PACKAGE_NAME + left join RDB$FIELDS F + on F.RDB$FIELD_NAME = FUNA.RDB$FIELD_SOURCE + where FUN.RDB$PACKAGE_NAME is null"""; //@formatter:on // NOTE: Including RDB$PACKAGE_NAME so index can be used to sort @@ -245,51 +256,54 @@ private static GetFunctionColumns createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetFunctionColumnsQuery(String catalog, String functionNamePattern, + MetadataQuery createGetFunctionColumnsQuery(String catalog, String schemaPattern, String functionNamePattern, String columnNamePattern) { - Clause functionNameClause = new Clause("FUN.RDB$FUNCTION_NAME", functionNamePattern); - Clause columnNameClause = new Clause( - "coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION)", columnNamePattern); + var clauses = List.of( + new Clause("FUN.RDB$FUNCTION_NAME", functionNamePattern), + new Clause("coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION)", + columnNamePattern)); String query = GET_FUNCTION_COLUMNS_FRAGMENT_3 - + functionNameClause.getCondition("\nand ", "") - + columnNameClause.getCondition("\nand ", "") + + (Clause.anyCondition(clauses) ? "\nand " + Clause.conjunction(clauses) : "") + GET_FUNCTION_COLUMNS_ORDER_BY_3; - return new MetadataQuery(query, Clause.parameters(functionNameClause, columnNameClause)); + return new MetadataQuery(query, Clause.parameters(clauses)); } + } private static final class FB3CatalogAsPackage extends GetFunctionColumns { //@formatter:off - private static final String GET_FUNCTION_COLUMNS_FRAGMENT_3_W_PKG = - "select\n" - + " coalesce(trim(trailing from FUN.RDB$PACKAGE_NAME), '') as FUNCTION_CAT,\n" - + " trim(trailing from FUN.RDB$FUNCTION_NAME) as FUNCTION_NAME,\n" - + " -- legacy UDF and return value have no parameter name: derive one\n" - + " coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION) as COLUMN_NAME,\n" - + " coalesce(FUNA.RDB$FIELD_TYPE, F.RDB$FIELD_TYPE) as " + FIELD_TYPE + ",\n" - + " coalesce(FUNA.RDB$FIELD_SUB_TYPE, F.RDB$FIELD_SUB_TYPE) as " + FIELD_SUB_TYPE + ",\n" - + " coalesce(FUNA.RDB$FIELD_PRECISION, F.RDB$FIELD_PRECISION) as " + FIELD_PRECISION + ",\n" - + " coalesce(FUNA.RDB$FIELD_SCALE, F.RDB$FIELD_SCALE) as " + FIELD_SCALE + ",\n" - + " coalesce(FUNA.RDB$FIELD_LENGTH, F.RDB$FIELD_LENGTH) as " + FIELD_LENGTH + ",\n" - + " coalesce(FUNA.RDB$CHARACTER_LENGTH, F.RDB$CHARACTER_LENGTH) as " + CHAR_LEN + ",\n" - + " coalesce(FUNA.RDB$CHARACTER_SET_ID, F.RDB$CHARACTER_SET_ID) as " + CHARSET_ID + ",\n" - + " case\n" - + " when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then 0\n" - + " else FUNA.RDB$ARGUMENT_POSITION\n" - + " end as ORDINAL_POSITION,\n" - + " case \n" - + " when coalesce(FUNA.RDB$NULL_FLAG, F.RDB$NULL_FLAG) = 1 then false\n" - + " when FUNA.RDB$MECHANISM = 0 then false\n" - + " when FUNA.RDB$MECHANISM = 1 then false\n" - + " else true\n" - + " end as IS_NULLABLE\n" - + "from RDB$FUNCTIONS FUN\n" - + "inner join RDB$FUNCTION_ARGUMENTS FUNA\n" - + " on FUNA.RDB$FUNCTION_NAME = FUN.RDB$FUNCTION_NAME \n" - + " and FUNA.RDB$PACKAGE_NAME is not distinct from FUN.RDB$PACKAGE_NAME\n" - + "left join RDB$FIELDS F\n" - + " on F.RDB$FIELD_NAME = FUNA.RDB$FIELD_SOURCE"; + private static final String GET_FUNCTION_COLUMNS_FRAGMENT_3_W_PKG = """ + select + coalesce(trim(trailing from FUN.RDB$PACKAGE_NAME), '') as FUNCTION_CAT, + null as FUNCTION_SCHEM, + trim(trailing from FUN.RDB$FUNCTION_NAME) as FUNCTION_NAME, + -- legacy UDF and return value have no parameter name: derive one + trim(trailing from coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION)) as COLUMN_NAME, + """ + + " coalesce(FUNA.RDB$FIELD_TYPE, F.RDB$FIELD_TYPE) as " + FIELD_TYPE + ",\n" + + " coalesce(FUNA.RDB$FIELD_SUB_TYPE, F.RDB$FIELD_SUB_TYPE) as " + FIELD_SUB_TYPE + ",\n" + + " coalesce(FUNA.RDB$FIELD_PRECISION, F.RDB$FIELD_PRECISION) as " + FIELD_PRECISION + ",\n" + + " coalesce(FUNA.RDB$FIELD_SCALE, F.RDB$FIELD_SCALE) as " + FIELD_SCALE + ",\n" + + " coalesce(FUNA.RDB$FIELD_LENGTH, F.RDB$FIELD_LENGTH) as " + FIELD_LENGTH + ",\n" + + " coalesce(FUNA.RDB$CHARACTER_LENGTH, F.RDB$CHARACTER_LENGTH) as " + CHAR_LEN + ",\n" + + " coalesce(FUNA.RDB$CHARACTER_SET_ID, F.RDB$CHARACTER_SET_ID) as " + CHARSET_ID + ",\n" + """ + case + when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then 0 + else FUNA.RDB$ARGUMENT_POSITION + end as ORDINAL_POSITION, + case + when coalesce(FUNA.RDB$NULL_FLAG, F.RDB$NULL_FLAG) = 1 then false + when FUNA.RDB$MECHANISM = 0 then false + when FUNA.RDB$MECHANISM = 1 then false + else true + end as IS_NULLABLE + from RDB$FUNCTIONS FUN + inner join RDB$FUNCTION_ARGUMENTS FUNA + on FUNA.RDB$FUNCTION_NAME = FUN.RDB$FUNCTION_NAME + and FUNA.RDB$PACKAGE_NAME is not distinct from FUN.RDB$PACKAGE_NAME + left join RDB$FIELDS F + on F.RDB$FIELD_NAME = FUNA.RDB$FIELD_SOURCE"""; //@formatter:on // NOTE: Including RDB$PACKAGE_NAME so index can be used to sort @@ -309,7 +323,7 @@ private static GetFunctionColumns createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetFunctionColumnsQuery(String catalog, String functionNamePattern, + MetadataQuery createGetFunctionColumnsQuery(String catalog, String schemaPattern, String functionNamePattern, String columnNamePattern) { var clauses = new ArrayList(3); if (catalog != null) { @@ -327,12 +341,161 @@ MetadataQuery createGetFunctionColumnsQuery(String catalog, String functionNameP "coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION)", columnNamePattern)); //@formatter:off String sql = GET_FUNCTION_COLUMNS_FRAGMENT_3_W_PKG - + (Clause.anyCondition(clauses) - ? "\nwhere " + Clause.conjunction(clauses) - : "") + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + GET_FUNCTION_COLUMNS_ORDER_BY_3_W_PKG; //@formatter:on return new MetadataQuery(sql, Clause.parameters(clauses)); } + + } + + private static final class FB6 extends GetFunctionColumns { + + //@formatter:off + private static final String GET_FUNCTION_COLUMNS_FRAGMENT_6 = """ + select + null as FUNCTION_CAT, + trim(trailing from FUN.RDB$SCHEMA_NAME) as FUNCTION_SCHEM, + trim(trailing from FUN.RDB$FUNCTION_NAME) as FUNCTION_NAME, + -- legacy UDF and return value have no parameter name: derive one + trim(trailing from coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION)) as COLUMN_NAME, + """ + + " coalesce(FUNA.RDB$FIELD_TYPE, F.RDB$FIELD_TYPE) as " + FIELD_TYPE + ",\n" + + " coalesce(FUNA.RDB$FIELD_SUB_TYPE, F.RDB$FIELD_SUB_TYPE) as " + FIELD_SUB_TYPE + ",\n" + + " coalesce(FUNA.RDB$FIELD_PRECISION, F.RDB$FIELD_PRECISION) as " + FIELD_PRECISION + ",\n" + + " coalesce(FUNA.RDB$FIELD_SCALE, F.RDB$FIELD_SCALE) as " + FIELD_SCALE + ",\n" + + " coalesce(FUNA.RDB$FIELD_LENGTH, F.RDB$FIELD_LENGTH) as " + FIELD_LENGTH + ",\n" + + " coalesce(FUNA.RDB$CHARACTER_LENGTH, F.RDB$CHARACTER_LENGTH) as " + CHAR_LEN + ",\n" + + " coalesce(FUNA.RDB$CHARACTER_SET_ID, F.RDB$CHARACTER_SET_ID) as " + CHARSET_ID + ",\n" + """ + case + when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then 0 + else FUNA.RDB$ARGUMENT_POSITION + end as ORDINAL_POSITION, + case + when coalesce(FUNA.RDB$NULL_FLAG, F.RDB$NULL_FLAG) = 1 then false + when FUNA.RDB$MECHANISM = 0 then false + when FUNA.RDB$MECHANISM = 1 then false + else true + end as IS_NULLABLE + from SYSTEM.RDB$FUNCTIONS FUN + inner join SYSTEM.RDB$FUNCTION_ARGUMENTS FUNA + on FUNA.RDB$SCHEMA_NAME = FUN.RDB$SCHEMA_NAME and FUNA.RDB$FUNCTION_NAME = FUN.RDB$FUNCTION_NAME + and FUNA.RDB$PACKAGE_NAME is not distinct from FUN.RDB$PACKAGE_NAME + left join SYSTEM.RDB$FIELDS F + on F.RDB$SCHEMA_NAME = FUNA.RDB$FIELD_SOURCE_SCHEMA_NAME and F.RDB$FIELD_NAME = FUNA.RDB$FIELD_SOURCE + where FUN.RDB$PACKAGE_NAME is null"""; + //@formatter:on + + // NOTE: Including RDB$PACKAGE_NAME so index can be used to sort + private static final String GET_FUNCTION_COLUMNS_ORDER_BY_6 = """ + \norder by FUN.RDB$SCHEMA_NAME, FUN.RDB$PACKAGE_NAME, FUN.RDB$FUNCTION_NAME, + case + when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then -1 + else FUNA.RDB$ARGUMENT_POSITION + end"""; + + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetFunctionColumns createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetFunctionColumnsQuery(String catalog, String schemaPattern, String functionNamePattern, + String columnNamePattern) { + var clauses = List.of( + new Clause("FUN.RDB$SCHEMA_NAME", schemaPattern), + new Clause("FUN.RDB$FUNCTION_NAME", functionNamePattern), + new Clause("coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION)", + columnNamePattern)); + String query = GET_FUNCTION_COLUMNS_FRAGMENT_6 + + (Clause.anyCondition(clauses) ? "\nand " + Clause.conjunction(clauses) : "") + + GET_FUNCTION_COLUMNS_ORDER_BY_6; + return new MetadataQuery(query, Clause.parameters(clauses)); + } + + } + + private static final class FB6CatalogAsPackage extends GetFunctionColumns { + + //@formatter:off + private static final String GET_FUNCTION_COLUMNS_FRAGMENT_6_W_PKG = """ + select + coalesce(trim(trailing from FUN.RDB$PACKAGE_NAME), '') as FUNCTION_CAT, + trim(trailing from FUN.RDB$SCHEMA_NAME) as FUNCTION_SCHEM, + trim(trailing from FUN.RDB$FUNCTION_NAME) as FUNCTION_NAME, + -- legacy UDF and return value have no parameter name: derive one + trim(trailing from coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION)) as COLUMN_NAME, + """ + + " coalesce(FUNA.RDB$FIELD_TYPE, F.RDB$FIELD_TYPE) as " + FIELD_TYPE + ",\n" + + " coalesce(FUNA.RDB$FIELD_SUB_TYPE, F.RDB$FIELD_SUB_TYPE) as " + FIELD_SUB_TYPE + ",\n" + + " coalesce(FUNA.RDB$FIELD_PRECISION, F.RDB$FIELD_PRECISION) as " + FIELD_PRECISION + ",\n" + + " coalesce(FUNA.RDB$FIELD_SCALE, F.RDB$FIELD_SCALE) as " + FIELD_SCALE + ",\n" + + " coalesce(FUNA.RDB$FIELD_LENGTH, F.RDB$FIELD_LENGTH) as " + FIELD_LENGTH + ",\n" + + " coalesce(FUNA.RDB$CHARACTER_LENGTH, F.RDB$CHARACTER_LENGTH) as " + CHAR_LEN + ",\n" + + " coalesce(FUNA.RDB$CHARACTER_SET_ID, F.RDB$CHARACTER_SET_ID) as " + CHARSET_ID + ",\n" + """ + case + when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then 0 + else FUNA.RDB$ARGUMENT_POSITION + end as ORDINAL_POSITION, + case + when coalesce(FUNA.RDB$NULL_FLAG, F.RDB$NULL_FLAG) = 1 then false + when FUNA.RDB$MECHANISM = 0 then false + when FUNA.RDB$MECHANISM = 1 then false + else true + end as IS_NULLABLE + from SYSTEM.RDB$FUNCTIONS FUN + inner join SYSTEM.RDB$FUNCTION_ARGUMENTS FUNA + on FUNA.RDB$SCHEMA_NAME = FUN.RDB$SCHEMA_NAME and FUNA.RDB$FUNCTION_NAME = FUN.RDB$FUNCTION_NAME + and FUNA.RDB$PACKAGE_NAME is not distinct from FUN.RDB$PACKAGE_NAME + left join SYSTEM.RDB$FIELDS F + on F.RDB$SCHEMA_NAME = FUNA.RDB$FIELD_SOURCE_SCHEMA_NAME and F.RDB$FIELD_NAME = FUNA.RDB$FIELD_SOURCE"""; + //@formatter:on + + private static final String GET_FUNCTION_COLUMNS_ORDER_BY_6_W_PKG = """ + \norder by FUN.RDB$PACKAGE_NAME nulls first, FUN.RDB$SCHEMA_NAME, FUN.RDB$FUNCTION_NAME, + case + when FUN.RDB$RETURN_ARGUMENT = FUNA.RDB$ARGUMENT_POSITION then -1 + else FUNA.RDB$ARGUMENT_POSITION + end"""; + + private FB6CatalogAsPackage(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetFunctionColumns createInstance(DbMetadataMediator mediator) { + return new FB6CatalogAsPackage(mediator); + } + + @Override + MetadataQuery createGetFunctionColumnsQuery(String catalog, String schemaPattern, String functionNamePattern, + String columnNamePattern) { + var clauses = new ArrayList(4); + clauses.add(new Clause("FUN.RDB$SCHEMA_NAME", schemaPattern)); + if (catalog != null) { + // To quote from the JDBC API: "" retrieves those without a catalog; null means that the catalog name + // should not be used to narrow the search + if (catalog.isEmpty()) { + clauses.add(Clause.isNullClause("FUN.RDB$PACKAGE_NAME")); + } else { + // Exact matches only + clauses.add(Clause.equalsClause("FUN.RDB$PACKAGE_NAME", catalog)); + } + } + clauses.add(new Clause("FUN.RDB$FUNCTION_NAME", functionNamePattern)); + clauses.add(new Clause( + "coalesce(FUNA.RDB$ARGUMENT_NAME, 'PARAM_' || FUNA.RDB$ARGUMENT_POSITION)", columnNamePattern)); + //@formatter:off + String sql = GET_FUNCTION_COLUMNS_FRAGMENT_6_W_PKG + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + + GET_FUNCTION_COLUMNS_ORDER_BY_6_W_PKG; + //@formatter:on + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java index d0d175490..d08b24349 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2019-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2019-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -22,9 +22,10 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultPropertiesForConnection; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.jdbc.FBDatabaseMetaDataFunctionsTest.isIgnoredFunction; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.*; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -353,7 +354,7 @@ void testFunctionColumnMetaData_useCatalogAsPackage_specificPackageProcedureColu try (var connection = DriverManager.getConnection(getUrl(), props)) { dbmd = connection.getMetaData(); List> expectedColumns = withCatalog("WITH$FUNCTION", - withSpecificName("\"WITH$FUNCTION\".\"IN$PACKAGE\"", + withSpecificName(ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$FUNCTION\".\"IN$PACKAGE\"", List.of(createNumericalType(Types.INTEGER, "IN$PACKAGE", "PARAM1", 1, 10, 0, true)))); validateExpectedFunctionColumns(catalog, "IN$PACKAGE", "PARAM1", expectedColumns); } @@ -386,7 +387,13 @@ private void validateExpectedFunctionColumns(String functionNamePattern, String private void validateExpectedFunctionColumns(String catalog, String functionNamePattern, String columnNamePattern, List> expectedColumns) throws Exception { - try (ResultSet columns = dbmd.getFunctionColumns(catalog, null, functionNamePattern, columnNamePattern)) { + validateExpectedFunctionColumns(catalog, null, functionNamePattern, columnNamePattern, expectedColumns); + } + + private void validateExpectedFunctionColumns(String catalog, String schemaPattern, String functionNamePattern, + String columnNamePattern, List> expectedColumns) throws Exception { + try (ResultSet columns = dbmd.getFunctionColumns( + catalog, schemaPattern, functionNamePattern, columnNamePattern)) { for (Map expectedColumn : expectedColumns) { expectNextFunctionColumn(columns); getFunctionColumnsDefinition.validateRowValues(columns, expectedColumn); @@ -397,7 +404,7 @@ private void validateExpectedFunctionColumns(String catalog, String functionName private static void expectNextFunctionColumn(ResultSet rs) throws SQLException { do { - assertTrue(rs.next(), "Expected a row"); + assertNextRow(rs); } while (isIgnoredFunction(rs.getString("SPECIFIC_NAME"))); } @@ -477,7 +484,7 @@ private static List> getUdfExample2Columns() private static List> getWithFunctionInPackageColumns() { return withCatalog("WITH$FUNCTION", - withSpecificName("\"WITH$FUNCTION\".\"IN$PACKAGE\"", + withSpecificName(ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$FUNCTION\".\"IN$PACKAGE\"", List.of( withColumnTypeFunctionReturn( createNumericalType(Types.INTEGER, "IN$PACKAGE", "PARAM_0", 0, 10, 0, true)), @@ -510,7 +517,8 @@ private static Map createColumn(String functionN int ordinalPosition, boolean nullable) { Map rules = getDefaultValidationRules(); rules.put(FunctionColumnMetaData.FUNCTION_NAME, functionName); - rules.put(FunctionColumnMetaData.SPECIFIC_NAME, functionName); + rules.put(FunctionColumnMetaData.SPECIFIC_NAME, ifSchemaElse( + "\"PUBLIC\"." + QuoteStrategy.DIALECT_3.quoteObjectName(functionName), functionName)); rules.put(FunctionColumnMetaData.COLUMN_NAME, columnName); rules.put(FunctionColumnMetaData.ORDINAL_POSITION, ordinalPosition); if (nullable) { @@ -674,7 +682,7 @@ private static Map createDecfloat(String functio static { Map defaults = new EnumMap<>(FunctionColumnMetaData.class); defaults.put(FunctionColumnMetaData.FUNCTION_CAT, null); - defaults.put(FunctionColumnMetaData.FUNCTION_SCHEM, null); + defaults.put(FunctionColumnMetaData.FUNCTION_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(FunctionColumnMetaData.PRECISION, null); defaults.put(FunctionColumnMetaData.SCALE, null); defaults.put(FunctionColumnMetaData.RADIX, 10); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java index fa5ff223f..91d2688e2 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2019-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2019-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -338,9 +338,10 @@ class Ignored { // Skipping RDB$GET_CONTEXT and RDB$SET_CONTEXT as that seems to be an implementation artifact: // present in FB 2.5, absent in FB 3.0 private static final Set FUNCTIONS_TO_IGNORE = Set.of("RDB$GET_CONTEXT", "RDB$SET_CONTEXT"); - // Also skipping functions from system packages (when testing with useCatalogAsPackage=true) + // Also skipping functions from system packages (when testing with useCatalogAsPackage=true), + // and schema SYSTEM (Firebird 6+) private static final List PREFIXES_TO_IGNORE = - List.of("\"RDB$BLOB_UTIL\".", "\"RDB$PROFILER\".", "\"RDB$TIME_ZONE_UTIL\"."); + List.of("\"SYSTEM\".\"RDB$", "\"RDB$BLOB_UTIL\".", "\"RDB$PROFILER\".", "\"RDB$TIME_ZONE_UTIL\"."); } if (Ignored.FUNCTIONS_TO_IGNORE.contains(specificName)) return true; return Ignored.PREFIXES_TO_IGNORE.stream().anyMatch(specificName::startsWith); From a5d8434595cb55d70e2f01be4e7842ec4206889c Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 22 Jun 2025 13:36:53 +0200 Subject: [PATCH 13/64] #882 Schema support for getFunctions --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 2 +- .../jdbc/metadata/GetFunctions.java | 146 +++++++++++++++--- ...FBDatabaseMetaDataFunctionColumnsTest.java | 2 + .../jdbc/FBDatabaseMetaDataFunctionsTest.java | 11 +- 4 files changed, 136 insertions(+), 25 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 043d311db..b7961796f 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1780,7 +1780,7 @@ public ResultSet getFunctionColumns(String catalog, String schemaPattern, String @Override public ResultSet getFunctions(String catalog, String schemaPattern, String functionNamePattern) throws SQLException { - return GetFunctions.create(getDbMetadataMediator()).getFunctions(catalog, functionNamePattern); + return GetFunctions.create(getDbMetadataMediator()).getFunctions(catalog, schemaPattern, functionNamePattern); } @Override diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java b/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java index ea9243cf3..9f6c0dfba 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2019-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2019-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -13,6 +13,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.List; import static java.sql.DatabaseMetaData.functionNoTable; import static org.firebirdsql.gds.ISCConstants.SQL_SHORT; @@ -30,8 +31,10 @@ public abstract class GetFunctions extends AbstractMetadataMethod { private static final String FUNCTIONS = "FUNCTIONS"; + private static final String COLUMN_CATALOG_NAME = "RDB$PACKAGE_NAME"; + private static final String COLUMN_SCHEMA_NAME = "RDB$SCHEMA_NAME"; private static final String COLUMN_FUNCTION_NAME = "RDB$FUNCTION_NAME"; - + private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(11) .at(0).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "FUNCTION_CAT", FUNCTIONS).addField() .at(1).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "FUNCTION_SCHEM", FUNCTIONS).addField() @@ -56,27 +59,29 @@ private GetFunctions(DbMetadataMediator mediator) { /** * @see java.sql.DatabaseMetaData#getFunctions(String, String, String) */ - public final ResultSet getFunctions(String catalog, String functionNamePattern) throws SQLException { + public final ResultSet getFunctions(String catalog, String schemaPattern, String functionNamePattern) + throws SQLException { if ("".equals(functionNamePattern)) { // Matching function name not possible return createEmpty(); } - MetadataQuery metadataQuery = createGetFunctionsQuery(catalog, functionNamePattern); + MetadataQuery metadataQuery = createGetFunctionsQuery(catalog, schemaPattern, functionNamePattern); return createMetaDataResultSet(metadataQuery); } @Override final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { String catalog = rs.getString("FUNCTION_CAT"); + String schema = rs.getString("FUNCTION_SCHEM"); String functionName = rs.getString("FUNCTION_NAME"); return valueBuilder .at(0).setString(catalog) - .at(1).set(null) + .at(1).setString(schema) .at(2).setString(functionName) .at(3).setString(rs.getString("REMARKS")) .at(4).setShort(functionNoTable) - .at(5).setString(toSpecificName(catalog, functionName)) + .at(5).setString(toSpecificName(catalog, schema, functionName)) .at(6).setString(rs.getString("JB_FUNCTION_SOURCE")) .at(7).setString(rs.getString("JB_FUNCTION_KIND")) .at(8).setString(rs.getString("JB_MODULE_NAME")) @@ -85,7 +90,7 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .toRowValue(false); } - abstract MetadataQuery createGetFunctionsQuery(String catalog, String functionNamePattern); + abstract MetadataQuery createGetFunctionsQuery(String catalog, String schemaPattern, String functionNamePattern); /** * Creates an instance of {@code GetFunctions}. @@ -97,7 +102,12 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr public static GetFunctions create(DbMetadataMediator mediator) { FirebirdSupportInfo firebirdSupportInfo = mediator.getFirebirdSupportInfo(); // NOTE: Indirection through static method prevents unnecessary classloading - if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { + if (firebirdSupportInfo.isVersionEqualOrAbove(6)) { + if (mediator.isUseCatalogAsPackage()) { + return FB6CatalogAsPackage.createInstance(mediator); + } + return FB6.createInstance(mediator); + } else if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { if (mediator.isUseCatalogAsPackage()) { return FB3CatalogAsPackage.createInstance(mediator); } @@ -115,7 +125,8 @@ private static final class FB2_5 extends GetFunctions { private static final String GET_FUNCTIONS_FRAGMENT_2_5 = """ select - null as FUNCTION_CAT, + cast(null as char(1)) as FUNCTION_CAT, + cast(null as char(1)) as FUNCTION_SCHEM, RDB$FUNCTION_NAME as FUNCTION_NAME, RDB$DESCRIPTION as REMARKS, cast(null as blob sub_type text) as JB_FUNCTION_SOURCE, @@ -137,7 +148,7 @@ private static GetFunctions createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetFunctionsQuery(String catalog, String functionNamePattern) { + MetadataQuery createGetFunctionsQuery(String catalog, String schemaPattern, String functionNamePattern) { Clause functionNameClause = new Clause(COLUMN_FUNCTION_NAME, functionNamePattern); String queryText = GET_FUNCTIONS_FRAGMENT_2_5 + functionNameClause.getCondition("\nwhere ", "") @@ -154,6 +165,7 @@ private static final class FB3 extends GetFunctions { private static final String GET_FUNCTIONS_FRAGMENT_3 = """ select null as FUNCTION_CAT, + null as FUNCTION_SCHEM, trim(trailing from RDB$FUNCTION_NAME) as FUNCTION_NAME, RDB$DESCRIPTION as REMARKS, RDB$FUNCTION_SOURCE as JB_FUNCTION_SOURCE, @@ -180,7 +192,7 @@ private static GetFunctions createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetFunctionsQuery(String catalog, String functionNamePattern) { + MetadataQuery createGetFunctionsQuery(String catalog, String schemaPattern, String functionNamePattern) { Clause functionNameClause = new Clause(COLUMN_FUNCTION_NAME, functionNamePattern); String queryText = GET_FUNCTIONS_FRAGMENT_3 + functionNameClause.getCondition("\nand ", "") @@ -194,6 +206,7 @@ private static final class FB3CatalogAsPackage extends GetFunctions { private static final String GET_FUNCTIONS_FRAGMENT_3_W_PKG = """ select coalesce(trim(trailing from RDB$PACKAGE_NAME), '') as FUNCTION_CAT, + null as FUNCTION_SCHEM, trim(trailing from RDB$FUNCTION_NAME) as FUNCTION_NAME, RDB$DESCRIPTION as REMARKS, RDB$FUNCTION_SOURCE as JB_FUNCTION_SOURCE, @@ -210,8 +223,6 @@ private static final class FB3CatalogAsPackage extends GetFunctions { private static final String GET_FUNCTIONS_ORDER_BY_3_W_PKG = "\norder by RDB$PACKAGE_NAME nulls first, RDB$FUNCTION_NAME"; - private static final String COLUMN_CATALOG_NAME = "RDB$PACKAGE_NAME"; - private FB3CatalogAsPackage(DbMetadataMediator mediator) { super(mediator); } @@ -221,7 +232,7 @@ private static GetFunctions createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetFunctionsQuery(String catalog, String functionNamePattern) { + MetadataQuery createGetFunctionsQuery(String catalog, String schemaPattern, String functionNamePattern) { var clauses = new ArrayList(2); if (catalog != null) { // To quote from the JDBC API: "" retrieves those without a catalog; null means that the catalog name @@ -234,14 +245,109 @@ MetadataQuery createGetFunctionsQuery(String catalog, String functionNamePattern } } clauses.add(new Clause(COLUMN_FUNCTION_NAME, functionNamePattern)); - //@formatter:off String sql = GET_FUNCTIONS_FRAGMENT_3_W_PKG - + (Clause.anyCondition(clauses) - ? "\nwhere " + Clause.conjunction(clauses) - : "") + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + GET_FUNCTIONS_ORDER_BY_3_W_PKG; - //@formatter:on return new MetadataQuery(sql, Clause.parameters(clauses)); } } + + private static final class FB6 extends GetFunctions { + + private static final String GET_FUNCTIONS_FRAGMENT_6 = """ + select + null as FUNCTION_CAT, + trim(trailing from RDB$SCHEMA_NAME) as FUNCTION_SCHEM, + trim(trailing from RDB$FUNCTION_NAME) as FUNCTION_NAME, + RDB$DESCRIPTION as REMARKS, + RDB$FUNCTION_SOURCE as JB_FUNCTION_SOURCE, + case + when RDB$LEGACY_FLAG = 1 then 'UDF' + when RDB$ENGINE_NAME is not null then 'UDR' + else 'PSQL' + end as JB_FUNCTION_KIND, + trim(trailing from RDB$MODULE_NAME) as JB_MODULE_NAME, + trim(trailing from RDB$ENTRYPOINT) as JB_ENTRYPOINT, + trim(trailing from RDB$ENGINE_NAME) as JB_ENGINE_NAME + from SYSTEM.RDB$FUNCTIONS + where RDB$PACKAGE_NAME is null"""; + + // NOTE: Including RDB$PACKAGE_NAME so index can be used to sort + private static final String GET_FUNCTIONS_ORDER_BY_6 = + "\norder by RDB$SCHEMA_NAME, RDB$PACKAGE_NAME, RDB$FUNCTION_NAME"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetFunctions createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetFunctionsQuery(String catalog, String schemaPattern, String functionNamePattern) { + var clauses = List.of( + new Clause(COLUMN_SCHEMA_NAME, schemaPattern), + new Clause(COLUMN_FUNCTION_NAME, functionNamePattern)); + String queryText = GET_FUNCTIONS_FRAGMENT_6 + + (Clause.anyCondition(clauses) ? "\nand " + Clause.conjunction(clauses) : "") + + GET_FUNCTIONS_ORDER_BY_6; + return new MetadataQuery(queryText, Clause.parameters(clauses)); + } + + } + + private static final class FB6CatalogAsPackage extends GetFunctions { + + private static final String GET_FUNCTIONS_FRAGMENT_6_W_PKG = """ + select + coalesce(trim(trailing from RDB$PACKAGE_NAME), '') as FUNCTION_CAT, + trim(trailing from RDB$SCHEMA_NAME) as FUNCTION_SCHEM, + trim(trailing from RDB$FUNCTION_NAME) as FUNCTION_NAME, + RDB$DESCRIPTION as REMARKS, + RDB$FUNCTION_SOURCE as JB_FUNCTION_SOURCE, + case + when RDB$LEGACY_FLAG = 1 then 'UDF' + when RDB$ENGINE_NAME is not null then 'UDR' + else 'PSQL' + end as JB_FUNCTION_KIND, + trim(trailing from RDB$MODULE_NAME) as JB_MODULE_NAME, + trim(trailing from RDB$ENTRYPOINT) as JB_ENTRYPOINT, + trim(trailing from RDB$ENGINE_NAME) as JB_ENGINE_NAME + from SYSTEM.RDB$FUNCTIONS"""; + + private static final String GET_FUNCTIONS_ORDER_BY_6_W_PKG = + "\norder by RDB$PACKAGE_NAME nulls first, RDB$SCHEMA_NAME, RDB$FUNCTION_NAME"; + + private FB6CatalogAsPackage(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetFunctions createInstance(DbMetadataMediator mediator) { + return new FB6CatalogAsPackage(mediator); + } + + @Override + MetadataQuery createGetFunctionsQuery(String catalog, String schemaPattern, String functionNamePattern) { + var clauses = new ArrayList(3); + clauses.add(new Clause(COLUMN_SCHEMA_NAME, schemaPattern)); + if (catalog != null) { + // To quote from the JDBC API: "" retrieves those without a catalog; null means that the catalog name + // should not be used to narrow the search + if (catalog.isEmpty()) { + clauses.add(Clause.isNullClause(COLUMN_CATALOG_NAME)); + } else { + // Exact matches only + clauses.add(Clause.equalsClause(COLUMN_CATALOG_NAME, catalog)); + } + } + clauses.add(new Clause(COLUMN_FUNCTION_NAME, functionNamePattern)); + String sql = GET_FUNCTIONS_FRAGMENT_6_W_PKG + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + + GET_FUNCTIONS_ORDER_BY_6_W_PKG; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + + } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java index d08b24349..9d20e0a40 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java @@ -36,6 +36,8 @@ */ class FBDatabaseMetaDataFunctionColumnsTest { + // TODO Add schema support: tests involving other schema + private static final String PSQL_EXAMPLE_1 = "PSQL$EXAMPLE$1"; private static final String PSQL_EXAMPLE_2 = "PSQL$EXAMPLE$2"; private static final String UDF_EXAMPLE_1 = "UDF$EXAMPLE$1"; diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java index 91d2688e2..ccd821777 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java @@ -24,6 +24,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultPropertiesForConnection; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -36,6 +37,8 @@ */ class FBDatabaseMetaDataFunctionsTest { + // TODO Add schema support: tests involving other schema + private static final String CREATE_UDF_EXAMPLE = """ declare external function UDF$EXAMPLE int by descriptor, int by descriptor @@ -274,7 +277,7 @@ private void validatePsqlExample(ResultSet functions, boolean useCatalogAsPackag rules.put(FunctionMetaData.FUNCTION_CAT, ""); } rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, "PSQL$EXAMPLE"); + rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse("\"PUBLIC\".\"PSQL$EXAMPLE\"", "PSQL$EXAMPLE")); if (supportsComments) { rules.put(FunctionMetaData.REMARKS, "Comment on PSQL$EXAMPLE"); } @@ -298,7 +301,7 @@ private void validateUdfExample(ResultSet functions, boolean useCatalogAsPackage rules.put(FunctionMetaData.FUNCTION_CAT, ""); } rules.put(FunctionMetaData.FUNCTION_NAME, "UDF$EXAMPLE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, "UDF$EXAMPLE"); + rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse("\"PUBLIC\".\"UDF$EXAMPLE\"", "UDF$EXAMPLE")); if (supportsComments) { rules.put(FunctionMetaData.REMARKS, "Comment on UDF$EXAMPLE"); } @@ -312,7 +315,7 @@ private void validatePackageFunctionExample(ResultSet functions) throws SQLExcep Map rules = getDefaultValidationRules(); rules.put(FunctionMetaData.FUNCTION_CAT, "WITH$FUNCTION"); rules.put(FunctionMetaData.FUNCTION_NAME, "IN$PACKAGE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, "\"WITH$FUNCTION\".\"IN$PACKAGE\""); + rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$FUNCTION\".\"IN$PACKAGE\""); // Stored with package rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, null); rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); @@ -351,7 +354,7 @@ class Ignored { static { Map defaults = new EnumMap<>(FunctionMetaData.class); defaults.put(FunctionMetaData.FUNCTION_CAT, null); - defaults.put(FunctionMetaData.FUNCTION_SCHEM, null); + defaults.put(FunctionMetaData.FUNCTION_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(FunctionMetaData.REMARKS, null); defaults.put(FunctionMetaData.FUNCTION_TYPE, (short) DatabaseMetaData.functionNoTable); defaults.put(FunctionMetaData.JB_FUNCTION_SOURCE, null); From c08df8bec4cdb3232e1147146e0fbaab91cbb801 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 22 Jun 2025 13:56:10 +0200 Subject: [PATCH 14/64] #882 Schema support for getIndexInfo --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 2 +- .../jdbc/metadata/GetIndexInfo.java | 75 ++++++++++++++++--- .../jdbc/FBDatabaseMetaDataIndexInfoTest.java | 16 +++- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index b7961796f..05511c9c0 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1497,7 +1497,7 @@ public ResultSet getTypeInfo() throws SQLException { @Override public ResultSet getIndexInfo(String catalog, String schema, String table, boolean unique, boolean approximate) throws SQLException { - return GetIndexInfo.create(getDbMetadataMediator()).getIndexInfo(table, unique, approximate); + return GetIndexInfo.create(getDbMetadataMediator()).getIndexInfo(schema, table, unique, approximate); } @Override diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetIndexInfo.java b/src/main/org/firebirdsql/jdbc/metadata/GetIndexInfo.java index 09f59e071..40ddce9df 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetIndexInfo.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetIndexInfo.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -12,6 +12,7 @@ import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import static org.firebirdsql.gds.ISCConstants.SQL_LONG; import static org.firebirdsql.gds.ISCConstants.SQL_SHORT; @@ -53,22 +54,22 @@ private GetIndexInfo(DbMetadataMediator mediator) { } @SuppressWarnings("unused") - public ResultSet getIndexInfo(String table, boolean unique, boolean approximate) throws SQLException { + public ResultSet getIndexInfo(String schema, String table, boolean unique, boolean approximate) throws SQLException { if (isNullOrEmpty(table)) { return createEmpty(); } - MetadataQuery metadataQuery = createIndexInfoQuery(table, unique); + MetadataQuery metadataQuery = createIndexInfoQuery(schema, table, unique); return createMetaDataResultSet(metadataQuery); } - abstract MetadataQuery createIndexInfoQuery(String table, boolean unique); + abstract MetadataQuery createIndexInfoQuery(String schema, String table, boolean unique); @Override RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { valueBuilder .at(0).set(null) - .at(1).set(null) + .at(1).setString(rs.getString("TABLE_SCHEM")) .at(2).setString(rs.getString("TABLE_NAME")) .at(3).setString(rs.getInt("UNIQUE_FLAG") == 0 ? "T" : "F") .at(4).set(null) @@ -107,7 +108,9 @@ RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQ public static GetIndexInfo create(DbMetadataMediator mediator) { // NOTE: Indirection through static method prevents unnecessary classloading - if (mediator.getOdsVersion().compareTo(ODS_13_1) >= 0) { + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else if (mediator.getOdsVersion().compareTo(ODS_13_1) >= 0) { return FB5.createInstance(mediator); } else { return FB2_5.createInstance(mediator); @@ -118,6 +121,7 @@ private static final class FB2_5 extends GetIndexInfo { private static final String GET_INDEX_INFO_START_2_5 = """ select + cast(null as char(1)) as TABLE_SCHEM, IND.RDB$RELATION_NAME as TABLE_NAME, IND.RDB$UNIQUE_FLAG as UNIQUE_FLAG, IND.RDB$INDEX_NAME as INDEX_NAME, @@ -127,7 +131,8 @@ private static final class FB2_5 extends GetIndexInfo { IND.RDB$INDEX_TYPE as ASC_OR_DESC, null as CONDITION_SOURCE from RDB$INDICES IND - left join RDB$INDEX_SEGMENTS ISE on IND.RDB$INDEX_NAME = ISE.RDB$INDEX_NAME where\s"""; + left join RDB$INDEX_SEGMENTS ISE on IND.RDB$INDEX_NAME = ISE.RDB$INDEX_NAME + where\s"""; private static final String GET_INDEX_INFO_END_2_5 = "\norder by IND.RDB$UNIQUE_FLAG, IND.RDB$INDEX_NAME, ISE.RDB$FIELD_POSITION"; @@ -141,7 +146,7 @@ private static GetIndexInfo createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createIndexInfoQuery(String table, boolean unique) { + MetadataQuery createIndexInfoQuery(String schema, String table, boolean unique) { Clause tableClause = Clause.equalsClause("IND.RDB$RELATION_NAME", table); String sql = GET_INDEX_INFO_START_2_5 + tableClause.getCondition(unique) @@ -156,6 +161,7 @@ private static final class FB5 extends GetIndexInfo { private static final String GET_INDEX_INFO_START_5 = """ select + null as TABLE_SCHEM, trim(trailing from IND.RDB$RELATION_NAME) as TABLE_NAME, IND.RDB$UNIQUE_FLAG as UNIQUE_FLAG, trim(trailing from IND.RDB$INDEX_NAME) as INDEX_NAME, @@ -165,7 +171,8 @@ private static final class FB5 extends GetIndexInfo { IND.RDB$INDEX_TYPE as ASC_OR_DESC, IND.RDB$CONDITION_SOURCE as CONDITION_SOURCE from RDB$INDICES IND - left join RDB$INDEX_SEGMENTS ISE on IND.RDB$INDEX_NAME = ISE.RDB$INDEX_NAME where\s"""; + left join RDB$INDEX_SEGMENTS ISE on IND.RDB$INDEX_NAME = ISE.RDB$INDEX_NAME + where\s"""; private static final String GET_INDEX_INFO_END_5 = "\norder by IND.RDB$UNIQUE_FLAG, IND.RDB$INDEX_NAME, ISE.RDB$FIELD_POSITION"; @@ -179,7 +186,7 @@ private static GetIndexInfo createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createIndexInfoQuery(String table, boolean unique) { + MetadataQuery createIndexInfoQuery(String schema, String table, boolean unique) { Clause tableClause = Clause.equalsClause("IND.RDB$RELATION_NAME", table); String sql = GET_INDEX_INFO_START_5 + tableClause.getCondition(unique) @@ -190,4 +197,50 @@ MetadataQuery createIndexInfoQuery(String table, boolean unique) { } + private static final class FB6 extends GetIndexInfo { + + private static final String GET_INDEX_INFO_START_6 = """ + select + trim(trailing from IND.RDB$SCHEMA_NAME) as TABLE_SCHEM, + trim(trailing from IND.RDB$RELATION_NAME) as TABLE_NAME, + IND.RDB$UNIQUE_FLAG as UNIQUE_FLAG, + trim(trailing from IND.RDB$INDEX_NAME) as INDEX_NAME, + ISE.RDB$FIELD_POSITION + 1 as ORDINAL_POSITION, + trim(trailing from ISE.RDB$FIELD_NAME) as COLUMN_NAME, + IND.RDB$EXPRESSION_SOURCE as EXPRESSION_SOURCE, + IND.RDB$INDEX_TYPE as ASC_OR_DESC, + IND.RDB$CONDITION_SOURCE as CONDITION_SOURCE + from SYSTEM.RDB$INDICES IND + left join SYSTEM.RDB$INDEX_SEGMENTS ISE + on IND.RDB$SCHEMA_NAME = ISE.RDB$SCHEMA_NAME and IND.RDB$INDEX_NAME = ISE.RDB$INDEX_NAME + where\s"""; + + private static final String GET_INDEX_INFO_END_6 = + "\norder by IND.RDB$UNIQUE_FLAG, IND.RDB$INDEX_NAME, ISE.RDB$FIELD_POSITION"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetIndexInfo createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createIndexInfoQuery(String schema, String table, boolean unique) { + var clauses = new ArrayList(2); + if (schema != null) { + // NOTE: empty string will return no rows as required ("" retrieves those without a schema) + clauses.add(Clause.equalsClause("IND.RDB$SCHEMA_NAME", schema)); + } + clauses.add(Clause.equalsClause("IND.RDB$RELATION_NAME", table)); + String sql = GET_INDEX_INFO_START_6 + + Clause.conjunction(clauses) + + (unique ? "\n and IND.RDB$UNIQUE_FLAG = 1" : "") + + GET_INDEX_INFO_END_6; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + + } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java index 99cb63d1b..22780881c 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2012-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -26,6 +26,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultPropertiesForConnection; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalTo; @@ -40,6 +41,8 @@ */ class FBDatabaseMetaDataIndexInfoTest { + // TODO Add schema support: tests involving other schema + private static final String CREATE_INDEX_TEST_TABLE_1 = """ CREATE TABLE index_test_table_1 ( id INTEGER CONSTRAINT pk_idx_test_1_id PRIMARY KEY, @@ -283,9 +286,16 @@ private void validate(ResultSet indexInfo, List> } } - private Map createRule(String tableName, boolean nonUnique, String indexName, + private Map createRule(String tableName, boolean nonUnique, String indexName, String columnName, Integer ordinalPosition, boolean ascending) { + return createRule(ifSchemaElse("PUBLIC", null), tableName, nonUnique, indexName, columnName, ordinalPosition, + ascending); + } + + private Map createRule(String schema, String tableName, boolean nonUnique, + String indexName, String columnName, Integer ordinalPosition, boolean ascending) { Map indexRules = getDefaultValueValidationRules(); + indexRules.put(IndexInfoMetaData.TABLE_SCHEM, schema); indexRules.put(IndexInfoMetaData.TABLE_NAME, tableName); indexRules.put(IndexInfoMetaData.NON_UNIQUE, nonUnique ? "T" : "F"); indexRules.put(IndexInfoMetaData.INDEX_NAME, indexName); @@ -314,7 +324,7 @@ private Map withFilterCondition(Map defaults = new EnumMap<>(IndexInfoMetaData.class); defaults.put(IndexInfoMetaData.TABLE_CAT, null); - defaults.put(IndexInfoMetaData.TABLE_SCHEM, null); + defaults.put(IndexInfoMetaData.TABLE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(IndexInfoMetaData.INDEX_QUALIFIER, null); defaults.put(IndexInfoMetaData.TYPE, DatabaseMetaData.tableIndexOther); defaults.put(IndexInfoMetaData.CARDINALITY, null); From a1655eb99b489f1229aa377704f0a6176688cb24 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 22 Jun 2025 15:11:46 +0200 Subject: [PATCH 15/64] #882 Schema support for getPrimaryKeys --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 2 +- .../jdbc/metadata/GetBestRowIdentifier.java | 2 +- .../jdbc/metadata/GetPrimaryKeys.java | 129 ++++++++++++++---- .../FBDatabaseMetaDataPrimaryKeysTest.java | 6 +- 4 files changed, 110 insertions(+), 29 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 05511c9c0..578e7df3b 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1428,7 +1428,7 @@ public ResultSet getVersionColumns(String catalog, String schema, String table) */ @Override public ResultSet getPrimaryKeys(String catalog, String schema, String table) throws SQLException { - return GetPrimaryKeys.create(getDbMetadataMediator()).getPrimaryKeys(table); + return GetPrimaryKeys.create(getDbMetadataMediator()).getPrimaryKeys(schema, table); } /** diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java index 870307cae..eed40154f 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java @@ -251,7 +251,7 @@ private static GetBestRowIdentifier createInstance(DbMetadataMediator mediator) @Override MetadataQuery createGetPrimaryKeyIdentifierQuery(String schema, String table) { - List clauses = new ArrayList<>(2); + var clauses = new ArrayList(2); if (schema != null) { // NOTE: empty string will return no rows as required ("" retrieves those without a schema) clauses.add(Clause.equalsClause("RC.RDB$SCHEMA_NAME", schema)); diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java b/src/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java index 777638300..4908edb89 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -10,6 +10,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import static org.firebirdsql.gds.ISCConstants.SQL_SHORT; import static org.firebirdsql.gds.ISCConstants.SQL_VARYING; @@ -22,7 +23,7 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetPrimaryKeys extends AbstractMetadataMethod { +public abstract class GetPrimaryKeys extends AbstractMetadataMethod { private static final String COLUMNINFO = "COLUMNINFO"; @@ -36,42 +37,25 @@ public final class GetPrimaryKeys extends AbstractMetadataMethod { .at(6).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "JB_PK_INDEX_NAME", COLUMNINFO).addField() .toRowDescriptor(); - private static final String GET_PRIMARY_KEYS_START = """ - select - RC.RDB$RELATION_NAME as TABLE_NAME, - ISGMT.RDB$FIELD_NAME as COLUMN_NAME, - ISGMT.RDB$FIELD_POSITION + 1 as KEY_SEQ, - RC.RDB$CONSTRAINT_NAME as PK_NAME, - RC.RDB$INDEX_NAME as JB_PK_INDEX_NAME - from RDB$RELATION_CONSTRAINTS RC - inner join RDB$INDEX_SEGMENTS ISGMT - on RC.RDB$INDEX_NAME = ISGMT.RDB$INDEX_NAME - where RC.RDB$CONSTRAINT_TYPE = 'PRIMARY KEY' - and\s"""; - - private static final String GET_PRIMARY_KEYS_END = "\norder by ISGMT.RDB$FIELD_NAME "; - private GetPrimaryKeys(DbMetadataMediator mediator) { super(ROW_DESCRIPTOR, mediator); } - public ResultSet getPrimaryKeys(String table) throws SQLException { + public final ResultSet getPrimaryKeys(String schema, String table) throws SQLException { if (isNullOrEmpty(table)) { return createEmpty(); } - Clause tableClause = Clause.equalsClause("RC.RDB$RELATION_NAME", table); - String sql = GET_PRIMARY_KEYS_START - + tableClause.getCondition(false) - + GET_PRIMARY_KEYS_END; - MetadataQuery metadataQuery = new MetadataQuery(sql, Clause.parameters(tableClause)); + MetadataQuery metadataQuery = createGetPrimaryKeysQuery(schema, table); return createMetaDataResultSet(metadataQuery); } + abstract MetadataQuery createGetPrimaryKeysQuery(String schema, String table); + @Override RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { return valueBuilder .at(0).set(null) - .at(1).set(null) + .at(1).setString(rs.getString("TABLE_SCHEM")) .at(2).setString(rs.getString("TABLE_NAME")) .at(3).setString(rs.getString("COLUMN_NAME")) .at(4).setShort(rs.getShort("KEY_SEQ")) @@ -81,6 +65,99 @@ RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQ } public static GetPrimaryKeys create(DbMetadataMediator mediator) { - return new GetPrimaryKeys(mediator); + // NOTE: Indirection through static method prevents unnecessary classloading + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else { + return FB5.createInstance(mediator); + } } + + /** + * Implementation for Firebird 5.0 and older. + */ + private static final class FB5 extends GetPrimaryKeys { + + private static final String GET_PRIMARY_KEYS_START_5 = """ + select + cast(null as char(1)) as TABLE_SCHEM, + RC.RDB$RELATION_NAME as TABLE_NAME, + ISGMT.RDB$FIELD_NAME as COLUMN_NAME, + ISGMT.RDB$FIELD_POSITION + 1 as KEY_SEQ, + RC.RDB$CONSTRAINT_NAME as PK_NAME, + RC.RDB$INDEX_NAME as JB_PK_INDEX_NAME + from RDB$RELATION_CONSTRAINTS RC + inner join RDB$INDEX_SEGMENTS ISGMT + on RC.RDB$INDEX_NAME = ISGMT.RDB$INDEX_NAME + where RC.RDB$CONSTRAINT_TYPE = 'PRIMARY KEY' + and\s"""; + + private static final String GET_PRIMARY_KEYS_END_5 = "\norder by ISGMT.RDB$FIELD_NAME"; + + private FB5(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetPrimaryKeys createInstance(DbMetadataMediator mediator) { + return new FB5(mediator); + } + + @Override + MetadataQuery createGetPrimaryKeysQuery(String schema, String table) { + Clause tableClause = Clause.equalsClause("RC.RDB$RELATION_NAME", table); + String sql = GET_PRIMARY_KEYS_START_5 + + tableClause.getCondition(false) + + GET_PRIMARY_KEYS_END_5; + return new MetadataQuery(sql, Clause.parameters(tableClause)); + } + + } + + /** + * Implementation for Firebird 6.0 and higher. + */ + private static final class FB6 extends GetPrimaryKeys { + + private static final String GET_PRIMARY_KEYS_START_6 = """ + select + trim(trailing from RC.RDB$SCHEMA_NAME) as TABLE_SCHEM, + trim(trailing from RC.RDB$RELATION_NAME) as TABLE_NAME, + trim(trailing from ISGMT.RDB$FIELD_NAME) as COLUMN_NAME, + ISGMT.RDB$FIELD_POSITION + 1 as KEY_SEQ, + trim(trailing from RC.RDB$CONSTRAINT_NAME) as PK_NAME, + trim(trailing from RC.RDB$INDEX_NAME) as JB_PK_INDEX_NAME + from SYSTEM.RDB$RELATION_CONSTRAINTS RC + inner join SYSTEM.RDB$INDEX_SEGMENTS ISGMT + on RC.RDB$SCHEMA_NAME = ISGMT.RDB$SCHEMA_NAME and RC.RDB$INDEX_NAME = ISGMT.RDB$INDEX_NAME + where RC.RDB$CONSTRAINT_TYPE = 'PRIMARY KEY' + and\s"""; + + // For consistent order (e.g. for tests), we're also sorting on schema name. + // JDBC specifies that the result set is sorted on COLUMN_NAME, so we can't sort on schema first + private static final String GET_PRIMARY_KEYS_END_6 = "\norder by ISGMT.RDB$FIELD_NAME, ISGMT.RDB$SCHEMA_NAME"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetPrimaryKeys createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetPrimaryKeysQuery(String schema, String table) { + var clauses = new ArrayList(2); + if (schema != null) { + // NOTE: empty string will return no rows as required ("" retrieves those without a schema) + clauses.add(Clause.equalsClause("RC.RDB$SCHEMA_NAME", schema)); + } + clauses.add(Clause.equalsClause("RC.RDB$RELATION_NAME", table)); + String sql = GET_PRIMARY_KEYS_START_6 + + Clause.conjunction(clauses) + + GET_PRIMARY_KEYS_END_6; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + + } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java index 85b661c73..735fa96e7 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2024-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -20,6 +20,7 @@ import static java.util.Collections.unmodifiableMap; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNoNextRow; @@ -30,6 +31,8 @@ */ class FBDatabaseMetaDataPrimaryKeysTest { + // TODO Add schema support: tests involving other schema + private static final String UNNAMED_CONSTRAINT_PREFIX = "INTEG_"; private static final String UNNAMED_PK_INDEX_PREFIX = "RDB$PRIMARY"; @@ -170,6 +173,7 @@ private void validateExpectedPrimaryKeys(String tableName, List(PrimaryKeysMetaData.class); Arrays.stream(PrimaryKeysMetaData.values()).forEach(key -> defaults.put(key, null)); + defaults.put(PrimaryKeysMetaData.TABLE_SCHEM, ifSchemaElse("PUBLIC", null)); DEFAULT_COLUMN_VALUES = unmodifiableMap(defaults); } From 6b55727bdfdcab5a50631c50f21f9c575a12c72f Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 22 Jun 2025 15:50:12 +0200 Subject: [PATCH 16/64] #882 Schema support for getProcedureColumns --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 2 +- .../jdbc/metadata/GetProcedureColumns.java | 314 +++++++++++++----- .../jdbc/metadata/GetProcedures.java | 6 +- ...BDatabaseMetaDataProcedureColumnsTest.java | 31 +- 4 files changed, 250 insertions(+), 103 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 578e7df3b..4192cef86 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1230,7 +1230,7 @@ public ResultSet getProcedures(String catalog, String schemaPattern, String proc public ResultSet getProcedureColumns(String catalog, String schemaPattern, String procedureNamePattern, String columnNamePattern) throws SQLException { return GetProcedureColumns.create(getDbMetadataMediator()) - .getProcedureColumns(catalog, procedureNamePattern, columnNamePattern); + .getProcedureColumns(catalog, schemaPattern, procedureNamePattern, columnNamePattern); } public static final String TABLE = "TABLE"; diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java index 83fca3254..6d1fb9ca5 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -13,6 +13,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.List; import static java.sql.DatabaseMetaData.procedureColumnIn; import static java.sql.DatabaseMetaData.procedureColumnOut; @@ -21,7 +22,6 @@ import static org.firebirdsql.gds.ISCConstants.SQL_LONG; import static org.firebirdsql.gds.ISCConstants.SQL_SHORT; import static org.firebirdsql.gds.ISCConstants.SQL_VARYING; -import static org.firebirdsql.jdbc.metadata.Clause.anyCondition; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.OBJECT_NAME_LENGTH; import static org.firebirdsql.jdbc.metadata.NameHelper.toSpecificName; import static org.firebirdsql.jdbc.metadata.TypeMetadata.CHARSET_ID; @@ -42,6 +42,8 @@ public abstract class GetProcedureColumns extends AbstractMetadataMethod { private static final String COLUMNINFO = "COLUMNINFO"; + private static final String COLUMN_SCHEMA_NAME = "PP.RDB$SCHEMA_NAME"; + private static final String COLUMN_PACKAGE_NAME = "PP.RDB$PACKAGE_NAME"; private static final String COLUMN_PROCEDURE_NAME = "PP.RDB$PROCEDURE_NAME"; private static final String COLUMN_PARAMETER_NAME = "PP.RDB$PARAMETER_NAME"; @@ -76,14 +78,15 @@ private GetProcedureColumns(DbMetadataMediator mediator) { /** * @see DatabaseMetaData#getProcedureColumns(String, String, String, String) */ - public final ResultSet getProcedureColumns(String catalog, String procedureNamePattern, String columnNamePattern) - throws SQLException { + public final ResultSet getProcedureColumns(String catalog, String schemaPattern, String procedureNamePattern, + String columnNamePattern) throws SQLException { if ("".equals(procedureNamePattern) || "".equals(columnNamePattern)) { // Matching procedure name or column name not possible return createEmpty(); } - MetadataQuery metadataQuery = createGetProcedureColumnsQuery(catalog, procedureNamePattern, columnNamePattern); + MetadataQuery metadataQuery = createGetProcedureColumnsQuery(catalog, schemaPattern, procedureNamePattern, + columnNamePattern); return createMetaDataResultSet(metadataQuery); } @@ -96,10 +99,11 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .fromCurrentRow(rs) .build(); String catalog = rs.getString("PROCEDURE_CAT"); + String schema = rs.getString("PROCEDURE_SCHEM"); String procedureName = rs.getString("PROCEDURE_NAME"); return valueBuilder .at(0).setString(catalog) - .at(1).set(null) + .at(1).setString(schema) .at(2).setString(procedureName) .at(3).setString(rs.getString("COLUMN_NAME")) // TODO: Unsure if procedureColumnOut is correct, maybe procedureColumnResult, or need ODS dependent use of RDB$PROCEDURE_TYPE to decide on selectable or executable? @@ -123,17 +127,22 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .at(17).setInt(rs.getInt("PARAMETER_NUMBER")) // TODO: Find out if there is a conceptual difference with NULLABLE (idx 11) .at(18).setString(nullFlag == 1 ? "NO" : "YES") - .at(19).setString(toSpecificName(catalog, procedureName)) + .at(19).setString(toSpecificName(catalog, schema, procedureName)) .toRowValue(false); } - abstract MetadataQuery createGetProcedureColumnsQuery(String catalog, String procedureNamePattern, - String columnNamePattern); + abstract MetadataQuery createGetProcedureColumnsQuery(String catalog, String schemaPattern, + String procedureNamePattern, String columnNamePattern); public static GetProcedureColumns create(DbMetadataMediator mediator) { FirebirdSupportInfo firebirdSupportInfo = mediator.getFirebirdSupportInfo(); // NOTE: Indirection through static method prevents unnecessary classloading - if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { + if (firebirdSupportInfo.isVersionEqualOrAbove(6)) { + if (mediator.isUseCatalogAsPackage()) { + return FB6CatalogAsPackage.createInstance(mediator); + } + return FB6.createInstance(mediator); + } else if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { if (mediator.isUseCatalogAsPackage()) { return FB3CatalogAsPackage.createInstance(mediator); } @@ -147,27 +156,30 @@ public static GetProcedureColumns create(DbMetadataMediator mediator) { private static class FB2_5 extends GetProcedureColumns { //@formatter:off - private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_2_5 = - "select\n" - + " null as PROCEDURE_CAT,\n" - + " PP.RDB$PROCEDURE_NAME as PROCEDURE_NAME,\n" - + " PP.RDB$PARAMETER_NAME as COLUMN_NAME,\n" - + " PP.RDB$PARAMETER_TYPE as COLUMN_TYPE,\n" - + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" - + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" - + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" - + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" - + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" - + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" - + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" - + " F.RDB$NULL_FLAG as NULL_FLAG,\n" - + " PP.RDB$DESCRIPTION as REMARKS,\n" - + " PP.RDB$PARAMETER_NUMBER + 1 as PARAMETER_NUMBER,\n" - + " coalesce(PP.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as COLUMN_DEF\n" - + "from RDB$PROCEDURE_PARAMETERS PP inner join RDB$FIELDS F on PP.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"; + private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_2_5 = """ + select + cast(null as CHAR(1)) as PROCEDURE_CAT, + cast(null as CHAR(1)) as PROCEDURE_SCHEM, + PP.RDB$PROCEDURE_NAME as PROCEDURE_NAME, + PP.RDB$PARAMETER_NAME as COLUMN_NAME, + PP.RDB$PARAMETER_TYPE as COLUMN_TYPE, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" + """ + F.RDB$NULL_FLAG as NULL_FLAG, + PP.RDB$DESCRIPTION as REMARKS, + PP.RDB$PARAMETER_NUMBER + 1 as PARAMETER_NUMBER, + coalesce(PP.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as COLUMN_DEF + from RDB$PROCEDURE_PARAMETERS PP inner join RDB$FIELDS F on PP.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"""; + //@formatter:on + private static final String GET_PROCEDURE_COLUMNS_END_2_5 = "\norder by PP.RDB$PROCEDURE_NAME, PP.RDB$PARAMETER_TYPE desc, PP.RDB$PARAMETER_NUMBER"; - //@formatter:on private FB2_5(DbMetadataMediator mediator) { super(mediator); @@ -178,47 +190,50 @@ private static GetProcedureColumns createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetProcedureColumnsQuery(String catalog, String procedureNamePattern, + MetadataQuery createGetProcedureColumnsQuery(String catalog, String schemaPattern, String procedureNamePattern, String columnNamePattern) { - Clause procedureClause = new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern); - Clause columnClause = new Clause(COLUMN_PARAMETER_NAME, columnNamePattern); + var clauses = List.of( + new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern), + new Clause(COLUMN_PARAMETER_NAME, columnNamePattern)); String query = GET_PROCEDURE_COLUMNS_FRAGMENT_2_5 - + (anyCondition(procedureClause, columnClause) - ? "\nwhere " + procedureClause.getCondition(columnClause.hasCondition()) - + columnClause.getCondition(false) - : "") + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + GET_PROCEDURE_COLUMNS_END_2_5; - return new MetadataQuery(query, Clause.parameters(procedureClause, columnClause)); + return new MetadataQuery(query, Clause.parameters(clauses)); } + } private static class FB3 extends GetProcedureColumns { //@formatter:off - private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_3 = - "select\n" - + " null as PROCEDURE_CAT,\n" - + " trim(trailing from PP.RDB$PROCEDURE_NAME) as PROCEDURE_NAME,\n" - + " trim(trailing from PP.RDB$PARAMETER_NAME) as COLUMN_NAME,\n" - + " PP.RDB$PARAMETER_TYPE as COLUMN_TYPE,\n" - + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" - + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" - + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" - + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" - + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" - + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" - + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" - + " F.RDB$NULL_FLAG as NULL_FLAG,\n" - + " PP.RDB$DESCRIPTION as REMARKS,\n" - + " PP.RDB$PARAMETER_NUMBER + 1 as PARAMETER_NUMBER,\n" - + " coalesce(PP.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as COLUMN_DEF\n" - + "from RDB$PROCEDURE_PARAMETERS PP inner join RDB$FIELDS F on PP.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME\n" - + "where PP.RDB$PACKAGE_NAME is null"; + private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_3 = """ + select + null as PROCEDURE_CAT, + null as PROCEDURE_SCHEM, + trim(trailing from PP.RDB$PROCEDURE_NAME) as PROCEDURE_NAME, + trim(trailing from PP.RDB$PARAMETER_NAME) as COLUMN_NAME, + PP.RDB$PARAMETER_TYPE as COLUMN_TYPE, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" + """ + F.RDB$NULL_FLAG as NULL_FLAG, + PP.RDB$DESCRIPTION as REMARKS, + PP.RDB$PARAMETER_NUMBER + 1 as PARAMETER_NUMBER, + coalesce(PP.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as COLUMN_DEF + from RDB$PROCEDURE_PARAMETERS PP inner join RDB$FIELDS F on PP.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME + where PP.RDB$PACKAGE_NAME is null"""; + //@formatter:on + // NOTE: Including RDB$PACKAGE_NAME so index can be used to sort private static final String GET_PROCEDURE_COLUMNS_END_3 = "\norder by PP.RDB$PACKAGE_NAME, PP.RDB$PROCEDURE_NAME, PP.RDB$PARAMETER_TYPE desc, " + "PP.RDB$PARAMETER_NUMBER"; - //@formatter:on + private FB3(DbMetadataMediator mediator) { super(mediator); @@ -229,46 +244,47 @@ private static GetProcedureColumns createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetProcedureColumnsQuery(String catalog, String procedureNamePattern, + MetadataQuery createGetProcedureColumnsQuery(String catalog, String schemaPattern, String procedureNamePattern, String columnNamePattern) { - Clause procedureClause = new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern); - Clause columnClause = new Clause(COLUMN_PARAMETER_NAME, columnNamePattern); + var clauses = List.of( + new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern), + new Clause(COLUMN_PARAMETER_NAME, columnNamePattern)); String query = GET_PROCEDURE_COLUMNS_FRAGMENT_3 - + procedureClause.getCondition("\nand ", "") - + columnClause.getCondition("\nand ", "") + + (Clause.anyCondition(clauses) ? "\nand " + Clause.conjunction(clauses) : "") + GET_PROCEDURE_COLUMNS_END_3; - return new MetadataQuery(query, Clause.parameters(procedureClause, columnClause)); + return new MetadataQuery(query, Clause.parameters(clauses)); } + } private static final class FB3CatalogAsPackage extends GetProcedureColumns { //@formatter:off - private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_3_W_PKG = - "select\n" - + " coalesce(trim(trailing from PP.RDB$PACKAGE_NAME), '') as PROCEDURE_CAT,\n" - + " trim(trailing from PP.RDB$PROCEDURE_NAME) as PROCEDURE_NAME,\n" - + " trim(trailing from PP.RDB$PARAMETER_NAME) as COLUMN_NAME,\n" - + " PP.RDB$PARAMETER_TYPE as COLUMN_TYPE,\n" - + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" - + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" - + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" - + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" - + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" - + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" - + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" - + " F.RDB$NULL_FLAG as NULL_FLAG,\n" - + " PP.RDB$DESCRIPTION as REMARKS,\n" - + " PP.RDB$PARAMETER_NUMBER + 1 as PARAMETER_NUMBER,\n" - + " coalesce(PP.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as COLUMN_DEF\n" - + "from RDB$PROCEDURE_PARAMETERS PP inner join RDB$FIELDS F on PP.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"; + private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_3_W_PKG = """ + select + coalesce(trim(trailing from PP.RDB$PACKAGE_NAME), '') as PROCEDURE_CAT, + null as PROCEDURE_SCHEM, + trim(trailing from PP.RDB$PROCEDURE_NAME) as PROCEDURE_NAME, + trim(trailing from PP.RDB$PARAMETER_NAME) as COLUMN_NAME, + PP.RDB$PARAMETER_TYPE as COLUMN_TYPE, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" + """ + F.RDB$NULL_FLAG as NULL_FLAG, + PP.RDB$DESCRIPTION as REMARKS, + PP.RDB$PARAMETER_NUMBER + 1 as PARAMETER_NUMBER, + coalesce(PP.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as COLUMN_DEF + from RDB$PROCEDURE_PARAMETERS PP inner join RDB$FIELDS F on PP.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"""; + //@formatter:on private static final String GET_PROCEDURE_COLUMNS_END_3_W_PKG = "\norder by PP.RDB$PACKAGE_NAME nulls first, PP.RDB$PROCEDURE_NAME, PP.RDB$PARAMETER_TYPE desc, " + "PP.RDB$PARAMETER_NUMBER"; - //@formatter:on - - private static final String COLUMN_PACKAGE_NAME = "PP.RDB$PACKAGE_NAME"; private FB3CatalogAsPackage(DbMetadataMediator mediator) { super(mediator); @@ -279,7 +295,7 @@ private static GetProcedureColumns createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetProcedureColumnsQuery(String catalog, String procedureNamePattern, + MetadataQuery createGetProcedureColumnsQuery(String catalog, String schemaPattern, String procedureNamePattern, String columnNamePattern) { var clauses = new ArrayList(3); if (catalog != null) { @@ -294,14 +310,130 @@ MetadataQuery createGetProcedureColumnsQuery(String catalog, String procedureNam } clauses.add(new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern)); clauses.add(new Clause(COLUMN_PARAMETER_NAME, columnNamePattern)); - //@formatter:off String sql = GET_PROCEDURE_COLUMNS_FRAGMENT_3_W_PKG - + (Clause.anyCondition(clauses) - ? "\nwhere " + Clause.conjunction(clauses) - : "") + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + GET_PROCEDURE_COLUMNS_END_3_W_PKG; - //@formatter:on return new MetadataQuery(sql, Clause.parameters(clauses)); } + + } + + private static class FB6 extends GetProcedureColumns { + + //@formatter:off + private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_6 = """ + select + null as PROCEDURE_CAT, + trim(trailing from PP.RDB$SCHEMA_NAME) as PROCEDURE_SCHEM, + trim(trailing from PP.RDB$PROCEDURE_NAME) as PROCEDURE_NAME, + trim(trailing from PP.RDB$PARAMETER_NAME) as COLUMN_NAME, + PP.RDB$PARAMETER_TYPE as COLUMN_TYPE, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" + """ + F.RDB$NULL_FLAG as NULL_FLAG, + PP.RDB$DESCRIPTION as REMARKS, + PP.RDB$PARAMETER_NUMBER + 1 as PARAMETER_NUMBER, + coalesce(PP.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as COLUMN_DEF + from SYSTEM.RDB$PROCEDURE_PARAMETERS PP + inner join SYSTEM.RDB$FIELDS F + on PP.RDB$FIELD_SOURCE_SCHEMA_NAME = F.RDB$SCHEMA_NAME and PP.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME + where PP.RDB$PACKAGE_NAME is null"""; + //@formatter:on + + private static final String GET_PROCEDURE_COLUMNS_END_6 = + "\norder by PP.RDB$PACKAGE_NAME, PP.RDB$SCHEMA_NAME, PP.RDB$PROCEDURE_NAME, " + + "PP.RDB$PARAMETER_TYPE desc, PP.RDB$PARAMETER_NUMBER"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetProcedureColumns createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetProcedureColumnsQuery(String catalog, String schemaPattern, String procedureNamePattern, + String columnNamePattern) { + var clauses = List.of( + new Clause(COLUMN_SCHEMA_NAME, schemaPattern), + new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern), + new Clause(COLUMN_PARAMETER_NAME, columnNamePattern)); + String query = GET_PROCEDURE_COLUMNS_FRAGMENT_6 + + (Clause.anyCondition(clauses) ? "\nand " + Clause.conjunction(clauses) : "") + + GET_PROCEDURE_COLUMNS_END_6; + return new MetadataQuery(query, Clause.parameters(clauses)); + } + + } + + private static final class FB6CatalogAsPackage extends GetProcedureColumns { + + //@formatter:off + private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_6_W_PKG = """ + select + coalesce(trim(trailing from PP.RDB$PACKAGE_NAME), '') as PROCEDURE_CAT, + trim(trailing from PP.RDB$SCHEMA_NAME) as PROCEDURE_SCHEM, + trim(trailing from PP.RDB$PROCEDURE_NAME) as PROCEDURE_NAME, + trim(trailing from PP.RDB$PARAMETER_NAME) as COLUMN_NAME, + PP.RDB$PARAMETER_TYPE as COLUMN_TYPE, + """ + + " F.RDB$FIELD_TYPE as " + FIELD_TYPE + ",\n" + + " F.RDB$FIELD_SUB_TYPE as " + FIELD_SUB_TYPE + ",\n" + + " F.RDB$FIELD_PRECISION as " + FIELD_PRECISION + ",\n" + + " F.RDB$FIELD_SCALE as " + FIELD_SCALE + ",\n" + + " F.RDB$FIELD_LENGTH as " + FIELD_LENGTH + ",\n" + + " F.RDB$CHARACTER_LENGTH as " + CHAR_LEN + ",\n" + + " F.RDB$CHARACTER_SET_ID as " + CHARSET_ID + ",\n" + """ + F.RDB$NULL_FLAG as NULL_FLAG, + PP.RDB$DESCRIPTION as REMARKS, + PP.RDB$PARAMETER_NUMBER + 1 as PARAMETER_NUMBER, + coalesce(PP.RDB$DEFAULT_SOURCE, F.RDB$DEFAULT_SOURCE) as COLUMN_DEF + from SYSTEM.RDB$PROCEDURE_PARAMETERS PP + inner join SYSTEM.RDB$FIELDS F + on PP.RDB$FIELD_SOURCE_SCHEMA_NAME = F.RDB$SCHEMA_NAME and PP.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME"""; + //@formatter:on + + private static final String GET_PROCEDURE_COLUMNS_END_6_W_PKG = + "\norder by PP.RDB$PACKAGE_NAME nulls first, PP.RDB$SCHEMA_NAME, PP.RDB$PROCEDURE_NAME," + + "PP.RDB$PARAMETER_TYPE desc, PP.RDB$PARAMETER_NUMBER"; + + private FB6CatalogAsPackage(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetProcedureColumns createInstance(DbMetadataMediator mediator) { + return new FB6CatalogAsPackage(mediator); + } + + @Override + MetadataQuery createGetProcedureColumnsQuery(String catalog, String schemaPattern, String procedureNamePattern, + String columnNamePattern) { + var clauses = new ArrayList(4); + clauses.add(new Clause(COLUMN_SCHEMA_NAME, schemaPattern)); + if (catalog != null) { + // To quote from the JDBC API: "" retrieves those without a catalog; null means that the catalog name + // should not be used to narrow the search + if (catalog.isEmpty()) { + clauses.add(Clause.isNullClause(COLUMN_PACKAGE_NAME)); + } else { + // Exact matches only + clauses.add(Clause.equalsClause(COLUMN_PACKAGE_NAME, catalog)); + } + } + clauses.add(new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern)); + clauses.add(new Clause(COLUMN_PARAMETER_NAME, columnNamePattern)); + String sql = GET_PROCEDURE_COLUMNS_FRAGMENT_6_W_PKG + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + + GET_PROCEDURE_COLUMNS_END_6_W_PKG; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + } } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java index 8f87e009d..2d277313e 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java @@ -290,13 +290,9 @@ MetadataQuery createGetProceduresQuery(String catalog, String schemaPattern, Str } } clauses.add(new Clause(COLUMN_PROCEDURE_NAME, procedureNamePattern)); - //@formatter:off String sql = GET_PROCEDURES_FRAGMENT_6_W_PKG - + (Clause.anyCondition(clauses) - ? "\nwhere " + Clause.conjunction(clauses) - : "") + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + GET_PROCEDURES_ORDER_BY_6_W_PKG; - //@formatter:on return new MetadataQuery(sql, Clause.parameters(clauses)); } } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java index 628ae9a5f..a8fe6f42d 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2012-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -23,6 +23,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultPropertiesForConnection; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.firebirdsql.jdbc.FBDatabaseMetaDataProceduresTest.isIgnoredProcedure; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.*; @@ -36,7 +37,8 @@ * @author Mark Rotteveel */ class FBDatabaseMetaDataProcedureColumnsTest { - + + // TODO Add schema support: tests involving other schema // TODO This test will need to be expanded with version dependent features // (eg TYPE OF (2.1), TYPE OF COLUMN (2.5), NOT NULL (2.1), DEFAULT (2.0) @@ -329,7 +331,7 @@ void testProcedureColumns_useCatalogAsPackage_specificPackageProcedureColumn(Str List> expectedColumns = withCatalog("WITH$PROCEDURE", - withSpecificName("\"WITH$PROCEDURE\".\"IN$PACKAGE\"", + withSpecificName(ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$PROCEDURE\".\"IN$PACKAGE\"", List.of(createNumericalType(Types.INTEGER, "IN$PACKAGE", "RETURN1", 1, 10, 0, true, DatabaseMetaData.procedureColumnOut)))); @@ -359,7 +361,7 @@ void testProcedureColumns_useCatalogAsPackage_nonPackagedOnly() throws Exception private static List> getInPackage_allColumns() { return withCatalog("WITH$PROCEDURE", - withSpecificName("\"WITH$PROCEDURE\".\"IN$PACKAGE\"", + withSpecificName(ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$PROCEDURE\".\"IN$PACKAGE\"", // TODO Having result columns first might be against JDBC spec // TODO Describing result columns as procedureColumnOut might be against JDBC spec List.of( @@ -392,9 +394,16 @@ private void validate(ResultSet procedureColumns, List createColumn(String procedureName, String columnName, int ordinalPosition, boolean nullable, int columnType) { + return createColumn(ifSchemaElse("PUBLIC", null), procedureName, columnName, ordinalPosition, nullable, + columnType); + } + + private static Map createColumn(String schema, String procedureName, + String columnName, int ordinalPosition, boolean nullable, int columnType) { Map rules = getDefaultValueValidationRules(); + rules.put(ProcedureColumnMetaData.PROCEDURE_SCHEM, schema); rules.put(ProcedureColumnMetaData.PROCEDURE_NAME, procedureName); - rules.put(ProcedureColumnMetaData.SPECIFIC_NAME, procedureName); + rules.put(ProcedureColumnMetaData.SPECIFIC_NAME, getProcedureSpecificName(schema, procedureName)); rules.put(ProcedureColumnMetaData.COLUMN_NAME, columnName); rules.put(ProcedureColumnMetaData.ORDINAL_POSITION, ordinalPosition); rules.put(ProcedureColumnMetaData.COLUMN_TYPE, columnType); @@ -405,6 +414,16 @@ private static Map createColumn(String procedur return rules; } + private static String getProcedureSpecificName(String schema, String procedureName) { + if (schema == null || schema.isEmpty()) return procedureName; + var quote = QuoteStrategy.DIALECT_3; + // 5 = 4 quotes + 1 period + var sb = new StringBuilder(schema.length() + procedureName.length() + 5); + quote.appendQuoted(schema, sb).append('.'); + quote.appendQuoted(procedureName, sb); + return sb.toString(); + } + @SuppressWarnings("SameParameterValue") private static Map createStringType(int jdbcType, String procedureName, String columnName, int ordinalPosition, int length, boolean nullable, int columnType) { @@ -554,7 +573,7 @@ private static List> withSpecificName( static { Map defaults = new EnumMap<>(ProcedureColumnMetaData.class); defaults.put(ProcedureColumnMetaData.PROCEDURE_CAT, null); - defaults.put(ProcedureColumnMetaData.PROCEDURE_SCHEM, null); + defaults.put(ProcedureColumnMetaData.PROCEDURE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(ProcedureColumnMetaData.SCALE, null); defaults.put(ProcedureColumnMetaData.RADIX, FbMetadataConstants.RADIX_DECIMAL); defaults.put(ProcedureColumnMetaData.NULLABLE, DatabaseMetaData.procedureNullable); From 584e2cc6a09743556093cdb3e26831376de6c76a Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 22 Jun 2025 16:12:44 +0200 Subject: [PATCH 17/64] #882 Schema support for getPseudoColumns --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 3 +- .../jdbc/metadata/GetPseudoColumns.java | 136 +++++++++++++----- .../FBDatabaseMetaDataPseudoColumnsTest.java | 31 +++- 3 files changed, 125 insertions(+), 45 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 4192cef86..d65133744 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1817,7 +1817,8 @@ public static String escapeWildcards(String objectName) { @Override public ResultSet getPseudoColumns(String catalog, String schemaPattern, String tableNamePattern, String columnNamePattern) throws SQLException { - return GetPseudoColumns.create(getDbMetadataMediator()).getPseudoColumns(tableNamePattern, columnNamePattern); + return GetPseudoColumns.create(getDbMetadataMediator()) + .getPseudoColumns(schemaPattern, tableNamePattern, columnNamePattern); } @Override diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java index 0e1812c22..28ec287f9 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -32,6 +32,7 @@ public abstract class GetPseudoColumns { private static final String PSEUDOCOLUMNS = "PSEUDOCOLUMNS"; + public static final String COLUMN_SCHEMA_NAME = "RDB$SCHEMA_NAME"; public static final String COLUMN_RELATION_NAME = "RDB$RELATION_NAME"; private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(12) @@ -62,7 +63,7 @@ private GetPseudoColumns(DbMetadataMediator mediator) { this.mediator = mediator; } - public ResultSet getPseudoColumns(String tableNamePattern, String columnNamePattern) throws SQLException { + public ResultSet getPseudoColumns(String schemaPattern, String tableNamePattern, String columnNamePattern) throws SQLException { if ("".equals(tableNamePattern) || "".equals(columnNamePattern)) { // Matching table and/or column not possible return createEmpty(); @@ -77,19 +78,22 @@ public ResultSet getPseudoColumns(String tableNamePattern, String columnNamePatt return createEmpty(); } - try (ResultSet rs = mediator.performMetaDataQuery(createGetPseudoColumnsQuery(tableNamePattern))) { + MetadataQuery metadataQuery = createGetPseudoColumnsQuery(schemaPattern, tableNamePattern); + try (ResultSet rs = mediator.performMetaDataQuery(metadataQuery)) { if (!rs.next()) { return createEmpty(); } - List rows = new ArrayList<>(); - RowValueBuilder valueBuilder = new RowValueBuilder(ROW_DESCRIPTOR); + var rows = new ArrayList(); + var valueBuilder = new RowValueBuilder(ROW_DESCRIPTOR); do { + String schema = rs.getString(COLUMN_SCHEMA_NAME); String tableName = rs.getString(COLUMN_RELATION_NAME); if (retrieveDbKey) { int dbKeyLength = rs.getInt("RDB$DBKEY_LENGTH"); valueBuilder + .at(1).setString(schema) .at(2).setString(tableName) .at(3).setString("RDB$DB_KEY") .at(4).setInt(Types.ROWID) @@ -104,6 +108,7 @@ public ResultSet getPseudoColumns(String tableNamePattern, String columnNamePatt if (retrieveRecordVersion && rs.getBoolean("HAS_RECORD_VERSION")) { valueBuilder + .at(1).setString(schema) .at(2).setString(tableName) .at(3).setString("RDB$RECORD_VERSION") .at(4).setInt(Types.BIGINT) @@ -122,7 +127,7 @@ public ResultSet getPseudoColumns(String tableNamePattern, String columnNamePatt abstract boolean supportsRecordVersion(); - abstract MetadataQuery createGetPseudoColumnsQuery(String tableNamePattern); + abstract MetadataQuery createGetPseudoColumnsQuery(String schemaPattern, String tableNamePattern); private ResultSet createEmpty() throws SQLException { return new FBResultSet(ROW_DESCRIPTOR, emptyList()); @@ -131,7 +136,9 @@ private ResultSet createEmpty() throws SQLException { public static GetPseudoColumns create(DbMetadataMediator mediator) { FirebirdSupportInfo firebirdSupportInfo = mediator.getFirebirdSupportInfo(); // NOTE: Indirection through static method prevents unnecessary classloading - if (firebirdSupportInfo.isVersionEqualOrAbove(3, 0)) { + if (firebirdSupportInfo.isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { return FB3.createInstance(mediator); } else { return FB2_5.createInstance(mediator); @@ -141,17 +148,17 @@ public static GetPseudoColumns create(DbMetadataMediator mediator) { @SuppressWarnings("java:S101") private static final class FB2_5 extends GetPseudoColumns { - //@formatter:off - private static final String GET_PSEUDO_COLUMNS_FRAGMENT_2_5 = - "select\n" - + " RDB$RELATION_NAME,\n" - + " RDB$DBKEY_LENGTH,\n" - + " 'F' AS HAS_RECORD_VERSION,\n" - + " '' AS RECORD_VERSION_NULLABLE\n" // unknown nullability (and doesn't matter, no RDB$RECORD_VERSION) - + "from RDB$RELATIONS\n"; + private static final String GET_PSEUDO_COLUMNS_FRAGMENT_2_5 = """ + select + cast(null as char(1)) as RDB$SCHEMA_NAME, + RDB$RELATION_NAME, + RDB$DBKEY_LENGTH, + 'F' AS HAS_RECORD_VERSION, + -- unknown nullability (and doesn't matter, no RDB$RECORD_VERSION) + '' AS RECORD_VERSION_NULLABLE + from RDB$RELATIONS"""; - private static final String GET_PSEUDO_COLUMNS_END_2_5 = "order by RDB$RELATION_NAME"; - //@formatter:on + private static final String GET_PSEUDO_COLUMNS_END_2_5 = "\norder by RDB$RELATION_NAME"; private FB2_5(DbMetadataMediator mediator) { super(mediator); @@ -167,32 +174,35 @@ boolean supportsRecordVersion() { } @Override - MetadataQuery createGetPseudoColumnsQuery(String tableNamePattern) { - Clause tableNameClause = new Clause(COLUMN_RELATION_NAME, tableNamePattern); + MetadataQuery createGetPseudoColumnsQuery(String schemaPattern, String tableNamePattern) { + var tableNameClause = new Clause(COLUMN_RELATION_NAME, tableNamePattern); String sql = GET_PSEUDO_COLUMNS_FRAGMENT_2_5 - + tableNameClause.getCondition("where ", "\n") + + tableNameClause.getCondition("\nwhere ", "") + GET_PSEUDO_COLUMNS_END_2_5; return new MetadataQuery(sql, Clause.parameters(tableNameClause)); } + } private static final class FB3 extends GetPseudoColumns { - //@formatter:off - private static final String GET_PSEUDO_COLUMNS_FRAGMENT_3 = - "select\n" - + " trim(trailing from RDB$RELATION_NAME) as RDB$RELATION_NAME,\n" - + " RDB$DBKEY_LENGTH,\n" - + " RDB$DBKEY_LENGTH = 8 as HAS_RECORD_VERSION,\n" - + " case\n" - + " when RDB$RELATION_TYPE in (0, 1, 4, 5) then 'NO'\n" // table, view, GTT preserve + delete: never null - + " when RDB$RELATION_TYPE in (2, 3) then 'YES'\n" // external + virtual: always null - + " else ''\n" // unknown or unsupported (by Jaybird) type: unknown nullability - + " end as RECORD_VERSION_NULLABLE\n" - + "from RDB$RELATIONS\n"; - - private static final String GET_PSEUDO_COLUMNS_END_3 = "order by RDB$RELATION_NAME"; - //@formatter:on + private static final String GET_PSEUDO_COLUMNS_FRAGMENT_3 = """ + select + null as RDB$SCHEMA_NAME, + trim(trailing from RDB$RELATION_NAME) as RDB$RELATION_NAME, + RDB$DBKEY_LENGTH, + RDB$DBKEY_LENGTH = 8 as HAS_RECORD_VERSION, + case + -- table, view, GTT preserve + delete: never null + when RDB$RELATION_TYPE in (0, 1, 4, 5) then 'NO' + -- external + virtual: always null + when RDB$RELATION_TYPE in (2, 3) then 'YES' + -- unknown or unsupported (by Jaybird) type: unknown nullability + else '' + end as RECORD_VERSION_NULLABLE + from RDB$RELATIONS"""; + + private static final String GET_PSEUDO_COLUMNS_END_3 = "\norder by RDB$RELATION_NAME"; private FB3(DbMetadataMediator mediator) { super(mediator); @@ -208,12 +218,60 @@ boolean supportsRecordVersion() { } @Override - MetadataQuery createGetPseudoColumnsQuery(String tableNamePattern) { - Clause tableNameClause = new Clause(COLUMN_RELATION_NAME, tableNamePattern); + MetadataQuery createGetPseudoColumnsQuery(String schemaPattern, String tableNamePattern) { + var tableNameClause = new Clause(COLUMN_RELATION_NAME, tableNamePattern); String sql = GET_PSEUDO_COLUMNS_FRAGMENT_3 - + tableNameClause.getCondition("where ", "\n") + + tableNameClause.getCondition("\nwhere ", "") + GET_PSEUDO_COLUMNS_END_3; return new MetadataQuery(sql, Clause.parameters(tableNameClause)); } + } + + private static final class FB6 extends GetPseudoColumns { + + private static final String GET_PSEUDO_COLUMNS_FRAGMENT_6 = """ + select + trim(trailing from RDB$SCHEMA_NAME) as RDB$SCHEMA_NAME, + trim(trailing from RDB$RELATION_NAME) as RDB$RELATION_NAME, + RDB$DBKEY_LENGTH, + RDB$DBKEY_LENGTH = 8 as HAS_RECORD_VERSION, + case + -- table, view, GTT preserve + delete: never null + when RDB$RELATION_TYPE in (0, 1, 4, 5) then 'NO' + -- external + virtual: always null + when RDB$RELATION_TYPE in (2, 3) then 'YES' + -- unknown or unsupported (by Jaybird) type: unknown nullability + else '' + end as RECORD_VERSION_NULLABLE + from SYSTEM.RDB$RELATIONS"""; + + private static final String GET_PSEUDO_COLUMNS_END_6 = "\norder by RDB$SCHEMA_NAME, RDB$RELATION_NAME"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetPseudoColumns createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + boolean supportsRecordVersion() { + return true; + } + + @Override + MetadataQuery createGetPseudoColumnsQuery(String schemaPattern, String tableNamePattern) { + var clauses = List.of( + new Clause(COLUMN_SCHEMA_NAME, schemaPattern), + new Clause(COLUMN_RELATION_NAME, tableNamePattern)); + String sql = GET_PSEUDO_COLUMNS_FRAGMENT_6 + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + + GET_PSEUDO_COLUMNS_END_6; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + + } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java index 9e11dc329..0714e820b 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2018-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2018-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -13,12 +13,15 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeTrue; class FBDatabaseMetaDataPseudoColumnsTest { + // TODO Add schema support: tests involving other schema + //@formatter:off private static final String NORMAL_TABLE_NAME = "NORMAL_TABLE"; private static final String CREATE_NORMAL_TABLE = "create table " + NORMAL_TABLE_NAME + " ( " @@ -136,7 +139,8 @@ void testExternalTable_allPseudoColumns() throws Exception { @Test void testMonitoringTable_allPseudoColumns() throws Exception { assumeTrue(getDefaultSupportInfo().supportsMonitoringTables(), "Test requires monitoring tables"); - List> validationRules = createStandardValidationRules("MON$DATABASE", "YES"); + List> validationRules = + createStandardValidationRules(ifSchemaElse("SYSTEM", null), "MON$DATABASE", "YES"); ResultSet pseudoColumns = dbmd.getPseudoColumns(null, null, "MON$DATABASE", "%"); validate(pseudoColumns, validationRules); @@ -271,10 +275,15 @@ void testPattern_nullTable() throws Exception { private List> createStandardValidationRules(String tableName, String recordVersionNullable) { + return createStandardValidationRules(ifSchemaElse("PUBLIC", null), tableName, recordVersionNullable); + } + + private List> createStandardValidationRules(String schema, String tableName, + String recordVersionNullable) { List> validationRules = new ArrayList<>(); - validationRules.add(createDbkeyValidationRules(tableName, 8)); + validationRules.add(createDbkeyValidationRules(schema, tableName, 8)); if (supportsRecordVersion) { - validationRules.add(createRecordVersionValidationRules(tableName, recordVersionNullable)); + validationRules.add(createRecordVersionValidationRules(schema, tableName, recordVersionNullable)); } return validationRules; } @@ -301,7 +310,7 @@ private void validate(ResultSet pseudoColumns, List defaults = new EnumMap<>(PseudoColumnMetaData.class); defaults.put(PseudoColumnMetaData.TABLE_CAT, null); - defaults.put(PseudoColumnMetaData.TABLE_SCHEM, null); + defaults.put(PseudoColumnMetaData.TABLE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(PseudoColumnMetaData.DECIMAL_DIGITS, null); defaults.put(PseudoColumnMetaData.NUM_PREC_RADIX, 10); defaults.put(PseudoColumnMetaData.COLUMN_USAGE, PseudoColumnUsage.NO_USAGE_RESTRICTIONS.name()); @@ -317,7 +326,13 @@ private static Map getDefaultValueValidationRules( } private Map createDbkeyValidationRules(String tableName, int expectedDbKeyLength) { + return createDbkeyValidationRules(ifSchemaElse("PUBLIC", null), tableName, expectedDbKeyLength); + } + + private Map createDbkeyValidationRules(String schema, String tableName, + int expectedDbKeyLength) { Map rules = getDefaultValueValidationRules(); + rules.put(PseudoColumnMetaData.TABLE_SCHEM, schema); rules.put(PseudoColumnMetaData.TABLE_NAME, tableName); rules.put(PseudoColumnMetaData.COLUMN_NAME, "RDB$DB_KEY"); rules.put(PseudoColumnMetaData.DATA_TYPE, Types.ROWID); @@ -328,7 +343,13 @@ private Map createDbkeyValidationRules(String tabl } private Map createRecordVersionValidationRules(String tableName, String nullable) { + return createRecordVersionValidationRules(ifSchemaElse("PUBLIC", null), tableName, nullable); + } + + private Map createRecordVersionValidationRules(String schema, String tableName, + String nullable) { Map rules = getDefaultValueValidationRules(); + rules.put(PseudoColumnMetaData.TABLE_SCHEM, schema); rules.put(PseudoColumnMetaData.TABLE_NAME, tableName); rules.put(PseudoColumnMetaData.COLUMN_NAME, "RDB$RECORD_VERSION"); rules.put(PseudoColumnMetaData.DATA_TYPE, Types.BIGINT); From 6bdb4bca516423f14bb3748b05181b7a96bfc2bf Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 23 Jun 2025 11:36:16 +0200 Subject: [PATCH 18/64] #882 Schema support for getTablePrivileges Add extra column JB_GRANTEE_SCHEMA to getTablePrivileges and getColumnPrivileges --- src/docs/asciidoc/release_notes.adoc | 11 +- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 10 +- .../jdbc/metadata/GetColumnPrivileges.java | 13 +- .../jdbc/metadata/GetTablePrivileges.java | 148 +++++++++++++----- ...BDatabaseMetaDataColumnPrivilegesTest.java | 4 +- ...FBDatabaseMetaDataTablePrivilegesTest.java | 24 ++- 6 files changed, 156 insertions(+), 54 deletions(-) diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index f820b919b..a633fc778 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -516,16 +516,15 @@ Firebird 6.0 introduces schemas, and Jaybird 7 provides support for schemas as d Changes include: * `DatabaseMetaData` -** `getProcedures` now uses the `schemaPattern` to filter by schema, with the following caveats -*** `schemaPattern` empty will return no rows on Firebird 6.0 and higher (all procedures are in a schema); -use `null` or `"%"` to match all schemas -*** `catalog` is (still) ignored if `useCatalogAsPackage` is `false` +** Methods accepting a `schema` (exact match if not `null`) or `schemaPattern` (`LIKE` match if not `null`) will return no rows for value empty (`""`) on Firebird 6.0 and higher; +use `null` or -- `schemaPattern` only -- `"%"` to match all schemas ** `getSchemas()` returns all defined schemas ** `getSchemas(String catalog, String schemaPattern)` returns all schemas matching the `LIKE` pattern `schemaPattern`, with the following caveats *** `catalog` non-empty will return no rows; we recommend to always use `null` for `catalog` -*** `schemaPattern` empty will return no rows (there are no schemas with an empty name); -use `null` or `"%"` to match all schemas +** `getColumnPrivileges` and `getTablePrivileges` received an additional column, `JB_GRANTEE_SCHEMA`, which is non-``null`` for grantees that are schema-bound (e.g. a procedure). ++ +As this is a non-standard column, we recommend to always retrieve it by name. // TODO add major changes diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index d65133744..8be2d4a60 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1327,10 +1327,12 @@ public ResultSet getColumns(String catalog, String schemaPattern, String tableNa * {@inheritDoc} * *

- * Jaybird defines an additional column: + * Jaybird defines these additional columns: *

    *
  1. JB_GRANTEE_TYPE String => Object type of {@code GRANTEE} (NOTE: Jaybird specific column; * retrieve by name!).
  2. + *
  3. JB_GRANTEE_SCHEMA String => Schema of {@code GRANTEE} if it's a schema-bound object (NOTE: + * Jaybird specific column; retrieve by name!).
  4. *
*

*

@@ -1358,10 +1360,12 @@ public ResultSet getColumnPrivileges(String catalog, String schema, String table * {@inheritDoc} * *

- * Jaybird defines an additional column: + * Jaybird defines these additional columns: *

    *
  1. JB_GRANTEE_TYPE String => Object type of {@code GRANTEE} (NOTE: Jaybird specific column; * retrieve by name!).
  2. + *
  3. JB_GRANTEE_SCHEMA String => Schema of {@code GRANTEE} if it's a schema-bound object (NOTE: + * Jaybird specific column; retrieve by name!).
  4. *
*

*

@@ -1373,7 +1377,7 @@ public ResultSet getColumnPrivileges(String catalog, String schema, String table @Override public ResultSet getTablePrivileges(String catalog, String schemaPattern, String tableNamePattern) throws SQLException { - return GetTablePrivileges.create(getDbMetadataMediator()).getTablePrivileges(tableNamePattern); + return GetTablePrivileges.create(getDbMetadataMediator()).getTablePrivileges(schemaPattern, tableNamePattern); } /** diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java b/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java index 1912e254e..ad6d20843 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java @@ -33,7 +33,7 @@ public abstract class GetColumnPrivileges extends AbstractMetadataMethod { private static final String COLUMNPRIV = "COLUMNPRIV"; - private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(9) + private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(10) .at(0).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "TABLE_CAT", COLUMNPRIV).addField() .at(1).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "TABLE_SCHEM", COLUMNPRIV).addField() .at(2).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "TABLE_NAME", COLUMNPRIV).addField() @@ -43,6 +43,7 @@ public abstract class GetColumnPrivileges extends AbstractMetadataMethod { .at(6).simple(SQL_VARYING, 31, "PRIVILEGE", COLUMNPRIV).addField() .at(7).simple(SQL_VARYING, 3, "IS_GRANTABLE", COLUMNPRIV).addField() .at(8).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "JB_GRANTEE_TYPE", COLUMNPRIV).addField() + .at(9).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "JB_GRANTEE_SCHEMA", COLUMNPRIV).addField() .toRowDescriptor(); GetColumnPrivileges(DbMetadataMediator mediator) { @@ -74,6 +75,7 @@ RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQ .at(6).setString(mapPrivilege(rs.getString("PRIVILEGE"))) .at(7).setString(rs.getBoolean("IS_GRANTABLE") ? "YES" : "NO") .at(8).setString(rs.getString("JB_GRANTEE_TYPE")) + .at(9).setString(rs.getString("JB_GRANTEE_SCHEMA")) .toRowValue(false); } @@ -99,7 +101,8 @@ private static final class FB5 extends GetColumnPrivileges { UP.RDB$USER as GRANTEE, UP.RDB$PRIVILEGE as PRIVILEGE, UP.RDB$GRANT_OPTION as IS_GRANTABLE, - T.RDB$TYPE_NAME as JB_GRANTEE_TYPE + T.RDB$TYPE_NAME as JB_GRANTEE_TYPE, + cast(null as char(1)) as JB_GRANTEE_SCHEMA from RDB$RELATION_FIELDS RF inner join RDB$USER_PRIVILEGES UP on UP.RDB$RELATION_NAME = RF.RDB$RELATION_NAME @@ -149,7 +152,8 @@ private static final class FB6 extends GetColumnPrivileges { trim(trailing from UP.RDB$USER) as GRANTEE, UP.RDB$PRIVILEGE as PRIVILEGE, UP.RDB$GRANT_OPTION as IS_GRANTABLE, - T.RDB$TYPE_NAME as JB_GRANTEE_TYPE + trim(trailing from T.RDB$TYPE_NAME) as JB_GRANTEE_TYPE, + trim(trailing from UP.RDB$USER_SCHEMA_NAME) as JB_GRANTEE_SCHEMA from SYSTEM.RDB$RELATION_FIELDS RF inner join SYSTEM.RDB$USER_PRIVILEGES UP on UP.RDB$RELATION_SCHEMA_NAME = RF.RDB$SCHEMA_NAME and UP.RDB$RELATION_NAME = RF.RDB$RELATION_NAME @@ -162,7 +166,8 @@ private static final class FB6 extends GetColumnPrivileges { // NOTE: Sort by user and schema is not defined in JDBC, but we do this to ensure a consistent order for tests private static final String GET_COLUMN_PRIVILEGES_END_6 = - "\norder by RF.RDB$FIELD_NAME, UP.RDB$PRIVILEGE, UP.RDB$USER, RF.RDB$SCHEMA_NAME"; + "\norder by RF.RDB$FIELD_NAME, UP.RDB$PRIVILEGE, UP.RDB$USER_SCHEMA_NAME nulls first, UP.RDB$USER, " + + "RF.RDB$SCHEMA_NAME"; private FB6(DbMetadataMediator mediator) { super(mediator); diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetTablePrivileges.java b/src/main/org/firebirdsql/jdbc/metadata/GetTablePrivileges.java index fc6610b72..a784e62fd 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetTablePrivileges.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetTablePrivileges.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -10,6 +10,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.List; import static org.firebirdsql.gds.ISCConstants.SQL_VARYING; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.OBJECT_NAME_LENGTH; @@ -27,11 +28,11 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetTablePrivileges extends AbstractMetadataMethod { +public abstract sealed class GetTablePrivileges extends AbstractMetadataMethod { private static final String TABLEPRIV = "TABLEPRIV"; - private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(8) + private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(9) .at(0).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "TABLE_CAT", TABLEPRIV).addField() .at(1).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "TABLE_SCHEM", TABLEPRIV).addField() .at(2).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "TABLE_NAME", TABLEPRIV).addField() @@ -40,63 +41,138 @@ public final class GetTablePrivileges extends AbstractMetadataMethod { .at(5).simple(SQL_VARYING, 31, "PRIVILEGE", TABLEPRIV).addField() .at(6).simple(SQL_VARYING, 3, "IS_GRANTABLE", TABLEPRIV).addField() .at(7).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "JB_GRANTEE_TYPE", TABLEPRIV).addField() + .at(8).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "JB_GRANTEE_SCHEMA", TABLEPRIV).addField() .toRowDescriptor(); - //@formatter:off - private static final String GET_TABLE_PRIVILEGES_START = - // Distinct is needed as we're selecting privileges for the table and columns of the table - "select distinct\n" - + " UP.RDB$RELATION_NAME as TABLE_NAME,\n" - + " UP.RDB$GRANTOR as GRANTOR,\n" - + " UP.RDB$USER as GRANTEE,\n" - + " UP.RDB$PRIVILEGE as PRIVILEGE,\n" - + " UP.RDB$GRANT_OPTION as IS_GRANTABLE,\n" - + " T.RDB$TYPE_NAME as JB_GRANTEE_TYPE\n" - + "from RDB$USER_PRIVILEGES UP\n" - + "left join RDB$TYPES T\n" - + " on T.RDB$FIELD_NAME = 'RDB$OBJECT_TYPE' and T.RDB$TYPE = UP.RDB$USER_TYPE \n" - // Other privileges don't make sense for table privileges - // TODO Consider including ALTER/DROP privileges - + "where UP.RDB$PRIVILEGE in ('A', 'D', 'I', 'R', 'S', 'U')\n" - // Only tables and views - + "and UP.RDB$OBJECT_TYPE in (0, 1)\n"; - - // NOTE: Sort by user is not defined in JDBC, but we do this to ensure a consistent order for tests - private static final String GET_TABLE_PRIVILEGES_END = "order by RDB$RELATION_NAME, RDB$PRIVILEGE, RDB$USER"; - //@formatter:on - private GetTablePrivileges(DbMetadataMediator mediator) { super(ROW_DESCRIPTOR, mediator); } - public ResultSet getTablePrivileges(String tableNamePattern) throws SQLException { + public final ResultSet getTablePrivileges(String schemaPattern, String tableNamePattern) throws SQLException { if ("".equals(tableNamePattern)) { return createEmpty(); } - Clause tableClause = new Clause("RDB$RELATION_NAME", tableNamePattern); - - String sql = GET_TABLE_PRIVILEGES_START - + tableClause.getCondition("and ", "\n") - + GET_TABLE_PRIVILEGES_END; - MetadataQuery metadataQuery = new MetadataQuery(sql, Clause.parameters(tableClause)); + MetadataQuery metadataQuery = createGetTablePrivilegesQuery(schemaPattern, tableNamePattern); return createMetaDataResultSet(metadataQuery); } + abstract MetadataQuery createGetTablePrivilegesQuery(String schemaPattern, String tableNamePattern); + @Override RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { return valueBuilder .at(0).set(null) - .at(1).set(null) + .at(1).setString(rs.getString("TABLE_SCHEM")) .at(2).setString(rs.getString("TABLE_NAME")) .at(3).setString(rs.getString("GRANTOR")) .at(4).setString(rs.getString("GRANTEE")) .at(5).setString(mapPrivilege(rs.getString("PRIVILEGE"))) .at(6).setString(rs.getBoolean("IS_GRANTABLE") ? "YES" : "NO") .at(7).setString(rs.getString("JB_GRANTEE_TYPE")) + .at(8).setString(rs.getString("JB_GRANTEE_SCHEMA")) .toRowValue(false); } public static GetTablePrivileges create(DbMetadataMediator mediator) { - return new GetTablePrivileges(mediator); + // NOTE: Indirection through static method prevents unnecessary classloading + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else { + return FB5.createInstance(mediator); + } + } + + /** + * Implementation for Firebird 5.0 and older. + */ + private static final class FB5 extends GetTablePrivileges { + + // Distinct is needed as we're selecting privileges for the table and columns of the table + private static final String GET_TABLE_PRIVILEGES_START_5 = """ + select distinct + cast(null as char(1)) as TABLE_SCHEM, + UP.RDB$RELATION_NAME as TABLE_NAME, + UP.RDB$GRANTOR as GRANTOR, + UP.RDB$USER as GRANTEE, + UP.RDB$PRIVILEGE as PRIVILEGE, + UP.RDB$GRANT_OPTION as IS_GRANTABLE, + T.RDB$TYPE_NAME as JB_GRANTEE_TYPE, + cast(null as char(1)) as JB_GRANTEE_SCHEMA + from RDB$USER_PRIVILEGES UP + left join RDB$TYPES T + on T.RDB$FIELD_NAME = 'RDB$OBJECT_TYPE' and T.RDB$TYPE = UP.RDB$USER_TYPE + where UP.RDB$PRIVILEGE in ('A', 'D', 'I', 'R', 'S', 'U') -- privileges relevant for tables + and UP.RDB$OBJECT_TYPE in (0, 1) -- Only tables and views"""; + + // NOTE: Sort by user is not defined in JDBC, but we do this to ensure a consistent order for tests + private static final String GET_TABLE_PRIVILEGES_END_5 = + "\norder by UP.RDB$RELATION_NAME, UP.RDB$PRIVILEGE, UP.RDB$USER"; + + private FB5(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetTablePrivileges createInstance(DbMetadataMediator mediator) { + return new FB5(mediator); + } + + @Override + MetadataQuery createGetTablePrivilegesQuery(String schemaPattern, String tableNamePattern) { + Clause tableClause = new Clause("UP.RDB$RELATION_NAME", tableNamePattern); + String sql = GET_TABLE_PRIVILEGES_START_5 + + tableClause.getCondition("\nand ", "") + + GET_TABLE_PRIVILEGES_END_5; + return new MetadataQuery(sql, Clause.parameters(tableClause)); + } + + } + + /** + * Implementation for Firebird 6.0 and newer. + */ + private static final class FB6 extends GetTablePrivileges { + + // Distinct is needed as we're selecting privileges for the table and columns of the table + private static final String GET_TABLE_PRIVILEGES_START_6 = """ + select distinct + trim(trailing from UP.RDB$RELATION_SCHEMA_NAME) as TABLE_SCHEM, + trim(trailing from UP.RDB$RELATION_NAME) as TABLE_NAME, + trim(trailing from UP.RDB$GRANTOR) as GRANTOR, + trim(trailing from UP.RDB$USER) as GRANTEE, + UP.RDB$PRIVILEGE as PRIVILEGE, + UP.RDB$GRANT_OPTION as IS_GRANTABLE, + trim(trailing from T.RDB$TYPE_NAME) as JB_GRANTEE_TYPE, + trim(trailing from UP.RDB$USER_SCHEMA_NAME) as JB_GRANTEE_SCHEMA + from SYSTEM.RDB$USER_PRIVILEGES UP + left join SYSTEM.RDB$TYPES T + on T.RDB$FIELD_NAME = 'RDB$OBJECT_TYPE' and T.RDB$TYPE = UP.RDB$USER_TYPE + where UP.RDB$PRIVILEGE in ('A', 'D', 'I', 'R', 'S', 'U') -- privileges relevant for tables + and UP.RDB$OBJECT_TYPE in (0, 1) -- Only tables and views"""; + + // NOTE: Sort by user schema and user is not defined in JDBC, but we do this to ensure a consistent order for tests + private static final String GET_TABLE_PRIVILEGES_END_6 = + "\norder by UP.RDB$RELATION_SCHEMA_NAME, UP.RDB$RELATION_NAME, UP.RDB$PRIVILEGE, " + + "UP.RDB$USER_SCHEMA_NAME nulls first, UP.RDB$USER"; + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetTablePrivileges createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetTablePrivilegesQuery(String schemaPattern, String tableNamePattern) { + var clauses = List.of( + new Clause("UP.RDB$RELATION_SCHEMA_NAME", schemaPattern), + new Clause("UP.RDB$RELATION_NAME", tableNamePattern)); + String sql = GET_TABLE_PRIVILEGES_START_6 + + (Clause.anyCondition(clauses) ? "\nand " + Clause.conjunction(clauses) : "") + + GET_TABLE_PRIVILEGES_END_6; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java index 9fba1fa3b..33358b0d4 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java @@ -231,6 +231,7 @@ private void validateExpectedColumnPrivileges(String schema, String table, Strin defaults.put(ColumnPrivilegesMetadata.TABLE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(ColumnPrivilegesMetadata.GRANTOR, SYSDBA); defaults.put(ColumnPrivilegesMetadata.JB_GRANTEE_TYPE, "USER"); + defaults.put(ColumnPrivilegesMetadata.JB_GRANTEE_SCHEMA, null); DEFAULT_COLUMN_PRIVILEGES_VALUES = Collections.unmodifiableMap(defaults); } @@ -248,7 +249,8 @@ private enum ColumnPrivilegesMetadata implements MetaDataInfo { GRANTEE(6), PRIVILEGE(7), IS_GRANTABLE(8), - JB_GRANTEE_TYPE(9); + JB_GRANTEE_TYPE(9), + JB_GRANTEE_SCHEMA(10); private final int position; diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java index 77c021aed..ebed9a646 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2023-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2023-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -21,6 +21,7 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -32,6 +33,8 @@ */ class FBDatabaseMetaDataTablePrivilegesTest { + // TODO Add schema support: tests involving other schema + private static final String SYSDBA = "SYSDBA"; private static final String USER1 = "USER1"; private static final String user2 = getDefaultSupportInfo().supportsCaseSensitiveUserNames() ? "user2" : "USER2"; @@ -116,7 +119,13 @@ void testColumnPrivileges_tbl2_all() throws Exception { private Map createRule(String tableName, String grantee, boolean grantable, String privilege) { + return createRule(ifSchemaElse("PUBLIC", null), tableName, grantee, grantable, privilege); + } + + private Map createRule(String schema, String tableName, String grantee, + boolean grantable, String privilege) { Map rules = getDefaultValueValidationRules(); + rules.put(TablePrivilegesMetadata.TABLE_SCHEM, schema); rules.put(TablePrivilegesMetadata.TABLE_NAME, tableName); rules.put(TablePrivilegesMetadata.GRANTEE, grantee); rules.put(TablePrivilegesMetadata.PRIVILEGE, privilege); @@ -126,7 +135,12 @@ private Map createRule(String tableName, String private void validateExpectedColumnPrivileges(String tableNamePattern, List> expectedTablePrivileges) throws SQLException { - try (ResultSet tablePrivileges = dbmd.getTablePrivileges(null, null, tableNamePattern)) { + validateExpectedColumnPrivileges(null, tableNamePattern, expectedTablePrivileges); + } + + private void validateExpectedColumnPrivileges(String schemaPattern, String tableNamePattern, + List> expectedTablePrivileges) throws SQLException { + try (ResultSet tablePrivileges = dbmd.getTablePrivileges(null, schemaPattern, tableNamePattern)) { int privilegeCount = 0; while (tablePrivileges.next()) { if (privilegeCount < expectedTablePrivileges.size()) { @@ -144,9 +158,10 @@ private void validateExpectedColumnPrivileges(String tableNamePattern, static { Map defaults = new EnumMap<>(TablePrivilegesMetadata.class); defaults.put(TablePrivilegesMetadata.TABLE_CAT, null); - defaults.put(TablePrivilegesMetadata.TABLE_SCHEM, null); + defaults.put(TablePrivilegesMetadata.TABLE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(TablePrivilegesMetadata.GRANTOR, SYSDBA); defaults.put(TablePrivilegesMetadata.JB_GRANTEE_TYPE, "USER"); + defaults.put(TablePrivilegesMetadata.JB_GRANTEE_SCHEMA, null); DEFAULT_TABLE_PRIVILEGES_VALUES = Collections.unmodifiableMap(defaults); } @@ -163,7 +178,8 @@ private enum TablePrivilegesMetadata implements MetaDataInfo { GRANTEE(5), PRIVILEGE(6), IS_GRANTABLE(7), - JB_GRANTEE_TYPE(8); + JB_GRANTEE_TYPE(8), + JB_GRANTEE_SCHEMA(9); private final int position; From 73ebef59dde2832949f71f589a7fc5bdf409cf41 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 23 Jun 2025 14:14:16 +0200 Subject: [PATCH 19/64] #882 Schema support for getTables --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 2 +- .../firebirdsql/jdbc/metadata/GetTables.java | 210 ++++++++++++------ .../jdbc/FBDatabaseMetaDataTablesTest.java | 9 +- 3 files changed, 146 insertions(+), 75 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 8be2d4a60..9b8005469 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1251,7 +1251,7 @@ public ResultSet getProcedureColumns(String catalog, String schemaPattern, Strin @Override public ResultSet getTables(String catalog, String schemaPattern, String tableNamePattern, String[] types) throws SQLException { - return createGetTablesInstance().getTables(tableNamePattern, types); + return createGetTablesInstance().getTables(schemaPattern, tableNamePattern, types); } private GetTables createGetTablesInstance() { diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetTables.java b/src/main/org/firebirdsql/jdbc/metadata/GetTables.java index 3c7cdfa98..e250be3ef 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetTables.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetTables.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -9,7 +9,6 @@ import org.firebirdsql.jdbc.DbMetadataMediator; import org.firebirdsql.jdbc.DbMetadataMediator.MetadataQuery; import org.firebirdsql.jdbc.FBResultSet; -import org.firebirdsql.util.InternalApi; import java.sql.ResultSet; import java.sql.SQLException; @@ -41,12 +40,19 @@ * @author Mark Rotteveel * @since 5 */ -@InternalApi -public abstract class GetTables extends AbstractMetadataMethod { +public abstract sealed class GetTables extends AbstractMetadataMethod { + private static final String LEGACY_IS_TABLE = "rdb$relation_type is null and rdb$view_blr is null"; + private static final String LEGACY_IS_VIEW = "rdb$relation_type is null and rdb$view_blr is not null"; private static final String TABLES = "TABLES"; private static final String TABLE_TYPE = "TABLE_TYPE"; + /** + * All table types supported for Firebird 2.5 and higher + */ + private static final Set ALL_TYPES = unmodifiableSet(new LinkedHashSet<>( + Arrays.asList(GLOBAL_TEMPORARY, SYSTEM_TABLE, TABLE, VIEW))); + private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(12) .at(0).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "TABLE_CAT", TABLES).addField() .at(1).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "TABLE_SCHEM", TABLES).addField() @@ -75,19 +81,19 @@ private GetTables(DbMetadataMediator mediator) { /** * @see java.sql.DatabaseMetaData#getTables(String, String, String, String[]) */ - public final ResultSet getTables(String tableNamePattern, String[] types) throws SQLException { + public final ResultSet getTables(String schemaPattern, String tableNamePattern, String[] types) throws SQLException { if ("".equals(tableNamePattern) || types != null && types.length == 0) { // Matching table name not possible return createEmpty(); } - - MetadataQuery metadataQuery = createGetTablesQuery(tableNamePattern, toTypesSet(types)); + MetadataQuery metadataQuery = createGetTablesQuery(schemaPattern, tableNamePattern, toTypesSet(types)); return createMetaDataResultSet(metadataQuery); } @Override final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { return valueBuilder + .at(1).setString(rs.getString("TABLE_SCHEM")) .at(2).setString(rs.getString("TABLE_NAME")) .at(3).setString(rs.getString(TABLE_TYPE)) .at(4).setString(rs.getString("REMARKS")) @@ -116,7 +122,7 @@ private Set toTypesSet(String[] types) { return types != null ? new HashSet<>(Arrays.asList(types)) : allTableTypes(); } - abstract MetadataQuery createGetTablesQuery(String tableNamePattern, Set types); + abstract MetadataQuery createGetTablesQuery(String schemaPattern, String tableNamePattern, Set types); /** * All supported table types. @@ -126,7 +132,9 @@ private Set toTypesSet(String[] types) { * * @return supported table types */ - abstract Set allTableTypes(); + Set allTableTypes() { + return ALL_TYPES; + } /** * The ODS of a Firebird 2.5 database. @@ -135,13 +143,48 @@ private Set toTypesSet(String[] types) { public static GetTables create(DbMetadataMediator mediator) { // NOTE: Indirection through static method prevents unnecessary classloading - if (mediator.getOdsVersion().compareTo(ODS_11_2) >= 0) { + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6.createInstance(mediator); + } else if (mediator.getOdsVersion().compareTo(ODS_11_2) >= 0) { return FB2_5.createInstance(mediator); } else { return FB2_1.createInstance(mediator); } } + void buildTypeCondition(StringBuilder sb, Set types) { + final int initialLength = sb.length(); + if (types.contains(SYSTEM_TABLE) && types.contains(TABLE)) { + sb.append("(rdb$relation_type in (0, 2, 3) or " + LEGACY_IS_TABLE + ")"); + } else if (types.contains(SYSTEM_TABLE)) { + // We assume that external tables are never system and that virtual tables are always system + sb.append("(rdb$relation_type in (0, 3) or " + LEGACY_IS_TABLE + ") and rdb$system_flag = 1"); + } else if (types.contains(TABLE)) { + // We assume that external tables are never system and that virtual tables are always system + sb.append("(rdb$relation_type in (0, 2) or " + LEGACY_IS_TABLE + ") and rdb$system_flag = 0"); + } + + if (types.contains(VIEW)) { + if (sb.length() != initialLength) { + sb.append(" or "); + } + // We assume (but don't check) that views are never system + sb.append("(rdb$relation_type = 1 or " + LEGACY_IS_VIEW + ")"); + } + + if (types.contains(GLOBAL_TEMPORARY)) { + if (sb.length() != initialLength) { + sb.append(" or "); + } + sb.append("rdb$relation_type in (4, 5)"); + } + + if (sb.length() == initialLength) { + // Requested types are unknown, query nothing + sb.append("1 = 0"); + } + } + @SuppressWarnings("java:S101") private static final class FB2_1 extends GetTables { @@ -150,7 +193,7 @@ private static final class FB2_1 extends GetTables { private static final String TABLE_COLUMNS_NORMAL_2_1 = formatTableQuery(TABLE, "RDB$SYSTEM_FLAG = 0 and rdb$view_blr is null"); private static final String TABLE_COLUMNS_VIEW_2_1 = formatTableQuery(VIEW, "rdb$view_blr is not null"); - private static final String GET_TABLE_ORDER_BY_2_1 = "\norder by 2, 1"; + private static final String GET_TABLE_ORDER_BY_2_1 = "\norder by 3, 2"; private static final Map QUERY_PER_TYPE; static { @@ -176,7 +219,7 @@ private static GetTables createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetTablesQuery(String tableNamePattern, Set types) { + MetadataQuery createGetTablesQuery(String schemaPattern, String tableNamePattern, Set types) { var tableNameClause = new Clause("RDB$RELATION_NAME", tableNamePattern); var clauses = new ArrayList(types.size()); var queryBuilder = new StringBuilder(2000); @@ -203,6 +246,7 @@ Set allTableTypes() { private static String formatTableQuery(String tableType, String condition) { return String.format(""" select + cast(null as char(1)) as TABLE_SCHEM, RDB$RELATION_NAME as TABLE_NAME, cast('%s' as varchar(31)) as TABLE_TYPE, RDB$DESCRIPTION as REMARKS, @@ -217,34 +261,27 @@ private static String formatTableQuery(String tableType, String condition) { @SuppressWarnings("java:S101") private static final class FB2_5 extends GetTables { - private static final String GET_TABLE_ORDER_BY_2_5 = "\norder by 2, 1"; + private static final String GET_TABLE_ORDER_BY_2_5 = "\norder by 3, 2"; //@formatter:off - private static final String LEGACY_IS_TABLE = "rdb$relation_type is null and rdb$view_blr is null"; - private static final String LEGACY_IS_VIEW = "rdb$relation_type is null and rdb$view_blr is not null"; - - private static final String TABLE_COLUMNS_2_5 = - "select\n" - + " trim(trailing from RDB$RELATION_NAME) as TABLE_NAME,\n" - + " trim(trailing from case" - + " when rdb$relation_type = 0 or " + LEGACY_IS_TABLE + " then case when RDB$SYSTEM_FLAG = 1 then '" + SYSTEM_TABLE + "' else '" + TABLE + "' end\n" - + " when rdb$relation_type = 1 or " + LEGACY_IS_VIEW + " then '" + VIEW + "'\n" - + " when rdb$relation_type = 2 then '" + TABLE + "'\n" // external table; assume as normal table - + " when rdb$relation_type = 3 then '" + SYSTEM_TABLE + "'\n" // virtual (monitoring) table: assume system - + " when rdb$relation_type in (4, 5) then '" + GLOBAL_TEMPORARY + "'\n" - + " end) as TABLE_TYPE,\n" - + " RDB$DESCRIPTION as REMARKS,\n" - + " trim(trailing from RDB$OWNER_NAME) as OWNER_NAME,\n" - + " RDB$RELATION_ID as JB_RELATION_ID\n" - + "from RDB$RELATIONS"; + private static final String TABLE_COLUMNS_2_5 = """ + select + null as TABLE_SCHEM, + trim(trailing from RDB$RELATION_NAME) as TABLE_NAME, + trim(trailing from case + """ + + " when rdb$relation_type = 0 or " + LEGACY_IS_TABLE + " then case when RDB$SYSTEM_FLAG = 1 then '" + SYSTEM_TABLE + "' else '" + TABLE + "' end\n" + + " when rdb$relation_type = 1 or " + LEGACY_IS_VIEW + " then '" + VIEW + "'\n" + + " when rdb$relation_type = 2 then '" + TABLE + "' -- external table; assume as normal table\n" + + " when rdb$relation_type = 3 then '" + SYSTEM_TABLE + "' -- virtual (monitoring) table: assume system\n" + + " when rdb$relation_type in (4, 5) then '" + GLOBAL_TEMPORARY + "'\n" + """ + end) as TABLE_TYPE, + RDB$DESCRIPTION as REMARKS, + trim(trailing from RDB$OWNER_NAME) as OWNER_NAME, + RDB$RELATION_ID as JB_RELATION_ID + from RDB$RELATIONS"""; //@formatter:on - /** - * All table types supported for Firebird 2.5 and higher - */ - private static final Set ALL_TYPES_2_5 = unmodifiableSet(new LinkedHashSet<>( - Arrays.asList(GLOBAL_TEMPORARY, SYSTEM_TABLE, TABLE, VIEW))); - private FB2_5(DbMetadataMediator mediator) { super(mediator); } @@ -254,7 +291,7 @@ private static GetTables createInstance(DbMetadataMediator mediator) { } @Override - MetadataQuery createGetTablesQuery(String tableNamePattern, Set types) { + MetadataQuery createGetTablesQuery(String schemaPattern, String tableNamePattern, Set types) { var tableNameClause = new Clause("RDB$RELATION_NAME", tableNamePattern); var queryBuilder = new StringBuilder(1000).append(TABLE_COLUMNS_2_5); @@ -266,13 +303,15 @@ MetadataQuery createGetTablesQuery(String tableNamePattern, Set types) { params = Collections.emptyList(); } - if (!types.containsAll(ALL_TYPES_2_5)) { + if (!types.containsAll(ALL_TYPES)) { // Only construct conditions when we don't query for all - StringBuilder typeCondition = buildTypeCondition(types); if (tableNameClause.hasCondition()) { - queryBuilder.append("\nand (").append(typeCondition).append(")"); + queryBuilder.append("\nand ("); + buildTypeCondition(queryBuilder, types); + queryBuilder.append(")"); } else { - queryBuilder.append("\nwhere ").append(typeCondition); + queryBuilder.append("\nwhere "); + buildTypeCondition(queryBuilder, types); } } queryBuilder.append(GET_TABLE_ORDER_BY_2_5); @@ -280,43 +319,70 @@ MetadataQuery createGetTablesQuery(String tableNamePattern, Set types) { return new MetadataQuery(queryBuilder.toString(), params); } - private static StringBuilder buildTypeCondition(Set types) { - var typeCondition = new StringBuilder(120); - if (types.contains(SYSTEM_TABLE) && types.contains(TABLE)) { - typeCondition.append("(rdb$relation_type in (0, 2, 3) or " + LEGACY_IS_TABLE + ")"); - } else if (types.contains(SYSTEM_TABLE)) { - // We assume that external tables are never system and that virtual tables are always system - typeCondition.append("(rdb$relation_type in (0, 3) or " + LEGACY_IS_TABLE + ") and rdb$system_flag = 1"); - } else if (types.contains(TABLE)) { - // We assume that external tables are never system and that virtual tables are always system - typeCondition.append("(rdb$relation_type in (0, 2) or " + LEGACY_IS_TABLE + ") and rdb$system_flag = 0"); - } + } - if (types.contains(VIEW)) { - if (!typeCondition.isEmpty()) { - typeCondition.append(" or "); - } - // We assume (but don't check) that views are never system - typeCondition.append("(rdb$relation_type = 1 or " + LEGACY_IS_VIEW + ")"); + private static final class FB6 extends GetTables { + + private static final String GET_TABLE_ORDER_BY_6 = "\norder by 3, 1, 2"; + + //@formatter:off + private static final String TABLE_COLUMNS_6 = """ + select + trim(trailing from RDB$SCHEMA_NAME) as TABLE_SCHEM, + trim(trailing from RDB$RELATION_NAME) as TABLE_NAME, + trim(trailing from case + """ + + " when rdb$relation_type = 0 or " + LEGACY_IS_TABLE + " then case when RDB$SYSTEM_FLAG = 1 then '" + SYSTEM_TABLE + "' else '" + TABLE + "' end\n" + + " when rdb$relation_type = 1 or " + LEGACY_IS_VIEW + " then '" + VIEW + "'\n" + + " when rdb$relation_type = 2 then '" + TABLE + "' -- external table; assume as normal table\n" + + " when rdb$relation_type = 3 then '" + SYSTEM_TABLE + "' -- virtual (monitoring) table: assume system\n" + + " when rdb$relation_type in (4, 5) then '" + GLOBAL_TEMPORARY + "'\n" + """ + end) as TABLE_TYPE, + RDB$DESCRIPTION as REMARKS, + trim(trailing from RDB$OWNER_NAME) as OWNER_NAME, + RDB$RELATION_ID as JB_RELATION_ID + from SYSTEM.RDB$RELATIONS"""; + //@formatter:on + + private FB6(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetTables createInstance(DbMetadataMediator mediator) { + return new FB6(mediator); + } + + @Override + MetadataQuery createGetTablesQuery(String schemaPattern, String tableNamePattern, Set types) { + var clauses = List.of( + new Clause("RDB$SCHEMA_NAME", schemaPattern), + new Clause("RDB$RELATION_NAME", tableNamePattern)); + + var queryBuilder = new StringBuilder(1000).append(TABLE_COLUMNS_6); + List params; + if (Clause.anyCondition(clauses)) { + queryBuilder.append("\nwhere ").append(Clause.conjunction(clauses)); + params = Clause.parameters(clauses); + } else { + params = Collections.emptyList(); } - if (types.contains(GLOBAL_TEMPORARY)) { - if (!typeCondition.isEmpty()) { - typeCondition.append(" or "); + if (!types.containsAll(ALL_TYPES)) { + // Only construct conditions when we don't query for all + if (Clause.anyCondition(clauses)) { + queryBuilder.append("\nand ("); + buildTypeCondition(queryBuilder, types); + queryBuilder.append(")"); + } else { + queryBuilder.append("\nwhere "); + buildTypeCondition(queryBuilder, types); } - typeCondition.append("rdb$relation_type in (4, 5)"); } + queryBuilder.append(GET_TABLE_ORDER_BY_6); - if (typeCondition.isEmpty()) { - // Requested types are unknown, query nothing - typeCondition.append("1 = 0"); - } - return typeCondition; + return new MetadataQuery(queryBuilder.toString(), params); } - @Override - Set allTableTypes() { - return ALL_TYPES_2_5; - } } + } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java index dd0984f46..f4bf1dfcb 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2012-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -17,6 +17,7 @@ import static java.lang.String.format; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.hamcrest.CoreMatchers.anyOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -35,6 +36,8 @@ */ class FBDatabaseMetaDataTablesTest { + // TODO Add schema support: tests involving other schema + // Valid values for TABLE_TYPE (separate from those defined in FBDatabaseMetaData for testing) private static final String VIEW = "VIEW"; private static final String TABLE = "TABLE"; @@ -233,6 +236,7 @@ private void validateTableMetaData_allSystemTables(String tableNamePattern) thro Set expectedTables = new HashSet<>(Arrays.asList("RDB$FIELDS", "RDB$GENERATORS", "RDB$ROLES", "RDB$DATABASE", "RDB$TRIGGERS")); Map rules = getDefaultValueValidationRules(); + rules.put(TableMetaData.TABLE_SCHEM, ifSchemaElse("SYSTEM", null)); rules.put(TableMetaData.TABLE_TYPE, SYSTEM_TABLE); try (ResultSet tables = dbmd.getTables(null, null, tableNamePattern, new String[] { SYSTEM_TABLE })) { while (tables.next()) { @@ -550,6 +554,7 @@ private void validateTableMetaDataNoRow(String tableNamePattern, String[] types) private void updateTableRules(String tableName, Map rules) { rules.put(TableMetaData.TABLE_NAME, tableName); if (tableName.startsWith("RDB$") || tableName.startsWith("MON$") || tableName.startsWith("SEC$")) { + rules.put(TableMetaData.TABLE_SCHEM, ifSchemaElse("SYSTEM", null)); rules.put(TableMetaData.TABLE_TYPE, SYSTEM_TABLE); } else if (tableName.equals("TEST_NORMAL_TABLE") || tableName.equals("test_quoted_normal_table") || tableName.equals("testquotedwith\\table")) { @@ -570,7 +575,7 @@ private void updateTableRules(String tableName, Map rules static { Map defaults = new EnumMap<>(TableMetaData.class); defaults.put(TableMetaData.TABLE_CAT, null); - defaults.put(TableMetaData.TABLE_SCHEM, null); + defaults.put(TableMetaData.TABLE_SCHEM, ifSchemaElse("PUBLIC", null)); defaults.put(TableMetaData.REMARKS, null); defaults.put(TableMetaData.TYPE_CAT, null); defaults.put(TableMetaData.TYPE_SCHEM, null); From 90ce855d072ba5aeecb7073615d5890867c7c80c Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 23 Jun 2025 14:24:38 +0200 Subject: [PATCH 20/64] #882 Schema support for getCatalogs --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 1 + src/docs/asciidoc/release_notes.adoc | 9 ++-- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 3 +- .../jdbc/metadata/GetCatalogs.java | 46 ++++++++++++++----- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index 467553bb6..960ac3bda 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -94,6 +94,7 @@ Jaybird cannot honour this requirement for plain `Statement`, as schema resoluti *** Maybe make it search `PUBLIC`, or `PUBLIC` and `SYSTEM`, or those on the search path? *** Or add a compatibility connection property to make it behave as `null` ("`__[...] means that the schema name should not be used to narrow the search__`")? *** Or just accept it as a breaking change? +** `getCatalogs`: TODO: Maybe add a custom column with a list of schema names for `useCatalogAsPackage=true`? * TODO: Define effects for management API Note to self: use `// TODO Add schema support` in places that you identify need to get/improve schema support, while working on schema support elsewhere diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index a633fc778..3a7fc7dc9 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -518,13 +518,16 @@ Changes include: * `DatabaseMetaData` ** Methods accepting a `schema` (exact match if not `null`) or `schemaPattern` (`LIKE` match if not `null`) will return no rows for value empty (`""`) on Firebird 6.0 and higher; use `null` or -- `schemaPattern` only -- `"%"` to match all schemas +** `getCatalogs` -- when `useCatalogAsPackage=true` -- returns all (distinct) package names over all schemas. +Within the limitations and specification of the JDBC API, this method cannot be used to find out which schema(s) contain a specific package name. +// TODO Maybe add a custom column with a list of schema names? +** `getColumnPrivileges` and `getTablePrivileges` received an additional column, `JB_GRANTEE_SCHEMA`, which is non-``null`` for grantees that are schema-bound (e.g. a procedure). ++ +As this is a non-standard column, we recommend to always retrieve it by name. ** `getSchemas()` returns all defined schemas ** `getSchemas(String catalog, String schemaPattern)` returns all schemas matching the `LIKE` pattern `schemaPattern`, with the following caveats *** `catalog` non-empty will return no rows; we recommend to always use `null` for `catalog` -** `getColumnPrivileges` and `getTablePrivileges` received an additional column, `JB_GRANTEE_SCHEMA`, which is non-``null`` for grantees that are schema-bound (e.g. a procedure). -+ -As this is a non-standard column, we recommend to always retrieve it by name. // TODO add major changes diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 9b8005469..57b203947 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1272,7 +1272,8 @@ public ResultSet getSchemas(String catalog, String schemaPattern) throws SQLExce * {@inheritDoc} *

* When {@code useCatalogAsPackage = true} and packages are supported, this method will return the package names in - * column {@code TABLE_CAT}. + * column {@code TABLE_CAT}. In Firebird 6.0, packages are schema-bound, so finding procedures or functions in a + * package may require you to search in a specific schema, or in all schemas. *

> */ @Override diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetCatalogs.java b/src/main/org/firebirdsql/jdbc/metadata/GetCatalogs.java index c8473819a..314f05d84 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetCatalogs.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetCatalogs.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2023 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -24,6 +24,8 @@ */ public sealed class GetCatalogs extends AbstractMetadataMethod { + // TODO Add schema support: maybe add a custom column with a list of schema names for useCatalogsAsPackage? + private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(1) .at(0).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "TABLE_CAT", "TABLECATALOGS").addField() .toRowDescriptor(); @@ -38,7 +40,11 @@ public ResultSet getCatalogs() throws SQLException { public static GetCatalogs create(DbMetadataMediator mediator) { if (mediator.isUseCatalogAsPackage()) { - return CatalogAsPackage.createInstance(mediator); + if (mediator.getFirebirdSupportInfo().isVersionEqualOrAbove(6)) { + return FB6CatalogAsPackage.createInstance(mediator); + } else { + return FB3CatalogAsPackage.createInstance(mediator); + } } else { return new GetCatalogs(mediator); } @@ -46,17 +52,19 @@ public static GetCatalogs create(DbMetadataMediator mediator) { @Override RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { - throw new AssertionError("should not get called"); + return valueBuilder + .at(0).setString(rs.getString("PACKAGE_NAME")) + .toRowValue(false); } - private static final class CatalogAsPackage extends GetCatalogs { + private static final class FB3CatalogAsPackage extends GetCatalogs { - private CatalogAsPackage(DbMetadataMediator mediator) { + private FB3CatalogAsPackage(DbMetadataMediator mediator) { super(mediator); } private static GetCatalogs createInstance(DbMetadataMediator mediator) { - return new CatalogAsPackage(mediator); + return new FB3CatalogAsPackage(mediator); } @Override @@ -68,11 +76,27 @@ select trim(trailing from RDB$PACKAGE_NAME) as PACKAGE_NAME return createMetaDataResultSet(metadataQuery); } + } + + private static final class FB6CatalogAsPackage extends GetCatalogs { + + private FB6CatalogAsPackage(DbMetadataMediator mediator) { + super(mediator); + } + + private static GetCatalogs createInstance(DbMetadataMediator mediator) { + return new FB6CatalogAsPackage(mediator); + } + @Override - RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) throws SQLException { - return valueBuilder - .at(0).setString(rs.getString("PACKAGE_NAME")) - .toRowValue(false); + public ResultSet getCatalogs() throws SQLException { + var metadataQuery = new MetadataQuery(""" + select distinct trim(trailing from RDB$PACKAGE_NAME) as PACKAGE_NAME + from SYSTEM.RDB$PACKAGES + order by 1""", List.of()); + return createMetaDataResultSet(metadataQuery); } + } + } From 11349a7c7d830916f8f48d85b3e63997d897eb8f Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 23 Jun 2025 14:41:44 +0200 Subject: [PATCH 21/64] Mark metadata classes sealed or final --- .../firebirdsql/jdbc/metadata/GetBestRowIdentifier.java | 2 +- .../firebirdsql/jdbc/metadata/GetColumnPrivileges.java | 2 +- src/main/org/firebirdsql/jdbc/metadata/GetColumns.java | 8 ++++---- .../org/firebirdsql/jdbc/metadata/GetCrossReference.java | 2 +- .../org/firebirdsql/jdbc/metadata/GetExportedKeys.java | 2 +- .../org/firebirdsql/jdbc/metadata/GetFunctionColumns.java | 2 +- src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java | 2 +- .../org/firebirdsql/jdbc/metadata/GetImportedKeys.java | 2 +- .../org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java | 2 +- .../firebirdsql/jdbc/metadata/GetProcedureColumns.java | 8 ++++---- src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java | 2 +- .../org/firebirdsql/jdbc/metadata/GetPseudoColumns.java | 2 +- src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java | 2 +- .../org/firebirdsql/jdbc/metadata/GetVersionColumns.java | 6 +++--- 14 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java index eed40154f..a2cf72228 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java @@ -42,7 +42,7 @@ * @author Mark Rotteveel * @since 5 */ -public abstract class GetBestRowIdentifier extends AbstractMetadataMethod { +public abstract sealed class GetBestRowIdentifier extends AbstractMetadataMethod { private static final String ROWIDENTIFIER = "ROWIDENTIFIER"; diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java b/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java index ad6d20843..01858fc43 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java @@ -30,7 +30,7 @@ * @author Mark Rotteveel * @since 5 */ -public abstract class GetColumnPrivileges extends AbstractMetadataMethod { +public abstract sealed class GetColumnPrivileges extends AbstractMetadataMethod { private static final String COLUMNPRIV = "COLUMNPRIV"; private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(10) diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetColumns.java index 4cb1c46f4..6afb758cc 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetColumns.java @@ -36,7 +36,7 @@ * @author Mark Rotteveel * @since 5 */ -public abstract class GetColumns extends AbstractMetadataMethod { +public abstract sealed class GetColumns extends AbstractMetadataMethod { private static final String COLUMNINFO = "COLUMNINFO"; @@ -156,7 +156,7 @@ public static GetColumns create(DbMetadataMediator mediator) { } @SuppressWarnings("java:S101") - private static class FB2_5 extends GetColumns { + private static final class FB2_5 extends GetColumns { private static final String GET_COLUMNS_FRAGMENT_2_5 = """ select @@ -203,7 +203,7 @@ MetadataQuery createGetColumnsQuery(String schemaPattern, String tableNamePatter } - private static class FB3 extends GetColumns { + private static final class FB3 extends GetColumns { private static final String GET_COLUMNS_FRAGMENT_3 = """ select @@ -250,7 +250,7 @@ MetadataQuery createGetColumnsQuery(String schemaPattern, String tableNamePatter } - private static class FB6 extends GetColumns { + private static final class FB6 extends GetColumns { private static final String GET_COLUMNS_FRAGMENT_6 = """ select diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java b/src/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java index 47369e451..36f9f0500 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java @@ -18,7 +18,7 @@ * @author Mark Rotteveel * @since 5 */ -public abstract class GetCrossReference extends AbstractKeysMethod { +public abstract sealed class GetCrossReference extends AbstractKeysMethod { private GetCrossReference(DbMetadataMediator mediator) { super(mediator); diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetExportedKeys.java b/src/main/org/firebirdsql/jdbc/metadata/GetExportedKeys.java index 0434e8853..2459ea103 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetExportedKeys.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetExportedKeys.java @@ -18,7 +18,7 @@ * @author Mark Rotteveel * @since 5 */ -public abstract class GetExportedKeys extends AbstractKeysMethod { +public abstract sealed class GetExportedKeys extends AbstractKeysMethod { private GetExportedKeys(DbMetadataMediator mediator) { super(mediator); diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java index c7c464109..88ec1425e 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java @@ -41,7 +41,7 @@ */ @InternalApi @SuppressWarnings({ "java:S1192", "java:S5665" }) -public abstract class GetFunctionColumns extends AbstractMetadataMethod { +public abstract sealed class GetFunctionColumns extends AbstractMetadataMethod { private static final String FUNCTION_COLUMNS = "FUNCTION_COLUMNS"; diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java b/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java index 9f6c0dfba..0d14ef598 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java @@ -28,7 +28,7 @@ * @since 4.0 */ @InternalApi -public abstract class GetFunctions extends AbstractMetadataMethod { +public abstract sealed class GetFunctions extends AbstractMetadataMethod { private static final String FUNCTIONS = "FUNCTIONS"; private static final String COLUMN_CATALOG_NAME = "RDB$PACKAGE_NAME"; diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java b/src/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java index 8c264b6fb..ad0fc6bad 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java @@ -18,7 +18,7 @@ * @author Mark Rotteveel * @since 5 */ -public abstract class GetImportedKeys extends AbstractKeysMethod { +public abstract sealed class GetImportedKeys extends AbstractKeysMethod { private GetImportedKeys(DbMetadataMediator mediator) { super(mediator); diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java b/src/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java index 4908edb89..9ef6a16da 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java @@ -23,7 +23,7 @@ * @author Mark Rotteveel * @since 5 */ -public abstract class GetPrimaryKeys extends AbstractMetadataMethod { +public abstract sealed class GetPrimaryKeys extends AbstractMetadataMethod { private static final String COLUMNINFO = "COLUMNINFO"; diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java index 6d1fb9ca5..3567fbf4e 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java @@ -39,7 +39,7 @@ * @since 5 */ @SuppressWarnings("java:S1192") -public abstract class GetProcedureColumns extends AbstractMetadataMethod { +public abstract sealed class GetProcedureColumns extends AbstractMetadataMethod { private static final String COLUMNINFO = "COLUMNINFO"; private static final String COLUMN_SCHEMA_NAME = "PP.RDB$SCHEMA_NAME"; @@ -153,7 +153,7 @@ public static GetProcedureColumns create(DbMetadataMediator mediator) { } @SuppressWarnings("java:S101") - private static class FB2_5 extends GetProcedureColumns { + private static final class FB2_5 extends GetProcedureColumns { //@formatter:off private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_2_5 = """ @@ -203,7 +203,7 @@ MetadataQuery createGetProcedureColumnsQuery(String catalog, String schemaPatter } - private static class FB3 extends GetProcedureColumns { + private static final class FB3 extends GetProcedureColumns { //@formatter:off private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_3 = """ @@ -318,7 +318,7 @@ MetadataQuery createGetProcedureColumnsQuery(String catalog, String schemaPatter } - private static class FB6 extends GetProcedureColumns { + private static final class FB6 extends GetProcedureColumns { //@formatter:off private static final String GET_PROCEDURE_COLUMNS_FRAGMENT_6 = """ diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java index 2d277313e..65a089d1a 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java @@ -29,7 +29,7 @@ * @since 5 */ @InternalApi -public abstract class GetProcedures extends AbstractMetadataMethod { +public abstract sealed class GetProcedures extends AbstractMetadataMethod { private static final String PROCEDURES = "PROCEDURES"; private static final String COLUMN_PROCEDURE_NAME = "RDB$PROCEDURE_NAME"; diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java index 28ec287f9..af25c3620 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java @@ -29,7 +29,7 @@ * @author Mark Rotteveel * @since 5 */ -public abstract class GetPseudoColumns { +public abstract sealed class GetPseudoColumns { private static final String PSEUDOCOLUMNS = "PSEUDOCOLUMNS"; public static final String COLUMN_SCHEMA_NAME = "RDB$SCHEMA_NAME"; diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java index 514a924d0..fd9d152a3 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java @@ -21,7 +21,7 @@ * @author Mark Rotteveel * @since 5 */ -public abstract class GetSchemas extends AbstractMetadataMethod { +public abstract sealed class GetSchemas extends AbstractMetadataMethod { private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(2) .at(0).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "TABLE_SCHEM", "TABLESCHEMAS").addField() diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetVersionColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetVersionColumns.java index 013fec716..8c1759e4c 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetVersionColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetVersionColumns.java @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: Copyright 2001-2024 Firebird development team and individual contributors -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2001-2025 Firebird development team and individual contributors +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; @@ -25,7 +25,7 @@ /** * @author Mark Rotteveel */ -public class GetVersionColumns { +public final class GetVersionColumns { private static final String VERSIONCOL = "VERSIONCOL"; From ea0a2738196f4d08542129a2d87b25695266c92e Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 23 Jun 2025 17:03:37 +0200 Subject: [PATCH 22/64] #882 Schema support for getXXXSourceCode --- src/docs/asciidoc/release_notes.adoc | 3 + .../firebirdsql/jdbc/FBDatabaseMetaData.java | 78 ++++++++++++------ .../jdbc/FirebirdDatabaseMetaData.java | 81 ++++++++++++++++--- .../org/firebirdsql/jdbc/metadata/Clause.java | 2 +- 4 files changed, 126 insertions(+), 38 deletions(-) diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 3a7fc7dc9..cff02351d 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -524,6 +524,9 @@ Within the limitations and specification of the JDBC API, this method cannot be ** `getColumnPrivileges` and `getTablePrivileges` received an additional column, `JB_GRANTEE_SCHEMA`, which is non-``null`` for grantees that are schema-bound (e.g. a procedure). + As this is a non-standard column, we recommend to always retrieve it by name. +** `getProcedureSourceCode`/`getTriggerSourceCode`/`getViewSourceCode` now also have an overload accepting the schema; +the overloads without a `schema` parameter, or `schema` is `null` will return the source code of the first match found. +The `schema` parameter is ignored on Firebird 5.0 and older. ** `getSchemas()` returns all defined schemas ** `getSchemas(String catalog, String schemaPattern)` returns all schemas matching the `LIKE` pattern `schemaPattern`, with the following caveats *** `catalog` non-empty will return no rows; diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 57b203947..c294702cd 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1834,42 +1834,72 @@ public boolean generatedKeyAlwaysReturned() throws SQLException { @Override public String getProcedureSourceCode(String procedureName) throws SQLException { - String sResult = null; - String sql = "Select RDB$PROCEDURE_SOURCE From RDB$PROCEDURES Where " - + "RDB$PROCEDURE_NAME = ?"; - List params = new ArrayList<>(); - params.add(procedureName); - try (ResultSet rs = doQuery(sql, params)) { - if (rs.next()) sResult = rs.getString(1); - } + return getProcedureSourceCode(null, procedureName); + } - return sResult; + @Override + public String getProcedureSourceCode(String schema, String procedureName) throws SQLException { + return getSourceCode(schema, procedureName, SourceObjectType.PROCEDURE); } @Override public String getTriggerSourceCode(String triggerName) throws SQLException { - String sResult = null; - String sql = "Select RDB$TRIGGER_SOURCE From RDB$TRIGGERS Where RDB$TRIGGER_NAME = ?"; - List params = new ArrayList<>(); - params.add(triggerName); - try (ResultSet rs = doQuery(sql, params)) { - if (rs.next()) sResult = rs.getString(1); - } + return getTriggerSourceCode(null, triggerName); + } - return sResult; + @Override + public String getTriggerSourceCode(String schema, String triggerName) throws SQLException { + return getSourceCode(schema, triggerName, SourceObjectType.TRIGGER); } @Override public String getViewSourceCode(String viewName) throws SQLException { - String sResult = null; - String sql = "Select RDB$VIEW_SOURCE From RDB$RELATIONS Where RDB$RELATION_NAME = ?"; - List params = new ArrayList<>(); - params.add(viewName); - try (ResultSet rs = doQuery(sql, params)) { - if (rs.next()) sResult = rs.getString(1); + return getViewSourceCode(null, viewName); + } + + @Override + public String getViewSourceCode(String schema, String viewName) throws SQLException { + return getSourceCode(schema, viewName, SourceObjectType.VIEW); + } + + private enum SourceObjectType { + PROCEDURE("RDB$PROCEDURES", "RDB$PROCEDURE_SOURCE", "RDB$PROCEDURE_NAME"), + TRIGGER("RDB$TRIGGERS", "RDB$TRIGGER_SOUCE", "RDB$TRIGGER_NAME"), + VIEW("RDB$RELATIONS", "RDB$VIEW_SOURCE", "RDB$RELATION_NAME"), + ; + + private final String tableName; + private final String objectSourceColumn; + private final String objectNameColumn; + + SourceObjectType(String tableName, String objectSourceColumn, String objectNameColumn) { + this.tableName = tableName; + this.objectSourceColumn = objectSourceColumn; + this.objectNameColumn = objectNameColumn; + } + + List toClauses(boolean supportsSchemas, String schema, String objectName) { + var objectNameClause = Clause.equalsClause(objectNameColumn, objectName); + return schema != null && supportsSchemas + ? List.of(Clause.equalsClause("RDB$SCHEMA_NAME", schema), objectNameClause) + : List.of(objectNameClause); } - return sResult; + String toQuery(boolean supportsSchemas, List clauses) { + return "select " + objectSourceColumn + " from " + (supportsSchemas ? "SYSTEM." : "") + tableName + + " where " + Clause.conjunction(clauses); + } + + } + + private String getSourceCode(String schema, String objectName, SourceObjectType objectType) throws SQLException { + final boolean supportsSchemas = firebirdSupportInfo.supportsSchemas(); + var clauses = objectType.toClauses(supportsSchemas, schema, objectName); + String sql = objectType.toQuery(supportsSchemas, clauses); + try (ResultSet rs = doQuery(sql, Clause.parameters(clauses))) { + if (rs.next()) return rs.getString(1); + } + return null; } protected static byte[] getBytes(String value) { diff --git a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java index fb0c07381..3cf49ef2a 100644 --- a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java @@ -16,39 +16,94 @@ */ @SuppressWarnings("unused") public interface FirebirdDatabaseMetaData extends DatabaseMetaData { - + /** * Get the source of a stored procedure. - * + *

+ * On Firebird 6.0 and higher, it is recommended to use {@link #getProcedureSourceCode(String, String)} instead. + *

+ * * @param procedureName - * name of the stored procedure. - * @return source of the stored procedure. + * name of the stored procedure + * @return source of the stored procedure * @throws SQLException - * if specified procedure cannot be found. + * if specified procedure cannot be found + * @see #getProcedureSourceCode(String, String) */ String getProcedureSourceCode(String procedureName) throws SQLException; + /** + * Get the source of a stored procedure. + * + * @param schema + * schema of the stored procedure ({@code null} drops the schema from the search; ignored on Firebird 5.0 + * and older) + * @param procedureName + * name of the stored procedure + * @return source of the stored procedure + * @throws SQLException + * if specified procedure cannot be found + * @since 7 + */ + String getProcedureSourceCode(String schema, String procedureName) throws SQLException; + /** * Get the source of a trigger. - * + *

+ * On Firebird 6.0 and higher, it is recommended to use {@link #getTriggerSourceCode(String, String)} instead. + *

+ * * @param triggerName - * name of the trigger. - * @return source of the trigger. + * name of the trigger + * @return source of the trigger * @throws SQLException - * if specified trigger cannot be found. + * if specified trigger cannot be found + * @see #getTriggerSourceCode(String, String) */ String getTriggerSourceCode(String triggerName) throws SQLException; + /** + * Get the source of a trigger. + * + * @param schema + * schema of the trigger ({@code null} drops the schema from the search; ignored on Firebird 5.0 and older) + * @param triggerName + * name of the trigger + * @return source of the trigger + * @throws SQLException + * if specified trigger cannot be found + * @since 7 + */ + String getTriggerSourceCode(String schema, String triggerName) throws SQLException; + /** * Get the source of a view. - * + *

+ * On Firebird 6.0 and higher, it is recommended to use {@link #getViewSourceCode(String, String)} instead. + *

+ * * @param viewName - * name of the view. - * @return source of the view. + * name of the view + * @return source of the view * @throws SQLException - * if specified view cannot be found. + * if specified view cannot be found + * @see #getViewSourceCode(String, String) */ String getViewSourceCode(String viewName) throws SQLException; + + /** + * Get the source of a view. + * + * @param schema + * schema of the trigger ({@code null} drops the schema from the search; ignored on Firebird 5.0 and older) + * @param viewName + * name of the view + * @return source of the view + * @throws SQLException + * if specified view cannot be found + * @since 7 + */ + String getViewSourceCode(String schema, String viewName) throws SQLException; /** * Get the major version of the ODS (On-Disk Structure) of the database. diff --git a/src/main/org/firebirdsql/jdbc/metadata/Clause.java b/src/main/org/firebirdsql/jdbc/metadata/Clause.java index 4d765aea9..3335abff7 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/Clause.java +++ b/src/main/org/firebirdsql/jdbc/metadata/Clause.java @@ -51,7 +51,7 @@ private Clause(String columnName, MetadataPattern metadataPattern) { * value for equals condition * @return clause for a SQL equals ({@code =}) condition */ - static Clause equalsClause(String columnName, String value) { + public static Clause equalsClause(String columnName, String value) { return new Clause(columnName, MetadataPattern.equalsCondition(value)); } From b60c7d6006589bbec5de0e356535666e0d1c3059 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 23 Jun 2025 17:04:01 +0200 Subject: [PATCH 23/64] Add ifSchemaElse also to FirebirdSupportInfo --- .../org/firebirdsql/util/FirebirdSupportInfo.java | 13 +++++++++++++ .../org/firebirdsql/common/FBTestProperties.java | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java index 1b566b40d..e62fcfac2 100644 --- a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java +++ b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java @@ -793,6 +793,19 @@ public boolean supportsSchemas() { return isVersionEqualOrAbove(6); } + /** + * If schema support is available, returns {@code forSchema}, otherwise returns {@code withoutSchema}. + * + * @param forSchema + * value to return when schema support is available + * @param withoutSchema + * value to return when schema support is not available + * @return {@code forSchema} if schema support is available, otherwise {@code withoutSchema} + */ + public T ifSchemaElse(T forSchema, T withoutSchema) { + return supportsSchemas() ? forSchema : withoutSchema; + } + /** * @return {@code true} when this Firebird version is considered a supported version */ diff --git a/src/test/org/firebirdsql/common/FBTestProperties.java b/src/test/org/firebirdsql/common/FBTestProperties.java index d211e1d86..bf239013d 100644 --- a/src/test/org/firebirdsql/common/FBTestProperties.java +++ b/src/test/org/firebirdsql/common/FBTestProperties.java @@ -374,9 +374,10 @@ public static void defaultDatabaseTearDown(FBManager fbManager) throws Exception * @param withoutSchema * value to return when schema support is not available * @return {@code forSchema} if schema support is available, otherwise {@code withoutSchema} + * @see FirebirdSupportInfo#ifSchemaElse(Object, Object) */ public static T ifSchemaElse(T forSchema, T withoutSchema) { - return getDefaultSupportInfo().supportsSchemas() ? forSchema : withoutSchema; + return getDefaultSupportInfo().ifSchemaElse(forSchema, withoutSchema); } private FBTestProperties() { From a1545519abd2e7db7cd1204297c5b11abe3176e3 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 24 Jun 2025 10:06:07 +0200 Subject: [PATCH 24/64] #882 Client props, xid detection --- .../jaybird/xca/FBManagedConnection.java | 40 ++++++++++++++++++- .../firebirdsql/jdbc/ClientInfoProvider.java | 8 +++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/main/org/firebirdsql/jaybird/xca/FBManagedConnection.java b/src/main/org/firebirdsql/jaybird/xca/FBManagedConnection.java index f5be226e3..a4b575666 100644 --- a/src/main/org/firebirdsql/jaybird/xca/FBManagedConnection.java +++ b/src/main/org/firebirdsql/jaybird/xca/FBManagedConnection.java @@ -6,7 +6,7 @@ SPDX-FileCopyrightText: Copyright 2003 Ryan Baldwin SPDX-FileCopyrightText: Copyright 2005 Steven Jardine SPDX-FileCopyrightText: Copyright 2006 Ludovic Orban - SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jaybird.xca; @@ -1383,7 +1383,9 @@ private interface XidQueries { String recoveryQueryParameterized(); static XidQueries forVersion(GDSServerVersion version) { - if (version.isEqualOrAbove(3, 0)) { + if (version.isEqualOrAbove(6)) { + return XidQueriesFB60.INSTANCE; + } else if (version.isEqualOrAbove(3)) { return XidQueriesFB30.INSTANCE; } else if (version.isEqualOrAbove(2, 5)) { return XidQueriesFB25.INSTANCE; @@ -1392,6 +1394,40 @@ static XidQueries forVersion(GDSServerVersion version) { } } + /** + * Relatively efficient XID queries that work with Firebird 6.0 and higher. + */ + private static final class XidQueriesFB60 implements XidQueries { + + static final XidQueriesFB60 INSTANCE = new XidQueriesFB60(); + // We're no longer casting RDB$TRANSACTION_DESCRIPTION, as it will benefit from inline blobs + private static final String FIND_TRANSACTION_FRAGMENT = + "select RDB$TRANSACTION_ID, RDB$TRANSACTION_DESCRIPTION from SYSTEM.RDB$TRANSACTIONS\n"; + + @Override + public String forgetFindQuery() { + return FIND_TRANSACTION_FRAGMENT + """ + where RDB$TRANSACTION_STATE in (2, 3)" + and RDB$TRANSACTION_DESCRIPTION starting with x'0105'"""; + } + + @Override + public String forgetDelete() { + return "delete from SYSTEM.RDB$TRANSACTIONS where RDB$TRANSACTION_ID = "; + } + + @Override + public String recoveryQuery() { + return FIND_TRANSACTION_FRAGMENT + "where RDB$TRANSACTION_DESCRIPTION starting with x'0105'"; + } + + @Override + public String recoveryQueryParameterized() { + return FIND_TRANSACTION_FRAGMENT + + "where RDB$TRANSACTION_DESCRIPTION = cast(? AS varchar(32764) character set octets)"; + } + } + /** * Relatively efficient XID queries that work with Firebird 3.0 and higher. */ diff --git a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java index f5f2ad649..b6e04f73a 100644 --- a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java +++ b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2023-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2023-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -179,7 +179,7 @@ public String getClientInfo(String name) throws SQLException { QuoteStrategy quoteStrategy = connection.getQuoteStrategy(); var sb = new StringBuilder("select "); renderGetValue(sb, property, quoteStrategy); - sb.append(" from RDB$DATABASE"); + sb.append(" from ").append(hasSystemSchema() ? "SYSTEM.RDB$DATABASE" : "RDB$DATABASE"); try (var rs = getStatement().executeQuery(sb.toString())) { if (rs.next()) { registerKnownProperty(property); @@ -195,6 +195,10 @@ public String getClientInfo(String name) throws SQLException { } } + private boolean hasSystemSchema() throws SQLException { + return connection.getMetaData().supportsSchemasInDataManipulation(); + } + private void renderGetValue(StringBuilder sb, ClientInfoProperty property, QuoteStrategy quoteStrategy) { // CLIENT_PROCESS@SYSTEM was introduced in Firebird 2.5.3, so don't fall back for earlier versions if (APPLICATION_NAME_PROP.equals(property) && supportInfoFor(connection).isVersionEqualOrAbove(2, 5, 3)) { From 18a52280c62102771069c2da51974caeedc95081 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 24 Jun 2025 10:25:30 +0200 Subject: [PATCH 25/64] #882 Retrieve schema name in column information --- .../org/firebirdsql/gds/ISCConstants.java | 3 + .../gds/ng/ServerVersionInformation.java | 59 ++++++++++++ .../gds/ng/StatementInfoProcessor.java | 6 +- .../gds/ng/fields/FieldDescriptor.java | 42 ++++++++- .../gds/ng/fields/RowDescriptorBuilder.java | 19 +++- .../jdbc/AbstractFieldMetaData.java | 13 ++- .../firebirdsql/jdbc/FBResultSetMetaData.java | 74 ++++++++++----- .../gds/ng/AbstractStatementTest.java | 49 ++++------ .../gds/ng/fields/FieldDescriptorTest.java | 4 +- .../ng/fields/RowDescriptorBuilderTest.java | 37 ++++---- .../gds/ng/fields/RowValueTest.java | 17 +--- .../FBResultSetMetaDataParametrizedTest.java | 92 +++++++++---------- 12 files changed, 273 insertions(+), 142 deletions(-) diff --git a/src/main/org/firebirdsql/gds/ISCConstants.java b/src/main/org/firebirdsql/gds/ISCConstants.java index 1cdb58f7a..8fe688d50 100644 --- a/src/main/org/firebirdsql/gds/ISCConstants.java +++ b/src/main/org/firebirdsql/gds/ISCConstants.java @@ -378,6 +378,9 @@ public interface ISCConstants { int isc_info_sql_stmt_timeout_user = 28; int isc_info_sql_stmt_timeout_run = 29; int isc_info_sql_stmt_blob_align = 30; + int isc_info_sql_exec_path_blr_bytes = 31; + int isc_info_sql_exec_path_blr_text = 32; + int isc_info_sql_relation_schema = 33; // SQL information return values diff --git a/src/main/org/firebirdsql/gds/ng/ServerVersionInformation.java b/src/main/org/firebirdsql/gds/ng/ServerVersionInformation.java index a8fc5ff0e..85607565c 100644 --- a/src/main/org/firebirdsql/gds/ng/ServerVersionInformation.java +++ b/src/main/org/firebirdsql/gds/ng/ServerVersionInformation.java @@ -43,6 +43,22 @@ public byte[] getStatementInfoRequestItems() { public byte[] getParameterDescriptionInfoRequestItems() { return Constants.V_2_0_PARAMETER_INFO.clone(); } + }, + /** + * Information for Version 6.0 and higher + * + * @since 7 + */ + VERSION_6_0(6, 0) { + @Override + public byte[] getStatementInfoRequestItems() { + return Constants.V_6_0_STATEMENT_INFO.clone(); + } + + @Override + public byte[] getParameterDescriptionInfoRequestItems() { + return Constants.V_6_0_PARAMETER_INFO.clone(); + } }; private final int majorVersion; @@ -213,6 +229,49 @@ private static final class Constants { isc_info_sql_describe_end }; + static final byte[] V_6_0_STATEMENT_INFO = new byte[] { + isc_info_sql_stmt_type, + isc_info_sql_select, + isc_info_sql_describe_vars, + isc_info_sql_sqlda_seq, + isc_info_sql_type, isc_info_sql_sub_type, + isc_info_sql_scale, isc_info_sql_length, + isc_info_sql_field, + isc_info_sql_alias, + isc_info_sql_relation_schema, + isc_info_sql_relation, + isc_info_sql_relation_alias, + isc_info_sql_owner, + isc_info_sql_describe_end, + + isc_info_sql_bind, + isc_info_sql_describe_vars, + isc_info_sql_sqlda_seq, + isc_info_sql_type, isc_info_sql_sub_type, + isc_info_sql_scale, isc_info_sql_length, + // TODO: Information not available in normal queries, check for procedures, otherwise remove + //isc_info_sql_field, + //isc_info_sql_alias, + //isc_info_sql_relation_schema, + //isc_info_sql_relation, + //isc_info_sql_relation_alias, + //isc_info_sql_owner, + isc_info_sql_describe_end + }; + static final byte[] V_6_0_PARAMETER_INFO = new byte[] { + isc_info_sql_describe_vars, + isc_info_sql_sqlda_seq, + isc_info_sql_type, isc_info_sql_sub_type, + isc_info_sql_scale, isc_info_sql_length, + isc_info_sql_field, + isc_info_sql_alias, + isc_info_sql_relation_schema, + isc_info_sql_relation, + isc_info_sql_relation_alias, + isc_info_sql_owner, + isc_info_sql_describe_end + }; + private Constants() { // no instances } diff --git a/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java b/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java index 9e22649c9..ac38482ed 100644 --- a/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java +++ b/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2013-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2013-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.gds.ng; @@ -166,6 +166,10 @@ private void processDescriptors(final StatementInfo info, final RowDescriptorBui rdb.setFieldName(readStringValue(info)); break; + case ISCConstants.isc_info_sql_relation_schema: + rdb.setOriginalSchema(readStringValue(info)); + break; + case ISCConstants.isc_info_sql_relation: rdb.setOriginalTableName(readStringValue(info)); break; diff --git a/src/main/org/firebirdsql/gds/ng/fields/FieldDescriptor.java b/src/main/org/firebirdsql/gds/ng/fields/FieldDescriptor.java index f9c883537..fdbf213ec 100644 --- a/src/main/org/firebirdsql/gds/ng/fields/FieldDescriptor.java +++ b/src/main/org/firebirdsql/gds/ng/fields/FieldDescriptor.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2013-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2013-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause package org.firebirdsql.gds.ng.fields; @@ -33,6 +33,7 @@ public final class FieldDescriptor { private final String fieldName; private final String tableAlias; private final String originalName; + private final String originalSchema; private final String originalTableName; private final String ownerName; @@ -62,6 +63,8 @@ public final class FieldDescriptor { * Column table alias * @param originalName * Column original name + * @param originalSchema + * Column original schema * @param originalTableName * Column original table * @param ownerName @@ -69,8 +72,8 @@ public final class FieldDescriptor { */ public FieldDescriptor(int position, DatatypeCoder datatypeCoder, int type, int subType, int scale, int length, - String fieldName, String tableAlias, String originalName, String originalTableName, - String ownerName) { + String fieldName, String tableAlias, + String originalName, String originalSchema, String originalTableName, String ownerName) { this.position = position; this.datatypeCoder = datatypeCoderForType(datatypeCoder, type, subType, scale); this.type = type; @@ -82,10 +85,35 @@ public FieldDescriptor(int position, DatatypeCoder datatypeCoder, // TODO May want to do the reverse, or handle this better; see FirebirdResultSetMetaData contract this.tableAlias = trimToNull(tableAlias); this.originalName = originalName; + this.originalSchema = originalSchema; this.originalTableName = originalTableName; this.ownerName = ownerName; } + /** + * Constructor for metadata FieldDescriptor. + *

+ * This constructor sets all string-fields {@code null}, and is primarily intended for testing purposes. + *

+ * + * @param position + * Position of this field (0-based), or {@code -1} if position is not known (e.g. for test code) + * @param datatypeCoder + * Instance of DatatypeCoder to use when decoding column data (note that another instance may be derived + * internally, which then will be returned by {@link #getDatatypeCoder()}) + * @param type + * Column SQL type + * @param subType + * Column subtype + * @param scale + * Column scale + * @param length + * Column defined length + */ + public FieldDescriptor(int position, DatatypeCoder datatypeCoder, int type, int subType, int scale, int length) { + this(position, datatypeCoder, type, subType, scale, length, null, null, null, null, null, null); + } + /** * The position of the field in the row or parameter set. *

@@ -162,6 +190,14 @@ public String getOriginalName() { return originalName; } + /** + * @return The original schema ({@code null} if schemaless, e.g. Firebird 5.0 or older, or a column not backed by + * a table) + */ + public String getOriginalSchema() { + return originalSchema; + } + /** * @return The original table name */ diff --git a/src/main/org/firebirdsql/gds/ng/fields/RowDescriptorBuilder.java b/src/main/org/firebirdsql/gds/ng/fields/RowDescriptorBuilder.java index 3076bd00b..2262039fe 100644 --- a/src/main/org/firebirdsql/gds/ng/fields/RowDescriptorBuilder.java +++ b/src/main/org/firebirdsql/gds/ng/fields/RowDescriptorBuilder.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2013-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2013-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.gds.ng.fields; @@ -28,6 +28,7 @@ public final class RowDescriptorBuilder { private String fieldName; private String tableAlias; private String originalName; + private String originalSchema; private String originalTableName; private String ownerName; private final FieldDescriptor[] fieldDescriptors; @@ -143,6 +144,18 @@ public RowDescriptorBuilder setOriginalName(final String originalName) { return this; } + /** + * Sets the original schema of the underlying table. + * + * @param originalSchema + * The schema of the table + * @return this builder + */ + public RowDescriptorBuilder setOriginalSchema(final String originalSchema) { + this.originalSchema = originalSchema; + return this; + } + /** * Sets the original name of the underlying table. * @@ -247,7 +260,7 @@ public RowDescriptorBuilder simple(final int type, final int length, final Strin */ public FieldDescriptor toFieldDescriptor() { return new FieldDescriptor(currentFieldIndex, datatypeCoder, type, subType, scale, length, fieldName, - tableAlias, originalName, originalTableName, ownerName); + tableAlias, originalName, originalSchema, originalTableName, ownerName); } /** @@ -261,6 +274,7 @@ public RowDescriptorBuilder resetField() { fieldName = null; tableAlias = null; originalName = null; + originalSchema = null; originalTableName = null; ownerName = null; return this; @@ -281,6 +295,7 @@ public RowDescriptorBuilder copyFieldFrom(final FieldDescriptor sourceFieldDescr fieldName = sourceFieldDescriptor.getFieldName(); tableAlias = sourceFieldDescriptor.getTableAlias(); originalName = sourceFieldDescriptor.getOriginalName(); + originalSchema = sourceFieldDescriptor.getOriginalSchema(); originalTableName = sourceFieldDescriptor.getOriginalTableName(); ownerName = sourceFieldDescriptor.getOwnerName(); return this; diff --git a/src/main/org/firebirdsql/jdbc/AbstractFieldMetaData.java b/src/main/org/firebirdsql/jdbc/AbstractFieldMetaData.java index 1aab3d20f..869be7781 100644 --- a/src/main/org/firebirdsql/jdbc/AbstractFieldMetaData.java +++ b/src/main/org/firebirdsql/jdbc/AbstractFieldMetaData.java @@ -6,7 +6,7 @@ SPDX-FileCopyrightText: Copyright 2002-2003 Blas Rodriguez Somoza SPDX-FileCopyrightText: Copyright 2003 Nikolay Samofatov SPDX-FileCopyrightText: Copyright 2005-2006 Steven Jardine - SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc; @@ -265,22 +265,25 @@ protected final ExtendedFieldInfo getExtFieldInfo(int columnIndex) throws SQLExc * Stores additional information about fields in a database. */ protected record ExtendedFieldInfo(FieldKey fieldKey, int fieldPrecision, boolean autoIncrement) { - public ExtendedFieldInfo(String relationName, String fieldName, int precision, boolean autoIncrement) { - this(new FieldKey(relationName, fieldName), precision, autoIncrement); + public ExtendedFieldInfo(String schema, String relationName, String fieldName, int precision, + boolean autoIncrement) { + this(new FieldKey(schema, relationName, fieldName), precision, autoIncrement); } } /** * A composite key for internal field mapping structures. * + * @param schema schema ({@code null} if schemaless, i.e. Firebird 5.0 and older) * @param relationName * relation name * @param fieldName * field name */ - protected record FieldKey(String relationName, String fieldName) { + protected record FieldKey(String schema, String relationName, String fieldName) { public FieldKey(FieldDescriptor fieldDescriptor) { - this(fieldDescriptor.getOriginalTableName(), fieldDescriptor.getOriginalName()); + this(fieldDescriptor.getOriginalSchema(), fieldDescriptor.getOriginalTableName(), + fieldDescriptor.getOriginalName()); } } } diff --git a/src/main/org/firebirdsql/jdbc/FBResultSetMetaData.java b/src/main/org/firebirdsql/jdbc/FBResultSetMetaData.java index 4db7d9018..426bf5c0c 100644 --- a/src/main/org/firebirdsql/jdbc/FBResultSetMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBResultSetMetaData.java @@ -6,7 +6,7 @@ SPDX-FileCopyrightText: Copyright 2002-2003 Blas Rodriguez Somoza SPDX-FileCopyrightText: Copyright 2003 Nikolay Samofatov SPDX-FileCopyrightText: Copyright 2005-2006 Steven Jardine - SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc; @@ -24,6 +24,7 @@ import java.sql.Types; import java.util.*; +import static java.util.Objects.requireNonNullElseGet; import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; /** @@ -160,11 +161,12 @@ public String getColumnName(int column) throws SQLException { /** * {@inheritDoc} * - * @return Always {@code ""} as schemas are not supported. + * @return Schema of table, empty string ({@code ""}) if schemaless, e.g. always on Firebird 5.0 and older, or if + * the column has no backing table */ @Override public String getSchemaName(int column) throws SQLException { - return ""; + return Objects.toString(getFieldDescriptor(column).getOriginalSchema(), ""); } /** @@ -187,16 +189,17 @@ public int getScale(int column) throws SQLException { @Override public String getTableName(int column) throws SQLException { - String result = getFieldDescriptor(column).getOriginalTableName(); - if (result == null) result = ""; - return result; + return getTableName(getFieldDescriptor(column)); + } + + private static String getTableName(FieldDescriptor fieldDescriptor) { + return Objects.toString(fieldDescriptor.getOriginalTableName(), ""); } @Override public String getTableAlias(int column) throws SQLException { - String result = getFieldDescriptor(column).getTableAlias(); - if (result == null) result = getTableName(column); - return result; + FieldDescriptor fieldDescriptor = getFieldDescriptor(column); + return requireNonNullElseGet(fieldDescriptor.getTableAlias(), () -> getTableName(fieldDescriptor)); } /** @@ -253,13 +256,15 @@ public String getColumnClassName(int column) throws SQLException { return getFieldClassName(column); } - private static final int FIELD_INFO_RELATION_NAME = 1; - private static final int FIELD_INFO_FIELD_NAME = 2; - private static final int FIELD_INFO_FIELD_PRECISION = 3; - private static final int FIELD_INFO_FIELD_AUTO_INC = 4; + private static final int FIELD_INFO_SCHEMA_NAME = 1; + private static final int FIELD_INFO_RELATION_NAME = 2; + private static final int FIELD_INFO_FIELD_NAME = 3; + private static final int FIELD_INFO_FIELD_PRECISION = 4; + private static final int FIELD_INFO_FIELD_AUTO_INC = 5; private static final String GET_FIELD_INFO_25 = """ select + cast(null as char(1)) as SCHEMA_NAME, RF.RDB$RELATION_NAME as RELATION_NAME, RF.RDB$FIELD_NAME as FIELD_NAME, F.RDB$FIELD_PRECISION as FIELD_PRECISION, @@ -270,14 +275,27 @@ public String getColumnClassName(int column) throws SQLException { private static final String GET_FIELD_INFO_30 = """ select - RF.RDB$RELATION_NAME as RELATION_NAME, - RF.RDB$FIELD_NAME as FIELD_NAME, + null as SCHEMA_NAME, + trim(trailing from RF.RDB$RELATION_NAME) as RELATION_NAME, + trim(trailing from RF.RDB$FIELD_NAME) as FIELD_NAME, F.RDB$FIELD_PRECISION as FIELD_PRECISION, RF.RDB$IDENTITY_TYPE is not null as FIELD_AUTO_INC from RDB$RELATION_FIELDS RF inner join RDB$FIELDS F on RF.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME where RF.RDB$FIELD_NAME = ? and RF.RDB$RELATION_NAME = ?"""; + private static final String GET_FIELD_INFO_60 = """ + select + trim(trailing from RF.RDB$SCHEMA_NAME) as SCHEMA_NAME, + trim(trailing from RF.RDB$RELATION_NAME) as RELATION_NAME, + trim(trailing from RF.RDB$FIELD_NAME) as FIELD_NAME, + F.RDB$FIELD_PRECISION as FIELD_PRECISION, + RF.RDB$IDENTITY_TYPE is not null as FIELD_AUTO_INC + from SYSTEM.RDB$RELATION_FIELDS RF + inner join SYSTEM.RDB$FIELDS F + on RF.RDB$FIELD_SOURCE_SCHEMA_NAME = F.RDB$SCHEMA_NAME and RF.RDB$FIELD_SOURCE = F.RDB$FIELD_NAME + where RF.RDB$FIELD_NAME = ? and RF.RDB$SCHEMA_NAME = ? and RF.RDB$RELATION_NAME = ?"""; + // Apparently there is a limit in the UNION. It is necessary to split in several queries. Although the problem // reported with 93 UNION, use only 70. private static final int MAX_FIELD_INFO_UNIONS = 70; @@ -292,9 +310,12 @@ protected Map getExtendedFieldInfo(FBConnection con var result = new HashMap(); FBDatabaseMetaData metaData = (FBDatabaseMetaData) connection.getMetaData(); var params = new ArrayList(); + final boolean fb3OrHigher = metaData.getDatabaseMajorVersion() >= 3; + final boolean supportsSchemas = metaData.supportsSchemasInDataManipulation(); + String getFieldInfoQuery = fb3OrHigher + ? (supportsSchemas ? GET_FIELD_INFO_60 : GET_FIELD_INFO_30) + : GET_FIELD_INFO_25; var sb = new StringBuilder(); - boolean fb3OrHigher = metaData.getDatabaseMajorVersion() >= 3; - String getFieldInfoQuery = fb3OrHigher ? GET_FIELD_INFO_30 : GET_FIELD_INFO_25; while (currentColumn <= fieldCount) { params.clear(); sb.setLength(0); @@ -303,10 +324,15 @@ protected Map getExtendedFieldInfo(FBConnection con FieldDescriptor fieldDescriptor = getFieldDescriptor(currentColumn); if (!needsExtendedFieldInfo(fieldDescriptor, fb3OrHigher)) continue; + String schemaName = fieldDescriptor.getOriginalSchema(); String relationName = fieldDescriptor.getOriginalTableName(); String fieldName = fieldDescriptor.getOriginalName(); - if (isNullOrEmpty(relationName) || isNullOrEmpty(fieldName)) continue; + if (isNullOrEmpty(relationName) + || isNullOrEmpty(fieldName) + || supportsSchemas && isNullOrEmpty(schemaName)) { + continue; + } if (unionCount != 0) { sb.append("\nunion all\n"); @@ -314,6 +340,9 @@ protected Map getExtendedFieldInfo(FBConnection con sb.append(getFieldInfoQuery); params.add(fieldName); + if (supportsSchemas) { + params.add(schemaName); + } params.add(relationName); unionCount++; @@ -332,8 +361,9 @@ protected Map getExtendedFieldInfo(FBConnection con } private static ExtendedFieldInfo extractExtendedFieldInfo(ResultSet rs) throws SQLException { - return new ExtendedFieldInfo(rs.getString(FIELD_INFO_RELATION_NAME), rs.getString(FIELD_INFO_FIELD_NAME), - rs.getInt(FIELD_INFO_FIELD_PRECISION), rs.getBoolean(FIELD_INFO_FIELD_AUTO_INC)); + return new ExtendedFieldInfo(rs.getString(FIELD_INFO_SCHEMA_NAME), rs.getString(FIELD_INFO_RELATION_NAME), + rs.getString(FIELD_INFO_FIELD_NAME), rs.getInt(FIELD_INFO_FIELD_PRECISION), + rs.getBoolean(FIELD_INFO_FIELD_AUTO_INC)); } /** @@ -365,9 +395,7 @@ private enum ColumnStrategy { DEFAULT { @Override String getColumnName(FieldDescriptor fieldDescriptor) { - return fieldDescriptor.getOriginalName() != null - ? fieldDescriptor.getOriginalName() - : getColumnLabel(fieldDescriptor); + return requireNonNullElseGet(fieldDescriptor.getOriginalName(), () -> getColumnLabel(fieldDescriptor)); } }, /** diff --git a/src/test/org/firebirdsql/gds/ng/AbstractStatementTest.java b/src/test/org/firebirdsql/gds/ng/AbstractStatementTest.java index be44ce567..5974bc062 100644 --- a/src/test/org/firebirdsql/gds/ng/AbstractStatementTest.java +++ b/src/test/org/firebirdsql/gds/ng/AbstractStatementTest.java @@ -164,18 +164,19 @@ public void testSelect_NoParameters_Describe() throws Exception { assertNotNull(fields, "Fields"); final FirebirdSupportInfo supportInfo = supportInfoFor(db); final int metadataCharSetId = supportInfo.reportedMetadataCharacterSetId(); + final String schema = ifSchemaElse("SYSTEM", null); var expectedFields = List.of( new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_BLOB | 1, 1, supportInfo.reportsBlobCharSetInDescriptor() ? metadataCharSetId : 0, 8, "Description", null, - "RDB$DESCRIPTION", "RDB$DATABASE", "SYSDBA"), + "RDB$DESCRIPTION", schema, "RDB$DATABASE", "SYSDBA"), new FieldDescriptor(1, db.getDatatypeCoder(), ISCConstants.SQL_SHORT | 1, 0, 0, 2, - "RDB$RELATION_ID", null, "RDB$RELATION_ID", "RDB$DATABASE", "SYSDBA"), + "RDB$RELATION_ID", null, "RDB$RELATION_ID", schema, "RDB$DATABASE", "SYSDBA"), new FieldDescriptor(2, db.getDatatypeCoder(), ISCConstants.SQL_TEXT | 1, metadataCharSetId, 0, supportInfo.maxReportedIdentifierLengthBytes(), "RDB$SECURITY_CLASS", null, - "RDB$SECURITY_CLASS", "RDB$DATABASE", "SYSDBA"), + "RDB$SECURITY_CLASS", schema, "RDB$DATABASE", "SYSDBA"), new FieldDescriptor(3, db.getDatatypeCoder(), ISCConstants.SQL_TEXT | 1, metadataCharSetId, 0, supportInfo.maxReportedIdentifierLengthBytes(), "RDB$CHARACTER_SET_NAME", null, - "RDB$CHARACTER_SET_NAME", "RDB$DATABASE", "SYSDBA") + "RDB$CHARACTER_SET_NAME", schema, "RDB$DATABASE", "SYSDBA") ); assertEquals(expectedFields, fields.getFieldDescriptors(), "Unexpected values for fields"); assertNotNull(statement.getParameterDescriptor(), "Parameters"); @@ -230,18 +231,16 @@ public void testSelect_WithParameters_Describe() throws Exception { var expectedFields = List.of( new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_TEXT | 1, metadataCharSetId, 0, supportInfo.maxReportedIdentifierLengthBytes(), "RDB$CHARACTER_SET_NAME", - supportsTableAlias ? "A" : null, "RDB$CHARACTER_SET_NAME", "RDB$CHARACTER_SETS", "SYSDBA") + supportsTableAlias ? "A" : null, "RDB$CHARACTER_SET_NAME", ifSchemaElse("SYSTEM", null), + "RDB$CHARACTER_SETS", "SYSDBA") ); assertEquals(expectedFields, fields.getFieldDescriptors(), "Unexpected values for fields"); final RowDescriptor parameters = statement.getParameterDescriptor(); assertNotNull(parameters, "Parameters"); var expectedParameters = List.of( - new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_SHORT | 1, 0, 0, 2, - null, null, null, null, null), - new FieldDescriptor(1, db.getDatatypeCoder(), ISCConstants.SQL_SHORT | 1, 0, 0, 2, - null, null, null, null, null) - ); + new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_SHORT | 1, 0, 0, 2), + new FieldDescriptor(1, db.getDatatypeCoder(), ISCConstants.SQL_SHORT | 1, 0, 0, 2)); assertEquals(expectedParameters, parameters.getFieldDescriptors(), "Unexpected values for parameters"); } @@ -291,16 +290,14 @@ public void test_PrepareExecutableStoredProcedure() throws Exception { assertNotNull(fields, "Fields"); var expectedFields = List.of( new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_LONG | 1, 0, 0, 4, "OUTVALUE", null, - "OUTVALUE", "INCREMENT", "SYSDBA") + "OUTVALUE", ifSchemaElse("PUBLIC", null), "INCREMENT", "SYSDBA") ); assertEquals(expectedFields, fields.getFieldDescriptors(), "Unexpected values for fields"); final RowDescriptor parameters = statement.getParameterDescriptor(); assertNotNull(parameters, "Parameters"); var expectedParameters = List.of( - new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_LONG | 1, 0, 0, 4, null, null, null, - null, null) - ); + new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_LONG | 1, 0, 0, 4)); assertEquals(expectedParameters, parameters.getFieldDescriptors(), "Unexpected values for parameters"); } @@ -336,18 +333,15 @@ public void test_PrepareSelectableStoredProcedure() throws Exception { assertNotNull(fields, "Fields"); var expectedFields = List.of( new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_LONG | 1, 0, 0, 4, "OUTVALUE", null, - "OUTVALUE", "RANGE", "SYSDBA") + "OUTVALUE", ifSchemaElse("PUBLIC", null), "RANGE", "SYSDBA") ); assertEquals(expectedFields, fields.getFieldDescriptors(), "Unexpected values for fields"); final RowDescriptor parameters = statement.getParameterDescriptor(); assertNotNull(parameters, "Parameters"); var expectedParameters = List.of( - new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_LONG | 1, 0, 0, 4, null, null, null, - null, null), - new FieldDescriptor(1, db.getDatatypeCoder(), ISCConstants.SQL_LONG | 1, 0, 0, 4, null, null, null, - null, null) - ); + new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_LONG | 1, 0, 0, 4), + new FieldDescriptor(1, db.getDatatypeCoder(), ISCConstants.SQL_LONG | 1, 0, 0, 4)); assertEquals(expectedParameters, parameters.getFieldDescriptors(), "Unexpected values for parameters"); } @@ -365,16 +359,14 @@ public void test_PrepareInsertReturning() throws Exception { assertNotNull(fields, "Fields"); var expectedFields = List.of( new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_LONG | 1, 0, 0, 4, "THEKEY", null, - "THEKEY", "KEYVALUE", "SYSDBA") + "THEKEY", ifSchemaElse("PUBLIC", null), "KEYVALUE", "SYSDBA") ); assertEquals(expectedFields, fields.getFieldDescriptors(), "Unexpected values for fields"); final RowDescriptor parameters = statement.getParameterDescriptor(); assertNotNull(parameters, "Parameters"); var expectedParameters = List.of( - new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_VARYING | 1, 0, 0, 5, null, null, - null, null, null) - ); + new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_VARYING | 1, 0, 0, 5)); assertEquals(expectedParameters, parameters.getFieldDescriptors(), "Unexpected values for parameters"); } @@ -754,20 +746,19 @@ public void testStatementPrepareLongObjectNames() throws Exception { final RowDescriptor fields = statement.getRowDescriptor(); assertNotNull(fields, "Fields"); + final String schema = ifSchemaElse("PUBLIC", null); var expectedFields = List.of( new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_VARYING | 1, 4, - 0, 40, column1, null, column1, tableName, "SYSDBA"), + 0, 40, column1, null, column1, schema, tableName, "SYSDBA"), new FieldDescriptor(1, db.getDatatypeCoder(), ISCConstants.SQL_VARYING | 1, 4, - 0, 80, column2, null, column2, tableName, "SYSDBA") + 0, 80, column2, null, column2, schema, tableName, "SYSDBA") ); assertEquals(expectedFields, fields.getFieldDescriptors(), "Unexpected values for fields"); RowDescriptor parameters = statement.getParameterDescriptor(); assertNotNull(parameters, "Parameters"); var expectedParameters = List.of( - new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_VARYING | 1, 4, 0, 40, null, null, null, - null, null) - ); + new FieldDescriptor(0, db.getDatatypeCoder(), ISCConstants.SQL_VARYING | 1, 4, 0, 40)); assertEquals(expectedParameters, parameters.getFieldDescriptors(), "Unexpected values for parameters"); } diff --git a/src/test/org/firebirdsql/gds/ng/fields/FieldDescriptorTest.java b/src/test/org/firebirdsql/gds/ng/fields/FieldDescriptorTest.java index a712b69d5..86277e682 100644 --- a/src/test/org/firebirdsql/gds/ng/fields/FieldDescriptorTest.java +++ b/src/test/org/firebirdsql/gds/ng/fields/FieldDescriptorTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2017-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2017-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.gds.ng.fields; @@ -92,6 +92,6 @@ void shouldUseEncodingSpecificDatatypeCoder_blobTextType_notDefaultCharset() { } private FieldDescriptor createFieldDescriptor(int type, int subType, int scale) { - return new FieldDescriptor(1, defaultDatatypeCoder, type, subType, scale, 8, "x", "t", "x", "t", ""); + return new FieldDescriptor(1, defaultDatatypeCoder, type, subType, scale, 8, "x", "t", "x", "s", "t", ""); } } \ No newline at end of file diff --git a/src/test/org/firebirdsql/gds/ng/fields/RowDescriptorBuilderTest.java b/src/test/org/firebirdsql/gds/ng/fields/RowDescriptorBuilderTest.java index 68b1a48a9..cae611954 100644 --- a/src/test/org/firebirdsql/gds/ng/fields/RowDescriptorBuilderTest.java +++ b/src/test/org/firebirdsql/gds/ng/fields/RowDescriptorBuilderTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2013-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2013-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.gds.ng.fields; @@ -9,8 +9,6 @@ import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -24,17 +22,13 @@ class RowDescriptorBuilderTest { private static final DatatypeCoder datatypeCoder = DefaultDatatypeCoder.forEncodingFactory(EncodingFactory.createInstance(StandardCharsets.UTF_8)); - private static final List TEST_FIELD_DESCRIPTORS; - static { - List fields = new ArrayList<>(); - fields.add(new FieldDescriptor(0, datatypeCoder, 1, 1, 1, 1, "1", "1", "1", "1", "1")); - fields.add(new FieldDescriptor(1, datatypeCoder, 2, 2, 2, 2, "2", "2", "2", "2", "2")); - fields.add(new FieldDescriptor(2, datatypeCoder, 3, 3, 3, 3, "3", "3", "3", "3", "3")); + private static final List TEST_FIELD_DESCRIPTORS = List.of( + new FieldDescriptor(0, datatypeCoder, 1, 1, 1, 1, "1", "1", "1", "1", "1", "1"), + new FieldDescriptor(1, datatypeCoder, 2, 2, 2, 2, "2", "2", "2", "2", "2", "2"), + new FieldDescriptor(2, datatypeCoder, 3, 3, 3, 3, "3", "3", "3", "3", "3", "3")); - TEST_FIELD_DESCRIPTORS = Collections.unmodifiableList(fields); - } - - private static final FieldDescriptor SOURCE = new FieldDescriptor(-1, datatypeCoder, 1, 2, 3, 4, "5", "6", "7", "8", "9"); + private static final FieldDescriptor SOURCE = + new FieldDescriptor(-1, datatypeCoder, 1, 2, 3, 4, "5", "6", "7", "8", "9", "10"); @Test void testEmptyField() { @@ -49,6 +43,7 @@ void testEmptyField() { assertNull(descriptor.getFieldName(), "Unexpected FieldName"); assertNull(descriptor.getTableAlias(), "Unexpected TableAlias"); assertNull(descriptor.getOriginalName(), "Unexpected OriginalName"); + assertNull(descriptor.getOriginalSchema(), "Unexpected OriginalSchema"); assertNull(descriptor.getOriginalTableName(), "Unexpected OriginalTableName"); assertNull(descriptor.getOwnerName(), "Unexpected OwnerName"); } @@ -64,8 +59,9 @@ void testBasicFieldInitialization() { .setFieldName("5") .setTableAlias("6") .setOriginalName("7") - .setOriginalTableName("8") - .setOwnerName("9") + .setOriginalSchema("8") + .setOriginalTableName("9") + .setOwnerName("10") .toFieldDescriptor(); assertEquals(0, descriptor.getPosition(), "Unexpected Position"); @@ -76,8 +72,9 @@ void testBasicFieldInitialization() { assertEquals("5", descriptor.getFieldName(), "Unexpected FieldName"); assertEquals("6", descriptor.getTableAlias(), "Unexpected TableAlias"); assertEquals("7", descriptor.getOriginalName(), "Unexpected OriginalName"); - assertEquals("8", descriptor.getOriginalTableName(), "Unexpected OriginalTableName"); - assertEquals("9", descriptor.getOwnerName(), "Unexpected OwnerName"); + assertEquals("8", descriptor.getOriginalSchema(), "Unexpected OriginalSchema"); + assertEquals("9", descriptor.getOriginalTableName(), "Unexpected OriginalTableName"); + assertEquals("10", descriptor.getOwnerName(), "Unexpected OwnerName"); } @Test @@ -94,8 +91,9 @@ void testCopyFrom() { assertEquals("5", fieldDescriptor.getFieldName(), "Unexpected FieldName"); assertEquals("6", fieldDescriptor.getTableAlias(), "Unexpected TableAlias"); assertEquals("7", fieldDescriptor.getOriginalName(), "Unexpected OriginalName"); - assertEquals("8", fieldDescriptor.getOriginalTableName(), "Unexpected OriginalTableName"); - assertEquals("9", fieldDescriptor.getOwnerName(), "Unexpected OwnerName"); + assertEquals("8", fieldDescriptor.getOriginalSchema(), "Unexpected OriginalSchema"); + assertEquals("9", fieldDescriptor.getOriginalTableName(), "Unexpected OriginalTableName"); + assertEquals("10", fieldDescriptor.getOwnerName(), "Unexpected OwnerName"); } @Test @@ -113,6 +111,7 @@ void testResetField() { assertNull(fieldDescriptor.getFieldName(), "Unexpected FieldName"); assertNull(fieldDescriptor.getTableAlias(), "Unexpected TableAlias"); assertNull(fieldDescriptor.getOriginalName(), "Unexpected OriginalName"); + assertNull(fieldDescriptor.getOriginalSchema(), "Unexpected OriginalSchema"); assertNull(fieldDescriptor.getOriginalTableName(), "Unexpected OriginalTableName"); assertNull(fieldDescriptor.getOwnerName(), "Unexpected OwnerName"); } diff --git a/src/test/org/firebirdsql/gds/ng/fields/RowValueTest.java b/src/test/org/firebirdsql/gds/ng/fields/RowValueTest.java index 7cd5b756f..b43e003de 100644 --- a/src/test/org/firebirdsql/gds/ng/fields/RowValueTest.java +++ b/src/test/org/firebirdsql/gds/ng/fields/RowValueTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2018-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2018-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.gds.ng.fields; @@ -8,8 +8,6 @@ import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -20,15 +18,10 @@ class RowValueTest { private static final DatatypeCoder datatypeCoder = DefaultDatatypeCoder.forEncodingFactory(EncodingFactory.createInstance(StandardCharsets.UTF_8)); private static final RowDescriptor EMPTY_ROW_DESCRIPTOR = RowDescriptor.empty(datatypeCoder); - private static final List TEST_FIELD_DESCRIPTORS; - static { - List fields = new ArrayList<>(); - fields.add(new FieldDescriptor(0, datatypeCoder, 1, 1, 1, 1, "A", "1", "1", "1", "1")); - fields.add(new FieldDescriptor(1, datatypeCoder, 2, 2, 2, 2, "B", "2", "2", "2", "2")); - fields.add(new FieldDescriptor(2, datatypeCoder, 3, 3, 3, 3, "C", "3", "3", "3", "3")); - - TEST_FIELD_DESCRIPTORS = Collections.unmodifiableList(fields); - } + private static final List TEST_FIELD_DESCRIPTORS = List.of( + new FieldDescriptor(0, datatypeCoder, 1, 1, 1, 1, "A", "1", "1", "1", "1", "1"), + new FieldDescriptor(1, datatypeCoder, 2, 2, 2, 2, "B", "2", "2", "2", "2", "2"), + new FieldDescriptor(2, datatypeCoder, 3, 3, 3, 3, "C", "3", "3", "3", "3", "3")); @Test void testDefaultFor_emptyRowDescriptor_returns_EMPTY_ROW_VALUE() { diff --git a/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataParametrizedTest.java b/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataParametrizedTest.java index beaa7f5ef..26cb7cc6c 100644 --- a/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataParametrizedTest.java +++ b/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataParametrizedTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2014-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2014-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -41,51 +41,51 @@ */ class FBResultSetMetaDataParametrizedTest { + // TODO Add schema support: tests involving other schema + private static final String TABLE_NAME = "TEST_P_METADATA"; - //@formatter:off - private static final String CREATE_TABLE = - "CREATE TABLE test_p_metadata (" + - " id INTEGER, " + - " simple_field VARCHAR(60) CHARACTER SET WIN1251 COLLATE PXW_CYRL, " + - " two_byte_field VARCHAR(60) CHARACTER SET BIG_5, " + - " three_byte_field VARCHAR(60) CHARACTER SET UNICODE_FSS, " + - " long_field BIGINT, " + - " int_field INTEGER, " + - " short_field SMALLINT, " + - " float_field FLOAT, " + - " double_field DOUBLE PRECISION, " + - " smallint_numeric NUMERIC(3,1), " + - " integer_decimal_1 DECIMAL(3,1), " + - " integer_numeric NUMERIC(5,2), " + - " integer_decimal_2 DECIMAL(9,3), " + - " bigint_numeric NUMERIC(10,4), " + - " bigint_decimal DECIMAL(18,9), " + - " date_field DATE, " + - " time_field TIME, " + - " timestamp_field TIMESTAMP, " + - " blob_field BLOB, " + - " blob_text_field BLOB SUB_TYPE TEXT, " + - " blob_minus_one BLOB SUB_TYPE -1 " + - " /* boolean */ " + - " /* decfloat */ " + - " /* extended numerics */ " + - " /* time zone */ " + - " /* int128 */ " + - ")"; - - private static final String TEST_QUERY = - "SELECT " + - "simple_field, two_byte_field, three_byte_field, long_field, int_field, short_field," + - "float_field, double_field, smallint_numeric, integer_decimal_1, integer_numeric," + - "integer_decimal_2, bigint_numeric, bigint_decimal, date_field, time_field," + - "timestamp_field, blob_field, blob_text_field, blob_minus_one " + - "/* boolean */ " + - "/* decfloat */ " + - "/* extended numerics */ " + - "/* time zone */ " + - "/* int128 */ " + - "FROM test_p_metadata"; - //@formatter:on + private static final String CREATE_TABLE = """ + CREATE TABLE test_p_metadata ( + id INTEGER, + simple_field VARCHAR(60) CHARACTER SET WIN1251 COLLATE PXW_CYRL, + two_byte_field VARCHAR(60) CHARACTER SET BIG_5, + three_byte_field VARCHAR(60) CHARACTER SET UNICODE_FSS, + long_field BIGINT, + int_field INTEGER, + short_field SMALLINT, + float_field FLOAT, + double_field DOUBLE PRECISION, + smallint_numeric NUMERIC(3,1), + integer_decimal_1 DECIMAL(3,1), + integer_numeric NUMERIC(5,2), + integer_decimal_2 DECIMAL(9,3), + bigint_numeric NUMERIC(10,4), + bigint_decimal DECIMAL(18,9), + date_field DATE, + time_field TIME, + timestamp_field TIMESTAMP, + blob_field BLOB, + blob_text_field BLOB SUB_TYPE TEXT, + blob_minus_one BLOB SUB_TYPE -1 + /* boolean */ + /* decfloat */ + /* extended numerics */ + /* time zone */ + /* int128 */ + )"""; + + private static final String TEST_QUERY = """ + SELECT + simple_field, two_byte_field, three_byte_field, long_field, int_field, short_field, + float_field, double_field, smallint_numeric, integer_decimal_1, integer_numeric, + integer_decimal_2, bigint_numeric, bigint_decimal, date_field, time_field, + timestamp_field, blob_field, blob_text_field, blob_minus_one + /* boolean */ + /* decfloat */ + /* extended numerics */ + /* time zone */ + /* int128 */ + FROM test_p_metadata"""; @RegisterExtension static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll(); @@ -259,7 +259,7 @@ void testGetScale(Integer columnIndex, ResultSetMetaDataInfo expectedMetaData, S @ParameterizedTest(name = "Index {0} ({2})") @MethodSource("testData") void testGetSchemaName(Integer columnIndex, ResultSetMetaDataInfo ignored1, String ignored2) throws Exception { - assertEquals("", rsmd.getSchemaName(columnIndex), "getSchemaName"); + assertEquals(supportInfo.ifSchemaElse("PUBLIC", ""), rsmd.getSchemaName(columnIndex), "getSchemaName"); } @ParameterizedTest(name = "Index {0} ({2})") From bed0828c5585d12f1b4afec78f465f714a717da6 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 24 Jun 2025 10:51:22 +0200 Subject: [PATCH 26/64] #882 Incomplete change to selectable procedure detection --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 1 + .../jdbc/StoredProcedureMetaDataFactory.java | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index 960ac3bda..d5376ee61 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -96,6 +96,7 @@ Jaybird cannot honour this requirement for plain `Statement`, as schema resoluti *** Or just accept it as a breaking change? ** `getCatalogs`: TODO: Maybe add a custom column with a list of schema names for `useCatalogAsPackage=true`? * TODO: Define effects for management API +* TODO: Redesign retrieval of selectable procedure information (`StoredProcedureMetaDataFactory`) to be able to find stored procedures by schema Note to self: use `// TODO Add schema support` in places that you identify need to get/improve schema support, while working on schema support elsewhere diff --git a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java index 633ef17f8..d229f64a8 100644 --- a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java +++ b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: Copyright 2007 Gabriel Reid -// SPDX-FileCopyrightText: Copyright 2012-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -61,6 +61,10 @@ private static boolean versionEqualOrAboveFB21(int majorVersion, int minorVersio */ final class DefaultCallableStatementMetaData implements StoredProcedureMetaData { + // TODO Add schema support: solution needs to be reworked to support schemas, which will cascade into + // callable statement parsing. This needs further investigation. In addition, the current solution doesn't handle + // case-sensitivity + final Set selectableProcedureNames = new HashSet<>(); public DefaultCallableStatementMetaData(Connection connection) @@ -71,7 +75,9 @@ public DefaultCallableStatementMetaData(Connection connection) private void loadSelectableProcedureNames(Connection connection) throws SQLException { try (Statement stmt = connection.createStatement()) { // TODO Replace with looking for specific procedure - String sql = "SELECT RDB$PROCEDURE_NAME FROM RDB$PROCEDURES WHERE RDB$PROCEDURE_TYPE = 1"; + String sql = connection.getMetaData().supportsSchemasInDataManipulation() + ? "SELECT RDB$PROCEDURE_NAME FROM SYSTEM.RDB$PROCEDURES WHERE RDB$PROCEDURE_TYPE = 1" + : "SELECT RDB$PROCEDURE_NAME FROM RDB$PROCEDURES WHERE RDB$PROCEDURE_TYPE = 1"; try (ResultSet resultSet = stmt.executeQuery(sql)) { while (resultSet.next()) { selectableProcedureNames.add(resultSet.getString(1).trim().toUpperCase(Locale.ROOT)); From ce3abcc05fd8d6a283dae0d6e5279bcd466b00e5 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 24 Jun 2025 10:59:56 +0200 Subject: [PATCH 27/64] #882 Misc wording of jdp-2025-06 --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index d5376ee61..f5586dff8 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -31,6 +31,7 @@ Important details related to schemas: The session default can be configured with `isc_dpb_search_path` (string DPB item). The current search path can be altered with `SET SEARCH_PATH TO ...`. `ALTER SESSION RESET` reverts to the session default. +* If `SYSTEM` is not on the search path, it is automatically searched last * The "`current`" schema cannot be set separately; the first valid schema listed in the search path is considered the current schema. * `CURRENT_SCHEMA` and `RDB$GET_CONTEXT('SYSTEM', 'CURRENT_SCHEMA')` return the first valid schema from the search path @@ -38,16 +39,12 @@ the first valid schema listed in the search path is considered the current schem * Objects not qualified with a schema name will be resolved using the current search path. This is done -- with some exceptions -- at prepare time. * TBP has new item `isc_tpb_lock_table_schema` to specify the schema of a table to be locked (1 byte length + string data) -* Gbak has additional options to include/exclude (skip) schema datas in backup or restore, similar to existing options to include/exclude tables +* Gbak has additional options to include/exclude (skip) schema data in backup or restore, similar to existing options to include/exclude tables * Gstat has additional options to specify a schema for operations involving tables * For validation, `val_sch_incl` and `val_sch_excl` (I don't think we use the equivalent,`val_tab_incl`/`val_tab_excl` in Jaybird, so might not be relevant) JDBC defines various methods, parameters, and return values or result set columns that are or are related to schemas. -These are: - -* ... - Jaybird 5 is the "`long-term support`" version for Java 8. [NOTE] @@ -62,7 +59,7 @@ When Jaybird 7 is used on Firebird 5.0 or older, it will behave as before (no sc Further details can be found in <>. -Decision on backport to Jaybird 6 and/or Jaybird 5 is pending, and may be subject of a separate JDP. +Decision on backport to Jaybird 6 and/or Jaybird 5 is pending, and may be the subject of a separate JDP. [#consequences] == Consequences @@ -84,16 +81,12 @@ The schema name is stored _only_ for this replacement operation (i.e. it will no ** JDBC specifies that "`__Calling ``setSchema`` has no effect on previously created or prepared Statement objects.__`"; Jaybird cannot honour this requirement for plain `Statement`, as schema resolution is on prepare time (which for plain `Statement` is on execute), and not always for `CallableStatement` (as the implementation may delay actual prepare until execution). * Request `isc_info_sql_relation_schema` after preparing a query, record it in `FieldDescriptor`, and return it were relevant for JDBC (e.g. `ResultSetMetaData.getSchemaName(int)`) -** For Firebird 5.0 and older, we need to ensure that JDBC methods continue to report the correct value (i.e. ``""`` for schema-less objects) +** For Firebird 5.0 and older, we need to ensure that JDBC methods continue to report the correct value (i.e. `""` for schema-less objects) * A Firebird 6.0 variant of the `DatabaseMetaData` and other internal metadata queries needs to be written to address at least the following things: -** Explicitly qualify metadata tables with `"SYSTEM"`, so the queries will work even if `SYSTEM` is not on the search path. +** Explicitly qualify metadata tables with `SYSTEM`, so the queries will work even if `SYSTEM` is not on the search path. ** Returning schema names, and qualified object names where relevant (e.g. in `DatabaseMetaData` result sets) ** Include schema names in joins to ensure matching the right objects ** Allow searching for schema or schema pattern as specified in JDBC, or were needed for internal metadata queries -** TODO: investigate need for backwards compatible behaviour for `DatabaseMetaData` parameter `schema` with value `""` ("`__[...] retrieves those without a schema__`"). -*** Maybe make it search `PUBLIC`, or `PUBLIC` and `SYSTEM`, or those on the search path? -*** Or add a compatibility connection property to make it behave as `null` ("`__[...] means that the schema name should not be used to narrow the search__`")? -*** Or just accept it as a breaking change? ** `getCatalogs`: TODO: Maybe add a custom column with a list of schema names for `useCatalogAsPackage=true`? * TODO: Define effects for management API * TODO: Redesign retrieval of selectable procedure information (`StoredProcedureMetaDataFactory`) to be able to find stored procedures by schema From d365d92e97e2b6b3a1581f89fe2b7a0cc0de9de5 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 24 Jun 2025 12:03:47 +0200 Subject: [PATCH 28/64] #882 Define connection property searchPath --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 1 + src/docs/asciidoc/release_notes.adoc | 10 +++ ...bstractConnectionPropertiesDataSource.java | 10 +++ .../jaybird/fb/constants/DpbItems.java | 3 + .../props/DatabaseConnectionProperties.java | 26 ++++++ .../jaybird/props/PropertyNames.java | 1 + .../StandardConnectionPropertyDefiner.java | 1 + .../firebirdsql/common/FBTestProperties.java | 4 +- .../jdbc/ConnectionPropertiesTest.java | 79 ++++++++++++++++++- 9 files changed, 132 insertions(+), 3 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index f5586dff8..0b4645ff5 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -90,6 +90,7 @@ Jaybird cannot honour this requirement for plain `Statement`, as schema resoluti ** `getCatalogs`: TODO: Maybe add a custom column with a list of schema names for `useCatalogAsPackage=true`? * TODO: Define effects for management API * TODO: Redesign retrieval of selectable procedure information (`StoredProcedureMetaDataFactory`) to be able to find stored procedures by schema +* TODO: Add information to Jaybird manual Note to self: use `// TODO Add schema support` in places that you identify need to get/improve schema support, while working on schema support elsewhere diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index cff02351d..290bf07bb 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -515,6 +515,14 @@ Firebird 6.0 introduces schemas, and Jaybird 7 provides support for schemas as d Changes include: +* Connection property `searchPath` sets the initial search path of the connection. +The search path is the list of schemas that will be searched for schema-bound objects if they are not explicitly qualified with a schema name. +The first _valid_ schema is the current schema of the connection. ++ +The value of `searchPath` is a comma-separated list of schema names. +Schema names that are case-sensitive or otherwise non-regular identifiers, must be quoted. +Unknown schema names are ignored. +If `SYSTEM` is not included, the server will automatically add it as the last schema. * `DatabaseMetaData` ** Methods accepting a `schema` (exact match if not `null`) or `schemaPattern` (`LIKE` match if not `null`) will return no rows for value empty (`""`) on Firebird 6.0 and higher; use `null` or -- `schemaPattern` only -- `"%"` to match all schemas @@ -531,6 +539,8 @@ The `schema` parameter is ignored on Firebird 5.0 and older. ** `getSchemas(String catalog, String schemaPattern)` returns all schemas matching the `LIKE` pattern `schemaPattern`, with the following caveats *** `catalog` non-empty will return no rows; we recommend to always use `null` for `catalog` +* `ResultSetMetaData` +** `getSchemaName` reports the schema if the column is backed by a table, otherwise empty string (`""`) // TODO add major changes diff --git a/src/main/org/firebirdsql/ds/AbstractConnectionPropertiesDataSource.java b/src/main/org/firebirdsql/ds/AbstractConnectionPropertiesDataSource.java index e2225a143..99dea317a 100644 --- a/src/main/org/firebirdsql/ds/AbstractConnectionPropertiesDataSource.java +++ b/src/main/org/firebirdsql/ds/AbstractConnectionPropertiesDataSource.java @@ -518,6 +518,16 @@ public void setMaxBlobCacheSize(int maxBlobCacheSize) { FirebirdConnectionProperties.super.setMaxBlobCacheSize(maxBlobCacheSize); } + @Override + public String getSearchPath() { + return FirebirdConnectionProperties.super.getSearchPath(); + } + + @Override + public void setSearchPath(String searchPath) { + FirebirdConnectionProperties.super.setSearchPath(searchPath); + } + @SuppressWarnings("deprecation") @Deprecated(since = "5") @Override diff --git a/src/main/org/firebirdsql/jaybird/fb/constants/DpbItems.java b/src/main/org/firebirdsql/jaybird/fb/constants/DpbItems.java index 41f4c0728..6cfa83678 100644 --- a/src/main/org/firebirdsql/jaybird/fb/constants/DpbItems.java +++ b/src/main/org/firebirdsql/jaybird/fb/constants/DpbItems.java @@ -128,6 +128,9 @@ public final class DpbItems { // Firebird 6 constants public static final int isc_dpb_owner = 102; + public static final int isc_dpb_search_path = 105; + public static final int isc_dpb_blr_request_search_path = 106; + public static final int isc_dpb_gbak_restore_has_schema = 107; private DpbItems() { // no instances diff --git a/src/main/org/firebirdsql/jaybird/props/DatabaseConnectionProperties.java b/src/main/org/firebirdsql/jaybird/props/DatabaseConnectionProperties.java index fc09fdbe8..2ec37f9e5 100644 --- a/src/main/org/firebirdsql/jaybird/props/DatabaseConnectionProperties.java +++ b/src/main/org/firebirdsql/jaybird/props/DatabaseConnectionProperties.java @@ -824,4 +824,30 @@ default void setMaxBlobCacheSize(int maxBlobCacheSize) { setIntProperty(PropertyNames.maxBlobCacheSize, Math.max(0, maxBlobCacheSize)); } + /** + * @return the initial search path of the connection, {@code null} if the server default search path is used + * @see #setSearchPath(String) + */ + default String getSearchPath() { + return getProperty(PropertyNames.searchPath); + } + + /** + * Sets the initial search path of the connection. The search path is a list of schemas that will be searched for + * unqualified objects (i.e. without an explicit schema). + *

+ * This only applies to Firebird 6.0 and higher. + *

+ *

+ * The default value is {@code null}, which uses the server default (on Firebird 6.0, `PUBLIC, SYSTEM`). + * Case-sensitive, or otherwise non-regular identifiers need to be explicitly quoted. + *

+ * + * @param searchPath + * list of comma-separated schema names + */ + default void setSearchPath(String searchPath) { + setProperty(PropertyNames.searchPath, searchPath); + } + } diff --git a/src/main/org/firebirdsql/jaybird/props/PropertyNames.java b/src/main/org/firebirdsql/jaybird/props/PropertyNames.java index 857f6f8d4..2bc43b8c9 100644 --- a/src/main/org/firebirdsql/jaybird/props/PropertyNames.java +++ b/src/main/org/firebirdsql/jaybird/props/PropertyNames.java @@ -69,6 +69,7 @@ public final class PropertyNames { public static final String asyncFetch = "asyncFetch"; public static final String maxInlineBlobSize = "maxInlineBlobSize"; public static final String maxBlobCacheSize = "maxBlobCacheSize"; + public static final String searchPath = "searchPath"; // service connection public static final String expectedDb = "expectedDb"; diff --git a/src/main/org/firebirdsql/jaybird/props/internal/StandardConnectionPropertyDefiner.java b/src/main/org/firebirdsql/jaybird/props/internal/StandardConnectionPropertyDefiner.java index bc84e971a..41203451e 100644 --- a/src/main/org/firebirdsql/jaybird/props/internal/StandardConnectionPropertyDefiner.java +++ b/src/main/org/firebirdsql/jaybird/props/internal/StandardConnectionPropertyDefiner.java @@ -105,6 +105,7 @@ public Stream defineProperties() { .dpbItem(isc_dpb_max_inline_blob_size), builder(maxBlobCacheSize).type(INT).aliases("max_blob_cache_size", "isc_dpb_max_blob_cache_size") .dpbItem(isc_dpb_max_blob_cache_size), + builder(searchPath).aliases("search_path", "isc_dpb_search_path").dpbItem(isc_dpb_search_path), // TODO Consider removing this property, otherwise formally add it to PropertyNames builder("filename_charset"), diff --git a/src/test/org/firebirdsql/common/FBTestProperties.java b/src/test/org/firebirdsql/common/FBTestProperties.java index bf239013d..3a8c05c41 100644 --- a/src/test/org/firebirdsql/common/FBTestProperties.java +++ b/src/test/org/firebirdsql/common/FBTestProperties.java @@ -135,7 +135,9 @@ public static Properties getDefaultPropertiesForConnection() { */ public static Properties getPropertiesForConnection(String k1, String v1) { Properties props = getDefaultPropertiesForConnection(); - props.setProperty(k1, v1); + if (v1 != null) { + props.setProperty(k1, v1); + } return props; } diff --git a/src/test/org/firebirdsql/jdbc/ConnectionPropertiesTest.java b/src/test/org/firebirdsql/jdbc/ConnectionPropertiesTest.java index 8253311d8..0df91a59d 100644 --- a/src/test/org/firebirdsql/jdbc/ConnectionPropertiesTest.java +++ b/src/test/org/firebirdsql/jdbc/ConnectionPropertiesTest.java @@ -1,17 +1,27 @@ -// SPDX-FileCopyrightText: Copyright 2019-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2019-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.ds.FBSimpleDataSource; +import org.firebirdsql.jaybird.props.PropertyNames; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.ResultSetMetaData; +import java.util.List; import java.util.Properties; +import java.util.stream.Stream; import static org.firebirdsql.common.FBTestProperties.*; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.junit.jupiter.api.Assertions.assertEquals; /** @@ -22,7 +32,19 @@ class ConnectionPropertiesTest { @RegisterExtension - static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll(); + static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = + UsesDatabaseExtension.usesDatabaseForAll(getDbInitStatements()); + + private static List getDbInitStatements() { + FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); + if (supportInfo.supportsSchemas()) { + return List.of( + "create schema OTHER_SCHEMA", + "create table PUBLIC.TEST_TABLE (ID integer)", + "create table OTHER_SCHEMA.TEST_TABLE (OTHER_ID bigint)"); + } + return List.of(); + } @Test void testProperty_defaultIsolation_onDataSource() throws Exception { @@ -59,6 +81,59 @@ void testProperty_isolation_onDriverManager() throws Exception { } } + @ParameterizedTest + @MethodSource("searchPathTestCases") + void testProperty_searchPath_onDriverManager(String searchPath, String expectedSearchPath, String expectedSchema, + String expectedColumn) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + try (Connection connection = getConnectionViaDriverManager(PropertyNames.searchPath, searchPath)) { + verifySearchPath(connection, expectedSearchPath, expectedSchema, expectedColumn); + } + } + + @ParameterizedTest + @MethodSource("searchPathTestCases") + void testProperty_searchPath_onataSource(String searchPath, String expectedSearchPath, String expectedSchema, + String expectedColumn) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + FBSimpleDataSource ds = createDataSource(); + + ds.setSearchPath(searchPath); + + try (var connection = ds.getConnection()) { + verifySearchPath(connection, expectedSearchPath, expectedSchema, expectedColumn); + } + } + + private static void verifySearchPath(Connection connection, String expectedSearchPath, String expectedSchema, + String expectedColumn) throws Exception { + connection.setAutoCommit(false); + try (var stmt = connection.createStatement()) { + try (var rs = stmt.executeQuery( + "select rdb$get_context('SYSTEM', 'SEARCH_PATH') from SYSTEM.RDB$DATABASE")) { + assertNextRow(rs); + assertEquals(expectedSearchPath, rs.getString(1), "unexpected search path"); + } + } + try (var pstmt = connection.prepareStatement("select * from TEST_TABLE")) { + ResultSetMetaData metaData = pstmt.getMetaData(); + assertEquals("TEST_TABLE", metaData.getTableName(1), "tableName"); + assertEquals(expectedSchema, metaData.getSchemaName(1), "schemaName"); + assertEquals(expectedColumn, metaData.getColumnName(1), "columnName"); + } + } + + static Stream searchPathTestCases() { + return Stream.of( + Arguments.arguments(null, "\"PUBLIC\", \"SYSTEM\"", "PUBLIC", "ID"), + Arguments.arguments("PUBLIC", "\"PUBLIC\", \"SYSTEM\"", "PUBLIC", "ID"), + Arguments.arguments("OTHER_SCHEMA", "\"OTHER_SCHEMA\", \"SYSTEM\"", "OTHER_SCHEMA", "OTHER_ID"), + Arguments.arguments("PUBLIC, OTHER_SCHEMA", "\"PUBLIC\", \"OTHER_SCHEMA\", \"SYSTEM\"", "PUBLIC", "ID"), + Arguments.arguments("OTHER_SCHEMA, PUBLIC", "\"OTHER_SCHEMA\", \"PUBLIC\", \"SYSTEM\"", "OTHER_SCHEMA", + "OTHER_ID")); + } + + private FBSimpleDataSource createDataSource() { return configureDefaultDbProperties(new FBSimpleDataSource()); } From 276d8bc1b9730ad65ee450554e488e92080235c3 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Wed, 25 Jun 2025 09:30:25 +0200 Subject: [PATCH 29/64] Add supportInfoFor(FirebirdConnection) to avoid wrapper check --- .../firebirdsql/util/FirebirdSupportInfo.java | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java index e62fcfac2..446428e09 100644 --- a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java +++ b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java @@ -37,6 +37,8 @@ @SuppressWarnings("unused") public final class FirebirdSupportInfo { + private static final int SUPPORTED_MIN_VERSION = 3; + private final GDSServerVersion serverVersion; private FirebirdSupportInfo(GDSServerVersion serverVersion) { @@ -810,7 +812,7 @@ public T ifSchemaElse(T forSchema, T withoutSchema) { * @return {@code true} when this Firebird version is considered a supported version */ public boolean isSupportedVersion() { - return isVersionEqualOrAbove(3); + return isVersionEqualOrAbove(SUPPORTED_MIN_VERSION); } /** @@ -844,15 +846,15 @@ public static FirebirdSupportInfo supportInfoFor(FbAttachment attachment) { /** * @param connection - * A database connection (NOTE: {@link java.sql.Connection} is used, but it must be or unwrap to a - * {@link org.firebirdsql.jdbc.FirebirdConnection}). + * a database connection (NOTE: it must be or unwrap to a {@link org.firebirdsql.jdbc.FirebirdConnection}) * @return FirebirdVersionSupport instance * @throws java.lang.IllegalArgumentException - * When the provided connection is not an instance of or wrapper for + * when the provided connection is not an instance of or wrapper for * {@link org.firebirdsql.jdbc.FirebirdConnection} * @throws java.lang.IllegalStateException - * When an SQLException occurs unwrapping the connection, or creating + * when an SQLException occurs unwrapping the connection, or creating * the {@link org.firebirdsql.util.FirebirdSupportInfo} instance + * @see #supportInfoFor(FirebirdConnection) */ public static FirebirdSupportInfo supportInfoFor(java.sql.Connection connection) { try { @@ -867,4 +869,19 @@ public static FirebirdSupportInfo supportInfoFor(java.sql.Connection connection) } } + /** + * @param connection + * a database connection + * @return FirebirdVersionSupport instance + * @throws java.lang.IllegalStateException + * when an SQLException occurs creating the {@link org.firebirdsql.util.FirebirdSupportInfo} instance + */ + public static FirebirdSupportInfo supportInfoFor(FirebirdConnection connection) { + try { + return supportInfoFor(connection.getFbDatabase()); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + } From 501eb78caa693fd9ab6b307415bcff1820be111f Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Wed, 25 Jun 2025 09:58:34 +0200 Subject: [PATCH 30/64] Tighten up StoredProcedureMetaData, use support info --- src/docs/asciidoc/release_notes.adoc | 3 ++- .../firebirdsql/jdbc/ClientInfoProvider.java | 4 ++-- .../jdbc/StoredProcedureMetaData.java | 5 +++- .../jdbc/StoredProcedureMetaDataFactory.java | 23 ++++++++++++------- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 290bf07bb..8d6db8db8 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -682,13 +682,14 @@ If you are confronted with such a change, let us know on {firebird-java}[firebir * `FbWireOperations` ** The `ProcessAttachCallback` parameter of `authReceiveResponse` was removed, as all implementations did nothing, and since protocol 13, it wasn't only called for the attach response ** Interface `ProcessAttachCallback` was removed +* Interface `StoredProcedureMetaData` was made package-private [#breaking-changes-unlikely] === Unlikely breaking changes The following changes might cause issues, though we think this is unlikely: -// TODO Document unlikely breaking changes, or remove section +// TODO Document unlikely breaking changes, or comment out this section [#breaking-changes-for-jaybird-8] === Breaking changes for Jaybird 8 diff --git a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java index b6e04f73a..4ba27b864 100644 --- a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java +++ b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java @@ -195,8 +195,8 @@ public String getClientInfo(String name) throws SQLException { } } - private boolean hasSystemSchema() throws SQLException { - return connection.getMetaData().supportsSchemasInDataManipulation(); + private boolean hasSystemSchema() { + return supportInfoFor(connection).supportsSchemas(); } private void renderGetValue(StringBuilder sb, ClientInfoProperty property, QuoteStrategy quoteStrategy) { diff --git a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaData.java b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaData.java index 4d8aeb1c7..7c4e8599f 100644 --- a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaData.java +++ b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaData.java @@ -4,6 +4,7 @@ package org.firebirdsql.jdbc; import org.firebirdsql.util.InternalApi; +import org.jspecify.annotations.NullMarked; import java.sql.SQLException; @@ -15,7 +16,9 @@ *

*/ @InternalApi -public interface StoredProcedureMetaData { +@NullMarked +sealed interface StoredProcedureMetaData + permits DefaultCallableStatementMetaData, DummyCallableStatementMetaData { /** * Determine if the "selectability" of procedures is available. diff --git a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java index d229f64a8..b6eeec797 100644 --- a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java +++ b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java @@ -3,20 +3,21 @@ // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; -import java.sql.Connection; -import java.sql.ResultSet; +import org.jspecify.annotations.NullMarked; + import java.sql.SQLException; import java.sql.SQLNonTransientException; -import java.sql.Statement; import java.util.HashSet; import java.util.Locale; import java.util.Set; import static org.firebirdsql.jdbc.SQLStateConstants.SQL_STATE_GENERAL_ERROR; +import static org.firebirdsql.util.FirebirdSupportInfo.supportInfoFor; /** * Factory to retrieve meta-data on stored procedures in a Firebird database. */ +@NullMarked final class StoredProcedureMetaDataFactory { private StoredProcedureMetaDataFactory() { @@ -59,6 +60,7 @@ private static boolean versionEqualOrAboveFB21(int majorVersion, int minorVersio /** * A fully-functional implementation of {@link StoredProcedureMetaData}. */ +@NullMarked final class DefaultCallableStatementMetaData implements StoredProcedureMetaData { // TODO Add schema support: solution needs to be reworked to support schemas, which will cascade into @@ -67,18 +69,18 @@ final class DefaultCallableStatementMetaData implements StoredProcedureMetaData final Set selectableProcedureNames = new HashSet<>(); - public DefaultCallableStatementMetaData(Connection connection) + DefaultCallableStatementMetaData(FBConnection connection) throws SQLException { loadSelectableProcedureNames(connection); } - private void loadSelectableProcedureNames(Connection connection) throws SQLException { - try (Statement stmt = connection.createStatement()) { + private void loadSelectableProcedureNames(FBConnection connection) throws SQLException { + try (var stmt = connection.createStatement()) { // TODO Replace with looking for specific procedure - String sql = connection.getMetaData().supportsSchemasInDataManipulation() + String sql = supportInfoFor(connection).supportsSchemas() ? "SELECT RDB$PROCEDURE_NAME FROM SYSTEM.RDB$PROCEDURES WHERE RDB$PROCEDURE_TYPE = 1" : "SELECT RDB$PROCEDURE_NAME FROM RDB$PROCEDURES WHERE RDB$PROCEDURE_TYPE = 1"; - try (ResultSet resultSet = stmt.executeQuery(sql)) { + try (var resultSet = stmt.executeQuery(sql)) { while (resultSet.next()) { selectableProcedureNames.add(resultSet.getString(1).trim().toUpperCase(Locale.ROOT)); } @@ -86,10 +88,12 @@ private void loadSelectableProcedureNames(Connection connection) throws SQLExcep } } + @Override public boolean canGetSelectableInformation() { return true; } + @Override public boolean isSelectable(String procedureName) { return selectableProcedureNames.contains(procedureName.toUpperCase(Locale.ROOT)); } @@ -98,12 +102,15 @@ public boolean isSelectable(String procedureName) { /** * A non-functional implementation of {@link StoredProcedureMetaData} for databases that don't have this capability. */ +@NullMarked final class DummyCallableStatementMetaData implements StoredProcedureMetaData { + @Override public boolean canGetSelectableInformation() { return false; } + @Override public boolean isSelectable(String procedureName) throws SQLException { throw new SQLNonTransientException("A DummyCallableStatementMetaData can't retrieve selectable settings", SQL_STATE_GENERAL_ERROR); From ca102d27a17bcbe6fec9676c0ef22a0994a4fbf7 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Wed, 9 Jul 2025 18:14:10 +0200 Subject: [PATCH 31/64] #882 Implement Connection.get/setSchema --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 13 +- src/docs/asciidoc/release_notes.adoc | 9 +- .../jaybird/parser/SearchPathExtractor.java | 79 +++++++ .../jaybird/parser/SqlTokenizer.java | 6 +- .../jaybird/util/SearchPathHelper.java | 60 +++++ .../firebirdsql/jdbc/ClientInfoProvider.java | 17 +- .../org/firebirdsql/jdbc/FBConnection.java | 67 +++++- .../firebirdsql/jdbc/FirebirdConnection.java | 29 ++- .../jdbc/MetadataStatementHolder.java | 63 +++++ .../org/firebirdsql/jdbc/SchemaChanger.java | 202 ++++++++++++++++ .../jdbc/StoredProcedureMetaDataFactory.java | 2 +- .../parser/SearchPathExtractorTest.java | 47 ++++ .../jaybird/parser/SqlTokenizerTest.java | 33 ++- .../jaybird/util/SearchPathHelperTest.java | 57 +++++ .../jdbc/FBConnectionSchemaTest.java | 223 ++++++++++++++++++ 15 files changed, 869 insertions(+), 38 deletions(-) create mode 100644 src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java create mode 100644 src/main/org/firebirdsql/jaybird/util/SearchPathHelper.java create mode 100644 src/main/org/firebirdsql/jdbc/MetadataStatementHolder.java create mode 100644 src/main/org/firebirdsql/jdbc/SchemaChanger.java create mode 100644 src/test/org/firebirdsql/jaybird/parser/SearchPathExtractorTest.java create mode 100644 src/test/org/firebirdsql/jaybird/util/SearchPathHelperTest.java create mode 100644 src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index 0b4645ff5..9c42211d5 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -33,17 +33,17 @@ The current search path can be altered with `SET SEARCH_PATH TO ...`. `ALTER SESSION RESET` reverts to the session default. * If `SYSTEM` is not on the search path, it is automatically searched last * The "`current`" schema cannot be set separately; -the first valid schema listed in the search path is considered the current schema. +the first valid (i.e. existing) schema listed in the search path is considered the current schema. * `CURRENT_SCHEMA` and `RDB$GET_CONTEXT('SYSTEM', 'CURRENT_SCHEMA')` return the first valid schema from the search path * `RDB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')` returns the current search path * Objects not qualified with a schema name will be resolved using the current search path. This is done -- with some exceptions -- at prepare time. -* TBP has new item `isc_tpb_lock_table_schema` to specify the schema of a table to be locked (1 byte length + string data) +* TPB has new item `isc_tpb_lock_table_schema` to specify the schema of a table to be locked (1 byte length + string data) * Gbak has additional options to include/exclude (skip) schema data in backup or restore, similar to existing options to include/exclude tables * Gstat has additional options to specify a schema for operations involving tables * For validation, `val_sch_incl` and `val_sch_excl` (I don't think we use the equivalent,`val_tab_incl`/`val_tab_excl` in Jaybird, so might not be relevant) -JDBC defines various methods, parameters, and return values or result set columns that are or are related to schemas. +JDBC defines various methods, parameters, and return values or result set columns that are related to schemas. Jaybird 5 is the "`long-term support`" version for Java 8. @@ -76,18 +76,21 @@ the connection will not store this value The schema name is stored _only_ for this replacement operation (i.e. it will not be returned by `getSchema`!) + ** The name must match exactly as is stored in the metadata (it is always case-sensitive!) -** Jaybird will take care of quoting, and will always quote +** Jaybird will take care of quoting, and will always quote on dialect 3 ** Existence of the schema is **not** checked, so it is possible the current schema does not change with this operation, as `CURRENT_SCHEMA` reports the first _valid_ schema ** JDBC specifies that "`__Calling ``setSchema`` has no effect on previously created or prepared Statement objects.__`"; Jaybird cannot honour this requirement for plain `Statement`, as schema resolution is on prepare time (which for plain `Statement` is on execute), and not always for `CallableStatement` (as the implementation may delay actual prepare until execution). * Request `isc_info_sql_relation_schema` after preparing a query, record it in `FieldDescriptor`, and return it were relevant for JDBC (e.g. `ResultSetMetaData.getSchemaName(int)`) ** For Firebird 5.0 and older, we need to ensure that JDBC methods continue to report the correct value (i.e. `""` for schema-less objects) * A Firebird 6.0 variant of the `DatabaseMetaData` and other internal metadata queries needs to be written to address at least the following things: -** Explicitly qualify metadata tables with `SYSTEM`, so the queries will work even if `SYSTEM` is not on the search path. +** Explicitly qualify metadata tables with `SYSTEM`, so the queries will work even if another schema on the search path contains tables with the same names as the system tables. ** Returning schema names, and qualified object names where relevant (e.g. in `DatabaseMetaData` result sets) ** Include schema names in joins to ensure matching the right objects ** Allow searching for schema or schema pattern as specified in JDBC, or were needed for internal metadata queries ** `getCatalogs`: TODO: Maybe add a custom column with a list of schema names for `useCatalogAsPackage=true`? +* `FirebirdConnection` +** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported +** Added method `List getSearchPatList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported * TODO: Define effects for management API * TODO: Redesign retrieval of selectable procedure information (`StoredProcedureMetaDataFactory`) to be able to find stored procedures by schema * TODO: Add information to Jaybird manual diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 8d6db8db8..773f78b4f 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -524,12 +524,12 @@ Schema names that are case-sensitive or otherwise non-regular identifiers, must Unknown schema names are ignored. If `SYSTEM` is not included, the server will automatically add it as the last schema. * `DatabaseMetaData` -** Methods accepting a `schema` (exact match if not `null`) or `schemaPattern` (`LIKE` match if not `null`) will return no rows for value empty (`""`) on Firebird 6.0 and higher; +** Methods accepting a `schema` (exact match if not `null`) or `schemaPattern` (`LIKE` match if not `null`) will return no rows for value empty (`++""++`) on Firebird 6.0 and higher; use `null` or -- `schemaPattern` only -- `"%"` to match all schemas ** `getCatalogs` -- when `useCatalogAsPackage=true` -- returns all (distinct) package names over all schemas. Within the limitations and specification of the JDBC API, this method cannot be used to find out which schema(s) contain a specific package name. // TODO Maybe add a custom column with a list of schema names? -** `getColumnPrivileges` and `getTablePrivileges` received an additional column, `JB_GRANTEE_SCHEMA`, which is non-``null`` for grantees that are schema-bound (e.g. a procedure). +** `getColumnPrivileges` and `getTablePrivileges` received an additional column, `JB_GRANTEE_SCHEMA`, which is non-``null`` for grantees that are schema-bound (e.g. procedures). + As this is a non-standard column, we recommend to always retrieve it by name. ** `getProcedureSourceCode`/`getTriggerSourceCode`/`getViewSourceCode` now also have an overload accepting the schema; @@ -537,10 +537,13 @@ the overloads without a `schema` parameter, or `schema` is `null` will return th The `schema` parameter is ignored on Firebird 5.0 and older. ** `getSchemas()` returns all defined schemas ** `getSchemas(String catalog, String schemaPattern)` returns all schemas matching the `LIKE` pattern `schemaPattern`, with the following caveats -*** `catalog` non-empty will return no rows; +*** `catalog` non-empty will return no rows -- even if `useCatalogAsPackage` is `true`; we recommend to always use `null` for `catalog` * `ResultSetMetaData` ** `getSchemaName` reports the schema if the column is backed by a table, otherwise empty string (`""`) +* `FirebirdConnection`/`FBConnection` +** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported +** Added method `List getSearchPatList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported // TODO add major changes diff --git a/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java b/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java new file mode 100644 index 000000000..093f44529 --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.parser; + +import org.firebirdsql.jaybird.util.SearchPathHelper; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Token visitor to extract a search path to a list of unquoted schema names. + *

+ * This visitor is written for the needs of {@link SearchPathHelper#parseSearchPath(String)}, and + * may not be generally usable. + *

+ */ +public final class SearchPathExtractor implements TokenVisitor { + + private final List identifiers = new ArrayList<>(); + @Nullable + private Token previousToken; + + @Override + public void visitToken(Token token, VisitorRegistrar visitorRegistrar) { + if (token.isWhitespaceOrComment()) return; + try { + extractIdentifier(token, visitorRegistrar); + } finally { + previousToken = token; + } + } + + private void extractIdentifier(Token token, VisitorRegistrar visitorRegistrar) { + if (isPreviousTokenSeparator()) { + if (token instanceof QuotedIdentifierToken quotedIdentifier) { + identifiers.add(quotedIdentifier.name()); + } else if (token instanceof GenericToken identifier) { + // Firebird returns the search path with quoted identifiers, but this offers extra flexibility if needed + identifiers.add(identifier.text().toUpperCase(Locale.ROOT)); + } else { + // Unexpected token, end parsing + visitorRegistrar.removeVisitor(this); + identifiers.clear(); + } + } else if (!(token instanceof CommaToken && isPreviousTokenIdentifier())) { + // Unexpected token, end parsing + visitorRegistrar.removeVisitor(this); + identifiers.clear(); + } + } + + private boolean isPreviousTokenIdentifier() { + return previousToken instanceof QuotedIdentifierToken || previousToken instanceof GenericToken; + } + + private boolean isPreviousTokenSeparator() { + return previousToken instanceof CommaToken || previousToken == null; + } + + @Override + public void complete(VisitorRegistrar visitorRegistrar) { + if (!isPreviousTokenIdentifier()) { + // Unexpected token, clear list; for most cases, we already cleared the list, except if last was CommaToken + identifiers.clear(); + } + } + + /** + * The extract search path list, or empty if not parsed or if the parsed text was not a valid search path list. + * + * @return immutable list of unquoted search path entries + */ + public List getSearchPathList() { + return List.copyOf(identifiers); + } + +} \ No newline at end of file diff --git a/src/main/org/firebirdsql/jaybird/parser/SqlTokenizer.java b/src/main/org/firebirdsql/jaybird/parser/SqlTokenizer.java index 41148acfd..6a4744084 100644 --- a/src/main/org/firebirdsql/jaybird/parser/SqlTokenizer.java +++ b/src/main/org/firebirdsql/jaybird/parser/SqlTokenizer.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2021-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2021-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.parser; @@ -513,8 +513,8 @@ private boolean detectToken(char[][] expectedChars) { private static boolean isNormalTokenBoundary(int c) { return switch (c) { - case EOF, '\t', '\n', '\r', ' ', '(', ')', '{', '}', '[', ']', '\'', '"', ':', ';', '.', '+', '-', '/', '*', - '=', '>', '<', '~', '^', '!', '?' -> true; + case EOF, '\t', '\n', '\r', ' ', '(', ')', '{', '}', '[', ']', '\'', '"', ':', ';', '.', ',', '+', '-', '/', + '*', '=', '>', '<', '~', '^', '!', '?' -> true; default -> false; }; } diff --git a/src/main/org/firebirdsql/jaybird/util/SearchPathHelper.java b/src/main/org/firebirdsql/jaybird/util/SearchPathHelper.java new file mode 100644 index 000000000..a323c454b --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/util/SearchPathHelper.java @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.jaybird.parser.FirebirdReservedWords; +import org.firebirdsql.jaybird.parser.SearchPathExtractor; +import org.firebirdsql.jaybird.parser.SqlParser; +import org.firebirdsql.jdbc.QuoteStrategy; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Helpers for working with identifiers. + * + * @since 7 + */ +public final class SearchPathHelper { + + /** + * Parses the elements of the search path to a list of unquoted schema names. + * + * @param searchPath + * comma-separated search path, with — optionally — quoted schema names + * @return list of unquoted schema names, or empty if {@code searchPath} is {@code null}, blank or an invalid search + * path (e.g. not a comma-separated list of potential schema names, or unquoted schema names are reserved words) + */ + public static List parseSearchPath(@Nullable String searchPath) { + if (searchPath == null || searchPath.isBlank()) return List.of(); + var extractor = new SearchPathExtractor(); + SqlParser.withReservedWords(FirebirdReservedWords.latest()) + .withVisitor(extractor) + .of(searchPath) + .parse(); + return extractor.getSearchPathList(); + } + + /** + * Creates a search path from {@code searchPathList}. + * + * @param searchPathList + * list of unquoted schema names, blank values are ignored + * @param quoteStrategy + * quote strategy + * @return comma and space separated search path, quoted according to {@code quoteStrategy} + */ + public static String toSearchPath(List searchPathList, QuoteStrategy quoteStrategy) { + if (searchPathList.isEmpty()) return ""; + // Assume each entry takes 15 characters, including quotes and separators + var sb = new StringBuilder(searchPathList.size() * 15); + for (String schema : searchPathList) { + if (schema.isBlank()) continue; + quoteStrategy.appendQuoted(schema, sb).append(", "); + } + // Remove last separator + sb.setLength(sb.length() - 2); + return sb.toString(); + } + +} diff --git a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java index 4ba27b864..7b3ae5df9 100644 --- a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java +++ b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java @@ -4,10 +4,8 @@ import org.firebirdsql.gds.ISCConstants; import org.firebirdsql.gds.ng.FbExceptionBuilder; -import org.firebirdsql.jdbc.InternalTransactionCoordinator.MetaDataTransactionCoordinator; import java.sql.ClientInfoStatus; -import java.sql.ResultSet; import java.sql.SQLClientInfoException; import java.sql.SQLException; import java.sql.Statement; @@ -54,11 +52,10 @@ final class ClientInfoProvider { .collect(toUnmodifiableSet()); private final FBConnection connection; + // Holds statement used for setting or retrieving client info properties. + private final MetadataStatementHolder statementHolder; // if null, use DEFAULT_CLIENT_INFO_PROPERTIES private Set knownProperties; - // Statement used for setting or retrieving client info properties. - // We don't try to close this statement, and rely on it getting closed by connection close - private Statement statement; ClientInfoProvider(FBConnection connection) throws SQLException { connection.checkValidity(); @@ -67,17 +64,11 @@ final class ClientInfoProvider { "Required functionality (RDB$SET_CONTEXT()) only available in Firebird 2.0 or higher"); } this.connection = connection; + statementHolder = new MetadataStatementHolder(connection); } private Statement getStatement() throws SQLException { - Statement statement = this.statement; - if (statement != null && !statement.isClosed()) return statement; - var metaDataTransactionCoordinator = new MetaDataTransactionCoordinator(connection.txCoordinator); - // Create statement which piggybacks on active transaction, starts one when needed, but does not commit (not - // even in auto-commit) - var rsBehavior = ResultSetBehavior.of( - ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY, ResultSet.CLOSE_CURSORS_AT_COMMIT); - return this.statement = new FBStatement(connection, rsBehavior, metaDataTransactionCoordinator); + return statementHolder.getStatement(); } /** diff --git a/src/main/org/firebirdsql/jdbc/FBConnection.java b/src/main/org/firebirdsql/jdbc/FBConnection.java index 06142604e..6853e49d2 100644 --- a/src/main/org/firebirdsql/jdbc/FBConnection.java +++ b/src/main/org/firebirdsql/jdbc/FBConnection.java @@ -4,7 +4,7 @@ SPDX-FileCopyrightText: Copyright 2002-2003 Blas Rodriguez Somoza SPDX-FileCopyrightText: Copyright 2003 Nikolay Samofatov SPDX-FileCopyrightText: Copyright 2007 Gabriel Reid - SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-FileCopyrightText: Copyright 2016 Adriano dos Santos Fernandes SPDX-License-Identifier: LGPL-2.1-or-later */ @@ -86,6 +86,7 @@ public class FBConnection implements FirebirdConnection { private StoredProcedureMetaData storedProcedureMetaData; private GeneratedKeysSupport generatedKeysSupport; private ClientInfoProvider clientInfoProvider; + private SchemaChanger schemaChanger; private boolean readOnly; /** @@ -577,7 +578,7 @@ private boolean isValidImpl(int timeout) { } @Override - public DatabaseMetaData getMetaData() throws SQLException { + public FirebirdDatabaseMetaData getMetaData() throws SQLException { try (LockCloseable ignored = withLock()) { checkValidity(); if (metaData == null) @@ -1058,25 +1059,69 @@ public T unwrap(Class iface) throws SQLException { /** * {@inheritDoc} *

- * Implementation ignores calls to this method as schemas are not supported. + * Schemas are supported on Firebird 6.0 and higher. On older Firebird versions, this method is silently ignored, + * except for a connection validity check. Contrary to specified in the JDBC API, calling this method + * will affect previously created {@link Statement} objects, and may affect + * previously created {@link CallableStatement} objects. That is because the current search path is applied when + * preparing a statement (which for {@code Statement} happens on execute, and for {@code CallableStatement} may be + * delayed until execute). *

+ *

+ * This method modifies the search path of the connection by adding the specified {@code schema} as the first + * schema. This method does not check if the specified schema exists, so it may not actually change the current + * schema. Interleaving calls to this method with explicit execution of {@code SET SEARCH_PATH TO ...} may lead to + * undefined behaviour. + *

+ * + * @param schema + * correctly capitalized schema name, without quotes, {@code null} or blank is not allowed if + * schemas are supported */ @Override public void setSchema(String schema) throws SQLException { - // Ignore: no schema support - checkValidity(); + try (var ignored = withLock()) { + checkValidity(); + getSchemaChanger().setSchema(schema); + } } /** * {@inheritDoc} + *

+ * Schemas are supported on Firebird 6.0 and higher. The current schema is the value of {@code CURRENT_SCHEMA}, + * which reports the first valid schema of the search path. + *

* - * @return Always {@code null} as schemas ar not supported + * @return the current schema, on Firebird 5.0 and older always {@code null} as schemas ar not supported */ @Override - @SuppressWarnings("java:S4144") - public String getSchema() throws SQLException { - checkValidity(); - return null; + public final String getSchema() throws SQLException { + return getSchemaInfo().schema(); + } + + @Override + public final String getSearchPath() throws SQLException { + return getSchemaInfo().searchPath(); + } + + @Override + public final List getSearchPathList() throws SQLException { + return getSchemaInfo().toSearchPathList(); + } + + private SchemaChanger.SchemaInfo getSchemaInfo() throws SQLException { + try (var ignored = withLock()) { + return getSchemaChanger().getCurrentSchemaInfo(); + } + } + + private SchemaChanger getSchemaChanger() throws SQLException { + try (var ignored = withLock()) { + checkValidity(); + SchemaChanger schemaChanger = this.schemaChanger; + if (schemaChanger != null) return schemaChanger; + return this.schemaChanger = SchemaChanger.createInstance(this); + } } public void addWarning(SQLWarning warning) { @@ -1380,7 +1425,7 @@ QuoteStrategy getQuoteStrategy() throws SQLException { GeneratedKeysSupport getGeneratedKeysSupport() throws SQLException { if (generatedKeysSupport == null) { generatedKeysSupport = GeneratedKeysSupportFactory - .createFor(getGeneratedKeysEnabled(), (FirebirdDatabaseMetaData) getMetaData()); + .createFor(getGeneratedKeysEnabled(), getMetaData()); } return generatedKeysSupport; } diff --git a/src/main/org/firebirdsql/jdbc/FirebirdConnection.java b/src/main/org/firebirdsql/jdbc/FirebirdConnection.java index d6743c887..0af165010 100644 --- a/src/main/org/firebirdsql/jdbc/FirebirdConnection.java +++ b/src/main/org/firebirdsql/jdbc/FirebirdConnection.java @@ -1,15 +1,17 @@ // SPDX-FileCopyrightText: Copyright 2003-2005 Roman Rokytskyy -// SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause package org.firebirdsql.jdbc; import org.firebirdsql.gds.TransactionParameterBuffer; import org.firebirdsql.gds.ng.FbDatabase; +import org.firebirdsql.jaybird.util.SearchPathHelper; import org.firebirdsql.util.InternalApi; import java.sql.Blob; import java.sql.Connection; import java.sql.SQLException; +import java.util.List; /** * Extension of {@link Connection} interface providing access to Firebird specific features. @@ -126,4 +128,29 @@ public interface FirebirdConnection extends Connection { */ void resetKnownClientInfoProperties(); + /** + * Returns the schema search path. + * + * @return comma-separated list of quoted schema names of the search path, or {@code null} if schemas are not + * supported + * @throws SQLException + * if the connections is closed, or for database access errors + * @see #getSearchPathList() + * @since 7 + */ + String getSearchPath() throws SQLException; + + /** + * Returns the schema search path as a list of unquoted schema names. + * + * @return list of unquoted schema names, or an empty list if schemas are not supported + * @throws SQLException + * if the connection is closed, or for database access errors + * @see #getSearchPath() + * @since 7 + */ + default List getSearchPathList() throws SQLException { + return SearchPathHelper.parseSearchPath(getSearchPath()); + } + } \ No newline at end of file diff --git a/src/main/org/firebirdsql/jdbc/MetadataStatementHolder.java b/src/main/org/firebirdsql/jdbc/MetadataStatementHolder.java new file mode 100644 index 000000000..670675225 --- /dev/null +++ b/src/main/org/firebirdsql/jdbc/MetadataStatementHolder.java @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jdbc; + +import org.firebirdsql.util.InternalApi; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import static java.util.Objects.requireNonNull; + +/** + * A class that holds a single {@link Statement} for executing metadata queries. + *

+ * The statement returned by {@link #getStatement()} will piggyback on the active transaction, or start one when needed, + * but does not commit (not even in auto-commit). + *

+ * + * @since 7 + */ +@InternalApi +@NullMarked +final class MetadataStatementHolder { + + private final FBConnection connection; + private @Nullable FBStatement statement; + + MetadataStatementHolder(FBConnection connection) { + this.connection = requireNonNull(connection, "connection"); + } + + /** + * Returns an {@link FBStatement} suitable for executing metadata statements. + *

+ * For efficiency reasons, it is recommended that callers do not close the returned statement. If this holder has no + * statement, or if it has been closed, a new statement will be created. + *

+ * + * @return statement + * @throws SQLException + * if the connection is closed or the statement could not be allocated + * @see MetadataStatementHolder + */ + FBStatement getStatement() throws SQLException { + try (var ignored = connection.withLock()) { + FBStatement statement = this.statement; + if (statement != null && !statement.isClosed()) return statement; + return this.statement = createStatement(); + } + } + + private FBStatement createStatement() throws SQLException { + var metaDataTransactionCoordinator = + new InternalTransactionCoordinator.MetaDataTransactionCoordinator(connection.txCoordinator); + var rsBehavior = ResultSetBehavior.of( + ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY, ResultSet.CLOSE_CURSORS_AT_COMMIT); + return new FBStatement(connection, rsBehavior, metaDataTransactionCoordinator); + } + +} diff --git a/src/main/org/firebirdsql/jdbc/SchemaChanger.java b/src/main/org/firebirdsql/jdbc/SchemaChanger.java new file mode 100644 index 000000000..2466b2b07 --- /dev/null +++ b/src/main/org/firebirdsql/jdbc/SchemaChanger.java @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jdbc; + +import org.firebirdsql.jaybird.util.SearchPathHelper; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.sql.SQLDataException; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.firebirdsql.util.FirebirdSupportInfo.supportInfoFor; + +/** + * Changes the current schema of a connection, reports on the current schema, and tracks the information necessary to + * correctly perform subsequent modifications. + * + * @author Mark Rotteveel + * @since 7 + */ +@NullMarked +sealed abstract class SchemaChanger { + + /** + * If schemas are supported, attempts to change the current schema to {@code schema}. + *

+ * If {@code schema} is not an existing schema, the search path may be modified, but will not actually change + * the current schema. If schemas are not supported, this method is a no-op. + *

+ *

+ * The implementation tries to handle external search path changes, but correct functioning is + * not guaranteed if it is. + *

+ * + * @param schema + * new schema to set (non-{@code null} and not blank) + * @throws SQLException + * for database access errors, or if {@code schema} is {@code null} or blank if schemas are + * supported + */ + abstract void setSchema(String schema) throws SQLException; + + /** + * Current schema and search path. + *

+ * If schemas are not supported, an instance is returned with {@code schema} and {@code searchPath} {@code null}. + *

+ * + * @return current schema and search path + * @throws SQLException + * for database access errors + */ + abstract SchemaInfo getCurrentSchemaInfo() throws SQLException; + + /** + * Creates a schema changer. + *

+ * Depending on the Firebird version, the returned instance may ignore attempts to change the schema. + *

+ * + * @param connection + * connection + * @return a schema change (never {@code null}) + * @throws SQLException + * for database access errors + */ + static SchemaChanger createInstance(FBConnection connection) throws SQLException { + if (supportInfoFor(connection).supportsSchemas()) { + return new SchemaSupport(connection); + } + return NoSchemaSupport.getInstance(); + } + + /** + * Schema and search path. + * + * @param schema + * schema + * @param searchPath + * search path string + */ + record SchemaInfo(@Nullable String schema, @Nullable String searchPath) { + static final SchemaInfo NULL_INSTANCE = new SchemaInfo(null, null); + + List toSearchPathList() { + return SearchPathHelper.parseSearchPath(searchPath); + } + + boolean searchPathEquals(SchemaInfo other) { + return Objects.equals(this.searchPath, other.searchPath); + } + } + + /** + * Implementation for Firebird 6.0 and higher, which support schemas. + */ + private static final class SchemaSupport extends SchemaChanger { + + private final FBConnection connection; + // Holds statement used for querying and changing the schema + private final MetadataStatementHolder statementHolder; + private SchemaInfo schemaInfoAfterLastChange = SchemaInfo.NULL_INSTANCE; + /** + * {@code null} signifies no change recorded + */ + private @Nullable String lastSchemaChange; + private List lastSearchPath = List.of(); + + SchemaSupport(FBConnection connection) throws SQLException { + connection.checkValidity(); + if (!supportInfoFor(connection).supportsSchemas()) { + throw new FBDriverNotCapableException("Schema support is only available in Firebird 6.0 and higher"); + } + this.connection = connection; + statementHolder = new MetadataStatementHolder(connection); + } + + private Statement getStatement() throws SQLException { + return statementHolder.getStatement(); + } + + @Override + SchemaInfo getCurrentSchemaInfo() throws SQLException { + try (var rs = getStatement().executeQuery( + "select CURRENT_SCHEMA, RDB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH') from SYSTEM.RDB$DATABASE")) { + rs.next(); + return new SchemaInfo(rs.getString(1), rs.getString(2)); + } + } + + @Override + void setSchema(String schema) throws SQLException { + if (schema == null || schema.isBlank()) { + // TODO externalize? + throw new SQLDataException("schema must be non-null and not blank", + SQLStateConstants.SQL_STATE_INVALID_USE_NULL); + } + try (var ignored = connection.withLock()) { + SchemaInfo currentSchemaInfo = getCurrentSchemaInfo(); + final List newSearchPath; + if (currentSchemaInfo.searchPathEquals(schemaInfoAfterLastChange)) { + // assume no changes + if (schema.equals(lastSchemaChange)) return; + + // modify schema by replacing previous first schema with new first schema + newSearchPath = new ArrayList<>(lastSearchPath); + if (!newSearchPath.set(0, schema).equals(lastSchemaChange)) { + // TODO SQLstate, externalize? + throw new SQLException(("Expected first item in lastSearchPath to be '%s', but " + + "lastSearchPath was '%s'; this is probably a bug in Jaybird") + .formatted(lastSchemaChange, lastSearchPath)); + } + } else { + List originalSearchPath = currentSchemaInfo.toSearchPathList(); + if (lastSchemaChange == null + && !originalSearchPath.isEmpty() && schema.equals(originalSearchPath.get(0))) { + // Initial search path already has the specified schema first, don't change anything + return; + } + newSearchPath = new ArrayList<>(originalSearchPath.size() + 1); + newSearchPath.add(schema); + newSearchPath.addAll(originalSearchPath); + } + + //noinspection SqlSourceToSinkFlow + getStatement().execute("set search_path to " + + SearchPathHelper.toSearchPath(newSearchPath, connection.getQuoteStrategy())); + schemaInfoAfterLastChange = getCurrentSchemaInfo(); + lastSearchPath = List.copyOf(newSearchPath); + lastSchemaChange = schema; + } + } + } + + /** + * Implementation for Firebird 5.0 and older, which do not support schemas. + */ + static final class NoSchemaSupport extends SchemaChanger { + + private static final SchemaChanger INSTANCE = new NoSchemaSupport(); + + @Override + void setSchema(String schema) { + // do nothing (not even validate the name) + } + + @Override + SchemaInfo getCurrentSchemaInfo() { + return SchemaInfo.NULL_INSTANCE; + } + + static SchemaChanger getInstance() { + return INSTANCE; + } + + } + +} diff --git a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java index b6eeec797..eab78c4c8 100644 --- a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java +++ b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java @@ -45,7 +45,7 @@ private static boolean connectionHasProcedureMetadata(FBConnection connection) t if (connection.isIgnoreProcedureType()) { return false; } - FirebirdDatabaseMetaData metaData = (FirebirdDatabaseMetaData) connection.getMetaData(); + FirebirdDatabaseMetaData metaData = connection.getMetaData(); return versionEqualOrAboveFB21(metaData.getDatabaseMajorVersion(), metaData.getDatabaseMinorVersion()) && versionEqualOrAboveFB21(metaData.getOdsMajorVersion(), metaData.getOdsMinorVersion()); diff --git a/src/test/org/firebirdsql/jaybird/parser/SearchPathExtractorTest.java b/src/test/org/firebirdsql/jaybird/parser/SearchPathExtractorTest.java new file mode 100644 index 000000000..ef34da4a7 --- /dev/null +++ b/src/test/org/firebirdsql/jaybird/parser/SearchPathExtractorTest.java @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.parser; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SearchPathExtractorTest { + + private final SearchPathExtractor extractor = new SearchPathExtractor(); + + @Test + void initialSearchPathListIsEmpty() { + assertThat(extractor.getSearchPathList(), is(empty())); + } + + @ParameterizedTest + @MethodSource("extractionTestCases") + void testSearchPathListExtraction(String searchPath, List expectedSearchPathList) { + SqlParser.withReservedWords(FirebirdReservedWords.latest()) + .withVisitor(extractor) + .of(searchPath) + .parse(); + assertEquals(expectedSearchPathList, extractor.getSearchPathList()); + } + + static Stream extractionTestCases() { + return Stream.of( + Arguments.of("", List.of()), + Arguments.of("\"PUBLIC\"", List.of("PUBLIC")), + Arguments.of("\"PUBLIC\",\"SYSTEM\"", List.of("PUBLIC", "SYSTEM")), + Arguments.of("UNQUOTED_SCHEMA,\"QUOTED_SCHEMA\"", List.of("UNQUOTED_SCHEMA", "QUOTED_SCHEMA")), + Arguments.of("INVALID,,TWO_SEPARATORS", List.of()), + Arguments.of("INVALID,ENDS_IN_SEPARATOR,", List.of())); + } + +} \ No newline at end of file diff --git a/src/test/org/firebirdsql/jaybird/parser/SqlTokenizerTest.java b/src/test/org/firebirdsql/jaybird/parser/SqlTokenizerTest.java index f74637cbe..4607c36d3 100644 --- a/src/test/org/firebirdsql/jaybird/parser/SqlTokenizerTest.java +++ b/src/test/org/firebirdsql/jaybird/parser/SqlTokenizerTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2021-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2021-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.parser; @@ -223,6 +223,37 @@ void simpleSelectStatement() { ); } + @Test + void simpleColumnList() { + String statementText = "select a,b, c, d , e from some_table"; + + var tokenizer = SqlTokenizer.withReservedWords(FirebirdReservedWords.latest()) + .of(statementText); + + assertThat(tokenizer).toIterable().containsExactly( + new ReservedToken(0, "select"), + new WhitespaceToken(6, " "), + new GenericToken(7, "a"), + new CommaToken(8), + new GenericToken(9, "b"), + new CommaToken(10), + new WhitespaceToken(11, " "), + new GenericToken(12, "c"), + new CommaToken(13), + new WhitespaceToken(14, " "), + new GenericToken(15, "d"), + new WhitespaceToken(16, " "), + new CommaToken(17), + new WhitespaceToken(18, " "), + new GenericToken(19, "e"), + new WhitespaceToken(20, " "), + new ReservedToken(21, "from"), + new WhitespaceToken(25, " "), + new GenericToken(26, "some_table") + ); + + } + private static void expectSingleToken(String input, Token expectedToken) { SqlTokenizer tokenizer = SqlTokenizer.withReservedWords(FirebirdReservedWords.latest()).of(input); diff --git a/src/test/org/firebirdsql/jaybird/util/SearchPathHelperTest.java b/src/test/org/firebirdsql/jaybird/util/SearchPathHelperTest.java new file mode 100644 index 000000000..7cf894934 --- /dev/null +++ b/src/test/org/firebirdsql/jaybird/util/SearchPathHelperTest.java @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.jdbc.QuoteStrategy; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SearchPathHelperTest { + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " ", " "}) + void parseSearchPath_emptyList_forNullOrEmptyOrBlank(String searchPath) { + assertThat(SearchPathHelper.parseSearchPath(searchPath), is(empty())); + } + + @ParameterizedTest + @MethodSource("searchPathCases") + void parseSearchPath(String inputSearchPath, List expectedSearchPathList, QuoteStrategy ignored1, + String ignored2) { + assertEquals(expectedSearchPathList, SearchPathHelper.parseSearchPath(inputSearchPath)); + } + + @ParameterizedTest + @MethodSource("searchPathCases") + void toSearchPath(String ignored, List inputSearchPathList, QuoteStrategy quoteStrategy, + String expectedSearchPath) { + assertEquals(expectedSearchPath, SearchPathHelper.toSearchPath(inputSearchPathList, quoteStrategy)); + } + + static Stream searchPathCases() { + return Stream.of( + Arguments.of("", List.of(), QuoteStrategy.DIALECT_3, ""), + Arguments.of("", List.of(), QuoteStrategy.DIALECT_1, ""), + Arguments.of("PUBLIC, SYSTEM", List.of("PUBLIC", "SYSTEM"), QuoteStrategy.DIALECT_3, + "\"PUBLIC\", \"SYSTEM\""), + Arguments.of("PUBLIC, SYSTEM", List.of("PUBLIC", "SYSTEM"), QuoteStrategy.DIALECT_1, "PUBLIC, SYSTEM"), + Arguments.of("\"PUBLIC\", \"SYSTEM\"", List.of("PUBLIC", "SYSTEM"), QuoteStrategy.DIALECT_3, + "\"PUBLIC\", \"SYSTEM\""), + Arguments.of("\"PUBLIC\", \"SYSTEM\"", List.of("PUBLIC", "SYSTEM"), QuoteStrategy.DIALECT_1, + "PUBLIC, SYSTEM") + ); + } + +} \ No newline at end of file diff --git a/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java b/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java new file mode 100644 index 000000000..2354f7181 --- /dev/null +++ b/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jdbc; + +import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.common.extension.UsesDatabaseExtension.UsesDatabaseForAll; +import org.firebirdsql.jaybird.props.PropertyNames; +import org.firebirdsql.jaybird.util.SearchPathHelper; +import org.firebirdsql.util.FirebirdSupportInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.sql.Connection; +import java.sql.ResultSetMetaData; +import java.sql.SQLDataException; +import java.sql.SQLException; +import java.util.List; + +import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeFeatureMissing; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link FBConnection#setSchema(String)} and {@link FBConnection#getSchema()}, and other schema-specific + * tests of {@link FBConnection}. + */ +class FBConnectionSchemaTest { + + @RegisterExtension + static UsesDatabaseForAll usesDatabaseForAll = UsesDatabaseExtension.usesDatabaseForAll(dbInitStatements()); + + private static List dbInitStatements() { + if (getDefaultSupportInfo().supportsSchemas()) { + return List.of( + "create schema SCHEMA_1", + "create schema \"case_sensitive\"", + "create table PUBLIC.TABLE_ONE (IN_PUBLIC integer)", + "create table SCHEMA_1.TABLE_ONE (IN_SCHEMA_1 integer)", + "create table \"case_sensitive\".TABLE_ONE (\"IN_case_sensitive\" integer)"); + } + return List.of( + "create table TABLE_ONE (IN_ integer)"); + } + + @Test + void getSchema_noSupport_returnsNull() throws Exception { + assumeNoSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + assertNull(connection.getSchema(), "schema"); + checkSchemaResolution(connection, ""); + assertNull(connection.getSearchPath(), "searchPath"); + assertThat("searchPathList", connection.getSearchPathList(), is(empty())); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " ", "PUBLIC", "SYSTEM" }) + void setSchema_noSupport_ignoresValue(String schemaName) throws Exception { + assumeNoSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + assertDoesNotThrow(() -> connection.setSchema(schemaName)); + assertNull(connection.getSchema(), "schema always null"); + checkSchemaResolution(connection, ""); + assertNull(connection.getSearchPath(), "searchPath"); + assertThat("searchPathList", connection.getSearchPathList(), is(empty())); + } + } + + @Test + void getSchema_default_PUBLIC() throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + assertEquals("PUBLIC", connection.getSchema(), "schema"); + checkSchemaResolution(connection, "PUBLIC"); + assertEquals(List.of("PUBLIC", "SYSTEM"), connection.getSearchPathList(), "searchPathList"); + assertEquals("\"PUBLIC\", \"SYSTEM\"", connection.getSearchPath(), "searchPath"); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void setSchema_nullOrBlank_notAccepted(String schemaName) throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + var exception = assertThrows(SQLDataException.class, () -> connection.setSchema(schemaName)); + assertEquals("schema must be non-null and not blank", exception.getMessage(), "message"); + assertEquals(SQLStateConstants.SQL_STATE_INVALID_USE_NULL, exception.getSQLState(), "SQLState"); + } + } + + @ParameterizedTest + @ValueSource(strings = { "PUBLIC", "SCHEMA_1", "case_sensitive" }) + void setSchema_exists(String schemaName) throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + connection.setSchema(schemaName); + assertEquals(schemaName, connection.getSchema(), "schema after schema change"); + checkSchemaResolution(connection, schemaName); + // We leave the search path unmodified if the schema is already the first + final var expectedSearchPathList = "PUBLIC".equals(schemaName) + ? List.of("PUBLIC", "SYSTEM") + : List.of(schemaName, "PUBLIC", "SYSTEM"); + assertEquals(expectedSearchPathList, connection.getSearchPathList(), "searchPathList"); + final String expectedSearchPath = + SearchPathHelper.toSearchPath(expectedSearchPathList, QuoteStrategy.DIALECT_3); + assertEquals(expectedSearchPath, connection.getSearchPath(), "searchPath"); + } + } + + @Test + void setSchema_SYSTEM_first() throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + connection.setSchema("SYSTEM"); + assertEquals("SYSTEM", connection.getSchema(), "schema after schema change"); + // We're prepending the schema, leaving schemas later in the list untouched + final var expectedSearchPathList = List.of("SYSTEM", "PUBLIC", "SYSTEM"); + assertEquals(expectedSearchPathList, connection.getSearchPathList(), "searchPathList"); + final String expectedSearchPath = "\"SYSTEM\", \"PUBLIC\", \"SYSTEM\""; + assertEquals(expectedSearchPath, connection.getSearchPath(), "searchPath"); + } + } + + @Test + void setSchema_doesNotExist() throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + assertDoesNotThrow(() -> connection.setSchema("DOES_NOT_EXIST")); + assertEquals("PUBLIC", connection.getSchema(), + "current schema not changed after setting non-existent schema"); + // non-existent schema is included in the search path + final var expectedSearchPathList = List.of("DOES_NOT_EXIST", "PUBLIC", "SYSTEM"); + assertEquals(expectedSearchPathList, connection.getSearchPathList(), "searchPathList"); + final String expectedSearchPath = "\"DOES_NOT_EXIST\", \"PUBLIC\", \"SYSTEM\""; + assertEquals(expectedSearchPath, connection.getSearchPath(), "searchPath"); + } + } + + @Test + void setSchema_sequenceOfInvocations() throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + connection.setSchema("SCHEMA_1"); + assertEquals("SCHEMA_1", connection.getSchema(), "schema after SCHEMA_1"); + assertEquals(List.of("SCHEMA_1", "PUBLIC", "SYSTEM"), connection.getSearchPathList(), + "searchPathList after SCHEMA_1"); + checkSchemaResolution(connection, "SCHEMA_1"); + + connection.setSchema("DOES_NOT_EXIST"); + assertEquals("PUBLIC", connection.getSchema(), "schema after DOES_NOT_EXIST"); + assertEquals(List.of("DOES_NOT_EXIST", "PUBLIC", "SYSTEM"), connection.getSearchPathList(), + "searchPathList after DOES_NOT_EXIST"); + checkSchemaResolution(connection, "PUBLIC"); + + connection.setSchema("case_sensitive"); + assertEquals("case_sensitive", connection.getSchema(), "schema after case_sensitive"); + assertEquals(List.of("case_sensitive", "PUBLIC", "SYSTEM"), connection.getSearchPathList(), + "searchPathList after case_sensitive"); + checkSchemaResolution(connection, "case_sensitive"); + + connection.setSchema("PUBLIC"); + assertEquals("PUBLIC", connection.getSchema(), "schema after PUBLIC"); + assertEquals(List.of("PUBLIC", "PUBLIC", "SYSTEM"), connection.getSearchPathList(), + "searchPathList after PUBLIC"); + checkSchemaResolution(connection, "PUBLIC"); + } + } + + // There is some overlap with the searchPath tests in ConnectionPropertiesTest + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + searchPath, expectedSchema, expectedSearchPath + PUBLIC, PUBLIC, '"PUBLIC", "SYSTEM"' + 'PUBLIC, SYSTEM', PUBLIC, '"PUBLIC", "SYSTEM"' + public, PUBLIC, '"PUBLIC", "SYSTEM"' + "public", SYSTEM, '"public", "SYSTEM"' + SCHEMA_1, SCHEMA_1, '"SCHEMA_1", "SYSTEM"' + "case_sensitive", case_sensitive, '"case_sensitive", "SYSTEM"' + # NOTE Unquoted! + case_sensitive, SYSTEM, '"CASE_SENSITIVE", "SYSTEM"' + 'SCHEMA_1, "case_sensitive", SYSTEM, PUBLIC', SCHEMA_1, '"SCHEMA_1", "case_sensitive", "SYSTEM", "PUBLIC"' + """) + void connectionSearchPath(String searchPath, String expectedSchema, String expectedSearchPath) throws SQLException { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager(PropertyNames.searchPath, searchPath)) { + assertEquals(expectedSchema, connection.getSchema(), "schema"); + assertEquals(expectedSearchPath, connection.getSearchPath(), "searchPath"); + assertEquals(SearchPathHelper.parseSearchPath(expectedSearchPath), connection.getSearchPathList(), + "searchPathList"); + } + } + + private static void checkSchemaResolution(Connection connection, String expectedSchema) throws SQLException { + try (var pstmt = connection.prepareStatement("select * from TABLE_ONE")) { + ResultSetMetaData rsmd = pstmt.getMetaData(); + assertEquals(expectedSchema, rsmd.getSchemaName(1), "column schemaName"); + assertEquals("TABLE_ONE", rsmd.getTableName(1), "column tableName"); + assertEquals("IN_" + expectedSchema, rsmd.getColumnName(1), "column name"); + } + } + + private static void assumeSchemaSupport() { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test expects schema support"); + } + + private static void assumeNoSchemaSupport() { + assumeFeatureMissing(FirebirdSupportInfo::supportsSchemas, "Test expects no schema support"); + } +} From 6a7b308f62fb56edf8f8af839d356c1a4ba6f0be Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sat, 12 Jul 2025 12:16:23 +0200 Subject: [PATCH 32/64] #882 Additional schema tests for FBResultSetMetaData --- .../FBResultSetMetaDataParametrizedTest.java | 97 +++++++++++-------- .../jdbc/FBResultSetMetaDataTest.java | 30 +++++- 2 files changed, 84 insertions(+), 43 deletions(-) diff --git a/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataParametrizedTest.java b/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataParametrizedTest.java index 26cb7cc6c..ae65179d3 100644 --- a/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataParametrizedTest.java +++ b/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataParametrizedTest.java @@ -38,11 +38,10 @@ * * @author Mark Rotteveel * @since 3.0 + * @see FBResultSetMetaDataTest */ class FBResultSetMetaDataParametrizedTest { - // TODO Add schema support: tests involving other schema - private static final String TABLE_NAME = "TEST_P_METADATA"; private static final String CREATE_TABLE = """ CREATE TABLE test_p_metadata ( @@ -85,7 +84,16 @@ bigint_decimal DECIMAL(18,9), /* extended numerics */ /* time zone */ /* int128 */ - FROM test_p_metadata"""; + , column_from_secondary as secondary_aliased + FROM test_p_metadata cross join SECONDARY_TABLE"""; + + private static final String OTHER_SCHEMA = "OTHER_SCHEMA"; + private static final String SECONDARY_TABLE_NAME = "SECONDARY_TABLE"; + + private static final String CREATE_SECONDARY_TABLE = """ + create table SECONDARY_TABLE ( + column_from_secondary integer + )"""; @RegisterExtension static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll(); @@ -101,6 +109,7 @@ static void setupAll() throws Exception { supportInfo = supportInfoFor(connection); String createTable = CREATE_TABLE; + String createSecondaryTable = CREATE_SECONDARY_TABLE; String testQuery = TEST_QUERY; if (!supportInfo.supportsBigint()) { // No BIGINT support, replacing type so number of columns remain the same @@ -129,8 +138,19 @@ static void setupAll() throws Exception { createTable =createTable.replace("/* int128 */", ", col_int128 INT128"); testQuery = testQuery.replace("/* int128 */", ", col_int128"); } + if (supportInfo.supportsSchemas()) { + createSecondaryTable = createSecondaryTable.replace( + SECONDARY_TABLE_NAME, OTHER_SCHEMA + "." + SECONDARY_TABLE_NAME); + testQuery = testQuery.replace(SECONDARY_TABLE_NAME, OTHER_SCHEMA + "." + SECONDARY_TABLE_NAME); + } + connection.setAutoCommit(false); DdlHelper.executeCreateTable(connection, createTable); + if (supportInfo.supportsSchemas()) { + DdlHelper.executeDDL(connection, "create schema " + OTHER_SCHEMA); + } + DdlHelper.executeCreateTable(connection, createSecondaryTable); + connection.setAutoCommit(true); pstmt = connection.prepareStatement(testQuery); rsmd = pstmt.getMetaData(); @@ -150,47 +170,49 @@ static void tearDownAll() throws Exception { static Stream testData() { final boolean supportsFloatBinaryPrecision = getDefaultSupportInfo().supportsFloatBinaryPrecision(); + final String defaultSchema = ifSchemaElse("PUBLIC", ""); List testData = new ArrayList<>(Arrays.asList( - create(1, "java.lang.String", 60, "SIMPLE_FIELD", "SIMPLE_FIELD", VARCHAR, "VARCHAR", 60, 0, TABLE_NAME, columnNullable, true, false), - create(2, "java.lang.String", 60, "TWO_BYTE_FIELD", "TWO_BYTE_FIELD", VARCHAR, "VARCHAR", 60, 0, TABLE_NAME, columnNullable, true, false), - create(3, "java.lang.String", 60, "THREE_BYTE_FIELD", "THREE_BYTE_FIELD", VARCHAR, "VARCHAR", 60, 0, TABLE_NAME, columnNullable, true, false), - create(4, "java.lang.Long", 20, "LONG_FIELD", "LONG_FIELD", BIGINT, "BIGINT", 19, 0, TABLE_NAME, columnNullable, true, true), - create(5, "java.lang.Integer", 11, "INT_FIELD", "INT_FIELD", INTEGER, "INTEGER", 10, 0, TABLE_NAME, columnNullable, true, true), - create(6, "java.lang.Integer", 6, "SHORT_FIELD", "SHORT_FIELD", SMALLINT, "SMALLINT", 5, 0, TABLE_NAME, columnNullable, true, true), - create(7, "java.lang.Double", 13, "FLOAT_FIELD", "FLOAT_FIELD", FLOAT, "FLOAT", supportsFloatBinaryPrecision ? 24 : 7, 0, TABLE_NAME, columnNullable, true, true), - create(8, "java.lang.Double", 22, "DOUBLE_FIELD", "DOUBLE_FIELD", DOUBLE, "DOUBLE PRECISION", supportsFloatBinaryPrecision ? 53 : 15, 0, TABLE_NAME, columnNullable, true, true), - create(9, "java.math.BigDecimal", 5, "SMALLINT_NUMERIC", "SMALLINT_NUMERIC", NUMERIC, "NUMERIC", 3, 1, TABLE_NAME, columnNullable, true, true), - create(10, "java.math.BigDecimal", 5, "INTEGER_DECIMAL_1", "INTEGER_DECIMAL_1", DECIMAL, "DECIMAL", 3, 1, TABLE_NAME, columnNullable, true, true), - create(11, "java.math.BigDecimal", 7, "INTEGER_NUMERIC", "INTEGER_NUMERIC", NUMERIC, "NUMERIC", 5, 2, TABLE_NAME, columnNullable, true, true), - create(12, "java.math.BigDecimal", 11, "INTEGER_DECIMAL_2", "INTEGER_DECIMAL_2", DECIMAL, "DECIMAL", 9, 3, TABLE_NAME, columnNullable, true, true), - create(13, "java.math.BigDecimal", 12, "BIGINT_NUMERIC", "BIGINT_NUMERIC", NUMERIC, "NUMERIC", 10, 4, TABLE_NAME, columnNullable, true, true), - create(14, "java.math.BigDecimal", 20, "BIGINT_DECIMAL", "BIGINT_DECIMAL", DECIMAL, "DECIMAL", 18, 9, TABLE_NAME, columnNullable, true, true), - create(15, "java.sql.Date", 10, "DATE_FIELD", "DATE_FIELD", DATE, "DATE", 10, 0, TABLE_NAME, columnNullable, true, false), - create(16, "java.sql.Time", 8, "TIME_FIELD", "TIME_FIELD", TIME, "TIME", 8, 0, TABLE_NAME, columnNullable, true, false), - create(17, "java.sql.Timestamp", 19, "TIMESTAMP_FIELD", "TIMESTAMP_FIELD", TIMESTAMP, "TIMESTAMP", 19, 0, TABLE_NAME, columnNullable, true, false), - create(18, "[B", 0, "BLOB_FIELD", "BLOB_FIELD", LONGVARBINARY, "BLOB SUB_TYPE BINARY", 0, 0, TABLE_NAME, columnNullable, false, false), - create(19, "java.lang.String", 0, "BLOB_TEXT_FIELD", "BLOB_TEXT_FIELD", LONGVARCHAR, "BLOB SUB_TYPE TEXT", 0, 0, TABLE_NAME, columnNullable, false, false), - create(20, "java.sql.Blob", 0, "BLOB_MINUS_ONE", "BLOB_MINUS_ONE", BLOB, "BLOB SUB_TYPE -1", 0, 0, TABLE_NAME, columnNullable, false, false) + create(1, "java.lang.String", 60, "SIMPLE_FIELD", "SIMPLE_FIELD", VARCHAR, "VARCHAR", 60, 0, defaultSchema, TABLE_NAME, columnNullable, true, false), + create(2, "java.lang.String", 60, "TWO_BYTE_FIELD", "TWO_BYTE_FIELD", VARCHAR, "VARCHAR", 60, 0, defaultSchema, TABLE_NAME, columnNullable, true, false), + create(3, "java.lang.String", 60, "THREE_BYTE_FIELD", "THREE_BYTE_FIELD", VARCHAR, "VARCHAR", 60, 0, defaultSchema, TABLE_NAME, columnNullable, true, false), + create(4, "java.lang.Long", 20, "LONG_FIELD", "LONG_FIELD", BIGINT, "BIGINT", 19, 0, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(5, "java.lang.Integer", 11, "INT_FIELD", "INT_FIELD", INTEGER, "INTEGER", 10, 0, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(6, "java.lang.Integer", 6, "SHORT_FIELD", "SHORT_FIELD", SMALLINT, "SMALLINT", 5, 0, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(7, "java.lang.Double", 13, "FLOAT_FIELD", "FLOAT_FIELD", FLOAT, "FLOAT", supportsFloatBinaryPrecision ? 24 : 7, 0, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(8, "java.lang.Double", 22, "DOUBLE_FIELD", "DOUBLE_FIELD", DOUBLE, "DOUBLE PRECISION", supportsFloatBinaryPrecision ? 53 : 15, 0, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(9, "java.math.BigDecimal", 5, "SMALLINT_NUMERIC", "SMALLINT_NUMERIC", NUMERIC, "NUMERIC", 3, 1, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(10, "java.math.BigDecimal", 5, "INTEGER_DECIMAL_1", "INTEGER_DECIMAL_1", DECIMAL, "DECIMAL", 3, 1, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(11, "java.math.BigDecimal", 7, "INTEGER_NUMERIC", "INTEGER_NUMERIC", NUMERIC, "NUMERIC", 5, 2, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(12, "java.math.BigDecimal", 11, "INTEGER_DECIMAL_2", "INTEGER_DECIMAL_2", DECIMAL, "DECIMAL", 9, 3, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(13, "java.math.BigDecimal", 12, "BIGINT_NUMERIC", "BIGINT_NUMERIC", NUMERIC, "NUMERIC", 10, 4, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(14, "java.math.BigDecimal", 20, "BIGINT_DECIMAL", "BIGINT_DECIMAL", DECIMAL, "DECIMAL", 18, 9, defaultSchema, TABLE_NAME, columnNullable, true, true), + create(15, "java.sql.Date", 10, "DATE_FIELD", "DATE_FIELD", DATE, "DATE", 10, 0, defaultSchema, TABLE_NAME, columnNullable, true, false), + create(16, "java.sql.Time", 8, "TIME_FIELD", "TIME_FIELD", TIME, "TIME", 8, 0, defaultSchema, TABLE_NAME, columnNullable, true, false), + create(17, "java.sql.Timestamp", 19, "TIMESTAMP_FIELD", "TIMESTAMP_FIELD", TIMESTAMP, "TIMESTAMP", 19, 0, defaultSchema, TABLE_NAME, columnNullable, true, false), + create(18, "[B", 0, "BLOB_FIELD", "BLOB_FIELD", LONGVARBINARY, "BLOB SUB_TYPE BINARY", 0, 0, defaultSchema, TABLE_NAME, columnNullable, false, false), + create(19, "java.lang.String", 0, "BLOB_TEXT_FIELD", "BLOB_TEXT_FIELD", LONGVARCHAR, "BLOB SUB_TYPE TEXT", 0, 0, defaultSchema, TABLE_NAME, columnNullable, false, false), + create(20, "java.sql.Blob", 0, "BLOB_MINUS_ONE", "BLOB_MINUS_ONE", BLOB, "BLOB SUB_TYPE -1", 0, 0, defaultSchema, TABLE_NAME, columnNullable, false, false) )); final FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); if (supportInfo.supportsBoolean()) { - testData.add(create(testData.size() + 1, "java.lang.Boolean", 5, "BOOLEAN_FIELD", "BOOLEAN_FIELD", BOOLEAN, "BOOLEAN", 1, 0, TABLE_NAME, columnNullable, true, false)); + testData.add(create(testData.size() + 1, "java.lang.Boolean", 5, "BOOLEAN_FIELD", "BOOLEAN_FIELD", BOOLEAN, "BOOLEAN", 1, 0, defaultSchema, TABLE_NAME, columnNullable, true, false)); } if (supportInfo.supportsDecfloat()) { - testData.add(create(testData.size() + 1, "java.math.BigDecimal", 23, "DECFLOAT16_FIELD", "DECFLOAT16_FIELD", JaybirdTypeCodes.DECFLOAT, "DECFLOAT", 16, 0, TABLE_NAME, columnNullable, true, true)); - testData.add(create(testData.size() + 1, "java.math.BigDecimal", 42, "DECFLOAT34_FIELD", "DECFLOAT34_FIELD", JaybirdTypeCodes.DECFLOAT, "DECFLOAT", 34, 0, TABLE_NAME, columnNullable, true, true)); + testData.add(create(testData.size() + 1, "java.math.BigDecimal", 23, "DECFLOAT16_FIELD", "DECFLOAT16_FIELD", JaybirdTypeCodes.DECFLOAT, "DECFLOAT", 16, 0, defaultSchema, TABLE_NAME, columnNullable, true, true)); + testData.add(create(testData.size() + 1, "java.math.BigDecimal", 42, "DECFLOAT34_FIELD", "DECFLOAT34_FIELD", JaybirdTypeCodes.DECFLOAT, "DECFLOAT", 34, 0, defaultSchema, TABLE_NAME, columnNullable, true, true)); } if (supportInfo.supportsDecimalPrecision(38)) { - testData.add(create(testData.size() + 1, "java.math.BigDecimal", 27, "COL_NUMERIC25_20", "COL_NUMERIC25_20", NUMERIC, "NUMERIC", 25, 20, TABLE_NAME, columnNullable, true, true)); - testData.add(create(testData.size() + 1, "java.math.BigDecimal", 32, "COL_DECIMAL30_5", "COL_DECIMAL30_5", DECIMAL, "DECIMAL", 30, 5, TABLE_NAME, columnNullable, true, true)); + testData.add(create(testData.size() + 1, "java.math.BigDecimal", 27, "COL_NUMERIC25_20", "COL_NUMERIC25_20", NUMERIC, "NUMERIC", 25, 20, defaultSchema, TABLE_NAME, columnNullable, true, true)); + testData.add(create(testData.size() + 1, "java.math.BigDecimal", 32, "COL_DECIMAL30_5", "COL_DECIMAL30_5", DECIMAL, "DECIMAL", 30, 5, defaultSchema, TABLE_NAME, columnNullable, true, true)); } if (supportInfo.supportsTimeZones()) { - testData.add(create(testData.size() + 1, "java.time.OffsetTime", 19, "COL_TIMETZ", "COL_TIMETZ", TIME_WITH_TIMEZONE, "TIME WITH TIME ZONE", 19, 0, TABLE_NAME, columnNullable, true, false)); - testData.add(create(testData.size() + 1, "java.time.OffsetDateTime", 30, "COL_TIMESTAMPTZ", "COL_TIMESTAMPTZ", TIMESTAMP_WITH_TIMEZONE, "TIMESTAMP WITH TIME ZONE", 30, 0, TABLE_NAME, columnNullable, true, false)); + testData.add(create(testData.size() + 1, "java.time.OffsetTime", 19, "COL_TIMETZ", "COL_TIMETZ", TIME_WITH_TIMEZONE, "TIME WITH TIME ZONE", 19, 0, defaultSchema, TABLE_NAME, columnNullable, true, false)); + testData.add(create(testData.size() + 1, "java.time.OffsetDateTime", 30, "COL_TIMESTAMPTZ", "COL_TIMESTAMPTZ", TIMESTAMP_WITH_TIMEZONE, "TIMESTAMP WITH TIME ZONE", 30, 0, defaultSchema, TABLE_NAME, columnNullable, true, false)); } if (supportInfo.supportsInt128()) { - testData.add(create(testData.size() + 1, "java.math.BigDecimal", 40, "COL_INT128", "COL_INT128", NUMERIC, "INT128", 38, 0, TABLE_NAME, columnNullable, true, true)); + testData.add(create(testData.size() + 1, "java.math.BigDecimal", 40, "COL_INT128", "COL_INT128", NUMERIC, "INT128", 38, 0, defaultSchema, TABLE_NAME, columnNullable, true, true)); } + testData.add(create(testData.size() + 1, "java.lang.Integer", 11, "SECONDARY_ALIASED", "COLUMN_FROM_SECONDARY", INTEGER, "INTEGER", 10, 0, ifSchemaElse(OTHER_SCHEMA, ""), SECONDARY_TABLE_NAME, columnNullable, true, true)); return testData.stream(); } @@ -258,8 +280,8 @@ void testGetScale(Integer columnIndex, ResultSetMetaDataInfo expectedMetaData, S @ParameterizedTest(name = "Index {0} ({2})") @MethodSource("testData") - void testGetSchemaName(Integer columnIndex, ResultSetMetaDataInfo ignored1, String ignored2) throws Exception { - assertEquals(supportInfo.ifSchemaElse("PUBLIC", ""), rsmd.getSchemaName(columnIndex), "getSchemaName"); + void testGetSchemaName(Integer columnIndex, ResultSetMetaDataInfo expectedMetaData, String ignored2) throws Exception { + assertEquals(expectedMetaData.schemaName, rsmd.getSchemaName(columnIndex), "getSchemaName"); } @ParameterizedTest(name = "Index {0} ({2})") @@ -327,16 +349,15 @@ void testIsWritable(Integer columnIndex, ResultSetMetaDataInfo ignored1, String @SuppressWarnings("SameParameterValue") private static Arguments create(int index, String className, int displaySize, String label, String name, int type, - String typeName, int precision, int scale, String tableName, int nullable, boolean searchable, - boolean signed) { + String typeName, int precision, int scale, String schemaName, String tableName, int nullable, + boolean searchable, boolean signed) { return Arguments.of(index, new ResultSetMetaDataInfo(className, displaySize, label, name, type, typeName, precision, scale, - tableName, nullable, searchable, signed), - label); + schemaName, tableName, nullable, searchable, signed), label); } private record ResultSetMetaDataInfo( String className, int displaySize, String label, String name, int type, String typeName, int precision, - int scale, String tableName, int nullable, boolean searchable, boolean signed) { + int scale, String schemaName, String tableName, int nullable, boolean searchable, boolean signed) { } } diff --git a/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataTest.java b/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataTest.java index 4af6ac0f0..9702029b4 100644 --- a/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataTest.java +++ b/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataTest.java @@ -4,7 +4,7 @@ SPDX-FileCopyrightText: Copyright 2003 Ryan Baldwin SPDX-FileCopyrightText: Copyright 2003 Nikolay Samofatov SPDX-FileCopyrightText: Copyright 2005 Steven Jardine - SPDX-FileCopyrightText: Copyright 2012-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc; @@ -45,6 +45,7 @@ * * @author Roman Rokytskyy * @author Mark Rotteveel + * @see FBResultSetMetaDataParametrizedTest */ class FBResultSetMetaDataTest { @@ -324,16 +325,12 @@ void getTableAliasCTE() throws Exception { "select * from (select column1 from tablea b) a" }) { try (ResultSet rs = stmt.executeQuery(query)) { -// System.out.println(query); FirebirdResultSetMetaData rsmd = rs.getMetaData().unwrap(FirebirdResultSetMetaData.class); final String columnLabel = rsmd.getColumnLabel(1); final String tableAlias = rsmd.getTableAlias(1); -// System.out.println("'" + columnLabel + "'"); -// System.out.println("'" + tableAlias + "'"); assertEquals("COLUMN1", columnLabel, "columnLabel"); assertEquals("A", tableAlias, "tableAlias"); } -// System.out.println("---------"); } } } @@ -382,6 +379,7 @@ void getPrecision_connectionLessResultSet_shouldSucceedWithoutException_730() th .at(0).simple(SQL_FLOAT, 4, "TEST", "FLOAT").addField() .at(1).simple(SQL_DOUBLE, 8, "TEST", "DOUBLE").addField() .toRowDescriptor(); + //noinspection resource var rs = new FBResultSet(rowDescriptor, List.of()); ResultSetMetaData rsmd = rs.getMetaData(); @@ -487,4 +485,26 @@ void extendedInfoQueryDoesNotCloseResultSet() throws Exception { } } + @Test + void expressionInSelect() throws Exception { + try (var connection = getConnectionViaDriverManager(); + var stmt = connection.createStatement()) { + try (var rs = stmt.executeQuery("select id, long_field + 1 as EXPRESSION from test_rs_metadata")) { + ResultSetMetaData rsmd = rs.getMetaData(); + assertColumnMetadata(rsmd, 1, ifSchemaElse("PUBLIC", ""), "TEST_RS_METADATA", "ID", "ID", + Types.INTEGER); + assertColumnMetadata(rsmd, 2, "", "", "EXPRESSION", "ADD", Types.NUMERIC); + } + } + } + + static void assertColumnMetadata(ResultSetMetaData rsmd, int idx, String schema, String table, String label, + String name, int type) throws SQLException { + assertEquals(schema, rsmd.getSchemaName(idx), "schemaName"); + assertEquals(table, rsmd.getTableName(idx), "tableName"); + assertEquals(label, rsmd.getColumnLabel(idx), "columnLabel"); + assertEquals(name, rsmd.getColumnName(idx), "columnName"); + assertEquals(type, rsmd.getColumnType(idx), "columnType"); + } + } \ No newline at end of file From aa3d585c21e908f62c060acf5adb2305e34e0099 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 13 Jul 2025 13:26:36 +0200 Subject: [PATCH 33/64] #882 Modify FBRowUpdater to search for schema --- .../jaybird/util/QualifiedName.java | 122 ++++++++++++++++++ .../jdbc/AbstractFieldMetaData.java | 10 +- .../org/firebirdsql/jdbc/FBRowUpdater.java | 68 +++++----- .../jaybird/util/QualifiedNameTest.java | 103 +++++++++++++++ .../org/firebirdsql/jdbc/FBResultSetTest.java | 66 +++++++--- 5 files changed, 314 insertions(+), 55 deletions(-) create mode 100644 src/main/org/firebirdsql/jaybird/util/QualifiedName.java create mode 100644 src/test/org/firebirdsql/jaybird/util/QualifiedNameTest.java diff --git a/src/main/org/firebirdsql/jaybird/util/QualifiedName.java b/src/main/org/firebirdsql/jaybird/util/QualifiedName.java new file mode 100644 index 000000000..cd63cd119 --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/util/QualifiedName.java @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.gds.ng.fields.FieldDescriptor; +import org.firebirdsql.jdbc.QuoteStrategy; +import org.jspecify.annotations.Nullable; + +import java.util.Optional; + +import static java.util.Objects.requireNonNull; +import static java.util.Objects.requireNonNullElse; + +/** + * An object name qualified by a schema. + *

+ * NOTE: This class is not usable for packaged objects, as it has only one level of qualification. + *

+ * + * @param schema + * unquoted, case-sensitive schema name, {@code null} is returned as empty string ({@code ""}) + * @param object + * unquoted, case-sensitive object name (e.g. a table name) + * @since 7 + */ +public record QualifiedName(String schema, String object) { + + /** + * Canonical constructor. + * + * @param schema + * unquoted, case-sensitive schema name, {@code null} is returned as empty string ({@code ""}) + * @param object + * unquoted, case-sensitive object name (e.g. a table name) + * @throws IllegalArgumentException if {@code object} is blank + */ + public QualifiedName(@Nullable String schema, String object) { + // defined explicitly to annotate @Nullable on schema for constructor only + this.schema = requireNonNullElse(schema, ""); + if (object.isBlank()) { + throw new IllegalArgumentException("object cannot be blank"); + } + this.object = requireNonNull(object, "object"); + } + + @Override + public String schema() { + return schema; + } + + /** + * Estimated length of the quoted identifier. + *

+ * The estimate might be of if {@link #schema()} or {@link #object()} contains double quotes, or if + * {@link QuoteStrategy#DIALECT_1} is used. + *

+ *

+ * This can be used for pre-sizing a string builder for {@link #append(StringBuilder, QuoteStrategy)}. + *

+ * + * @return estimated length of the quoted identifier + */ + public int estimatedLength() { + // 2: double quotes, 1: separator + return (schema.isEmpty() ? 0 : 2 + schema.length() + 1) + 2 + object.length(); + } + + /** + * Produces the string of the identifier chain. + * + * @param quoteStrategy + * quote strategy to apply on {@code schema} and {@code object} + * @return identifier chain + */ + public String toString(QuoteStrategy quoteStrategy) { + if (!schema.isEmpty()) { + var sb = new StringBuilder(estimatedLength()); + quoteStrategy.appendQuoted(schema, sb).append('.'); + quoteStrategy.appendQuoted(object, sb); + return sb.toString(); + } + return quoteStrategy.quoteObjectName(object); + } + + /** + * Appends the identifier chain to {@code sb}, using {@code quoteStrategy}. + * + * @param sb + * StringBuilder for appending + * @param quoteStrategy + * quote strategy to apply on {@code schema} and {@code object} + * @return the StringBuilder for method chaining + * @see #estimatedLength() + */ + public StringBuilder append(StringBuilder sb, QuoteStrategy quoteStrategy) { + if (!schema.isEmpty()) { + quoteStrategy.appendQuoted(schema, sb).append('.'); + } + quoteStrategy.appendQuoted(object, sb); + return sb; + } + + /** + * Creates a qualified name from {@code originalSchema} and {@code originalTableName} of + * {@code fieldDescriptor}. + * + * @param fieldDescriptor + * field descriptor + * @return a schema-qualified name of the original table from {@code fieldDescriptor} or empty if its + * {@code originalTableName} is empty string or {@code null} + */ + public static Optional of(FieldDescriptor fieldDescriptor) { + String tableName = fieldDescriptor.getOriginalTableName(); + if (StringUtils.isNullOrEmpty(tableName)) { + return Optional.empty(); + } + // NOTE: This will produce an exception if tableName is blank and not empty, we accept that as that shouldn't + // happen in normal use + return Optional.of(new QualifiedName(fieldDescriptor.getOriginalSchema(), tableName)); + } + +} diff --git a/src/main/org/firebirdsql/jdbc/AbstractFieldMetaData.java b/src/main/org/firebirdsql/jdbc/AbstractFieldMetaData.java index 869be7781..14661dd26 100644 --- a/src/main/org/firebirdsql/jdbc/AbstractFieldMetaData.java +++ b/src/main/org/firebirdsql/jdbc/AbstractFieldMetaData.java @@ -274,13 +274,21 @@ public ExtendedFieldInfo(String schema, String relationName, String fieldName, i /** * A composite key for internal field mapping structures. * - * @param schema schema ({@code null} if schemaless, i.e. Firebird 5.0 and older) + * @param schema + * schema ({@code ""} if schemaless, i.e. Firebird 5.0 and older; {@code null} is converted to empty string) * @param relationName * relation name * @param fieldName * field name */ protected record FieldKey(String schema, String relationName, String fieldName) { + + protected FieldKey { + if (schema == null) { + schema = ""; + } + } + public FieldKey(FieldDescriptor fieldDescriptor) { this(fieldDescriptor.getOriginalSchema(), fieldDescriptor.getOriginalTableName(), fieldDescriptor.getOriginalName()); diff --git a/src/main/org/firebirdsql/jdbc/FBRowUpdater.java b/src/main/org/firebirdsql/jdbc/FBRowUpdater.java index 5a55715c8..bbe8923c4 100644 --- a/src/main/org/firebirdsql/jdbc/FBRowUpdater.java +++ b/src/main/org/firebirdsql/jdbc/FBRowUpdater.java @@ -11,6 +11,7 @@ import org.firebirdsql.gds.ng.fields.RowValue; import org.firebirdsql.gds.ng.listeners.StatementListener; import org.firebirdsql.jaybird.util.SQLExceptionChainBuilder; +import org.firebirdsql.jaybird.util.QualifiedName; import org.firebirdsql.jaybird.util.UncheckedSQLException; import org.firebirdsql.jdbc.field.FBField; import org.firebirdsql.jdbc.field.FBFlushableField; @@ -21,10 +22,8 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.stream.StreamSupport; -import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; import static org.firebirdsql.jdbc.SQLStateConstants.SQL_STATE_INVALID_CURSOR_STATE; /** @@ -69,7 +68,7 @@ final class FBRowUpdater implements FirebirdRowUpdater { private static final byte[][] EMPTY_2D_BYTES = new byte[0][]; - private final String tableName; + private final QualifiedName tableName; private final FBObjectListener.ResultSetListener rsListener; private final GDSHelper gdsHelper; private final RowDescriptor rowDescriptor; @@ -87,12 +86,12 @@ final class FBRowUpdater implements FirebirdRowUpdater { FBRowUpdater(FBConnection connection, RowDescriptor rowDescriptor, boolean cached, FBObjectListener.ResultSetListener rsListener) throws SQLException { - tableName = requireSingleTableName(rowDescriptor); + quoteStrategy = connection.getQuoteStrategy(); + tableName = requireSingleTable(rowDescriptor, quoteStrategy); keyColumns = deriveKeyColumns(tableName, rowDescriptor, connection.getMetaData()); this.rsListener = rsListener; gdsHelper = connection.getGDSHelper(); - quoteStrategy = connection.getQuoteStrategy(); fields = createFields(rowDescriptor, cached); newRow = rowDescriptor.createDefaultFieldValues(); @@ -119,31 +118,35 @@ private FBField createFieldUnchecked(FieldDescriptor fieldDescriptor, boolean ca } /** - * Returns the single table name referenced by {@code rowDescriptor}, or throws an exception if there are no or - * multiple table names. + * Returns the single table name (including schema if supported) referenced by {@code rowDescriptor}, or throws + * an exception if there are multiple tables, or derived columns (columns without a relation). * * @param rowDescriptor * row descriptor - * @return non-null table name + * @return non-null table identifier chain * @throws SQLException - * if {@code rowDescriptor} references multiple table names or no table names at all + * if {@code rowDescriptor} references multiple tables or has derived columns */ - private static String requireSingleTableName(RowDescriptor rowDescriptor) throws SQLException { - // find the table name (there can be only one table per updatable result set) - String tableName = null; + private static QualifiedName requireSingleTable(RowDescriptor rowDescriptor, QuoteStrategy quoteStrategy) + throws SQLException { + // find the tableName (there can be only one tableName per updatable result set) + QualifiedName tableName = null; for (FieldDescriptor fieldDescriptor : rowDescriptor) { - // TODO This will not detect derived columns in the prefix of the select list + var currentTable = QualifiedName.of(fieldDescriptor).orElse(null); + if (currentTable == null) { + // No table => derived column => not updatable + throw new FBResultSetNotUpdatableException( + "Underlying result set has derived columns (without a relation)"); + } if (tableName == null) { - tableName = fieldDescriptor.getOriginalTableName(); - } else if (!Objects.equals(tableName, fieldDescriptor.getOriginalTableName())) { + tableName = currentTable; + } else if (!tableName.equals(currentTable)) { + // Different table => not updatable throw new FBResultSetNotUpdatableException( "Underlying result set references at least two relations: %s and %s." - .formatted(tableName, fieldDescriptor.getOriginalTableName())); + .formatted(tableName.toString(quoteStrategy), currentTable.toString(quoteStrategy))); } } - if (isNullOrEmpty(tableName)) { - throw new FBResultSetNotUpdatableException("Underlying result set references no relations"); - } return tableName; } @@ -217,10 +220,10 @@ public FBField getField(int fieldPosition) { * @throws SQLException * for errors looking up the best row identifier */ - private static List deriveKeyColumns(String tableName, RowDescriptor rowDescriptor, + private static List deriveKeyColumns(QualifiedName table, RowDescriptor rowDescriptor, DatabaseMetaData dbmd) throws SQLException { // first try best row identifier - List keyColumns = keyColumnsOfBestRowIdentifier( tableName, rowDescriptor, dbmd); + List keyColumns = keyColumnsOfBestRowIdentifier(table, rowDescriptor, dbmd); if (keyColumns.isEmpty()) { // best row identifier not available or not fully matched, fallback to RDB$DB_KEY // NOTE: fallback is updatable, but may not be insertable (e.g. if missing PK column(s) are not generated)! @@ -248,11 +251,10 @@ private static List deriveKeyColumns(String tableName, RowDescr * @throws SQLException * for errors looking up the best row identifier */ - private static List keyColumnsOfBestRowIdentifier(String tableName, RowDescriptor rowDescriptor, - DatabaseMetaData dbmd) throws SQLException { - // TODO Add schema support - try (ResultSet bestRowIdentifier = dbmd - .getBestRowIdentifier("", null, tableName, DatabaseMetaData.bestRowTransaction, true)) { + private static List keyColumnsOfBestRowIdentifier(QualifiedName table, + RowDescriptor rowDescriptor, DatabaseMetaData dbmd) throws SQLException { + try (ResultSet bestRowIdentifier = dbmd.getBestRowIdentifier( + "", table.schema(), table.object(), DatabaseMetaData.bestRowTransaction, true)) { int bestRowIdentifierColumnCount = 0; List keyColumns = new ArrayList<>(); while (bestRowIdentifier.next()) { @@ -326,7 +328,7 @@ private String buildUpdateStatement() { // TODO raise exception if there are no updated columns, or do nothing? var sb = new StringBuilder(EST_STATEMENT_SIZE + newRow.initializedCount() * EST_COLUMN_SIZE) .append("update "); - quoteStrategy.appendQuoted(tableName, sb).append(" set "); + tableName.append(sb, quoteStrategy).append(" set "); boolean first = true; for (FieldDescriptor fieldDescriptor : rowDescriptor) { @@ -349,7 +351,7 @@ private String buildUpdateStatement() { private String buildDeleteStatement() { var sb = new StringBuilder(EST_STATEMENT_SIZE).append("delete from "); - quoteStrategy.appendQuoted(tableName, sb).append('\n'); + tableName.append(sb, quoteStrategy).append('\n'); appendWhereClause(sb); return sb.toString(); @@ -377,9 +379,9 @@ private String buildInsertStatement() { } // 27 = length of appended literals + 2 quote characters - var sb = new StringBuilder(27 + tableName.length() + columns.length() + params.length()).append("insert into "); - quoteStrategy.appendQuoted(tableName, sb) - .append(" (").append(columns).append(") values (").append(params).append(')'); + var sb = new StringBuilder(27 + tableName.estimatedLength() + columns.length() + params.length()) + .append("insert into "); + tableName.append(sb,quoteStrategy).append(" (").append(columns).append(") values (").append(params).append(')'); return sb.toString(); } @@ -403,9 +405,9 @@ private String buildSelectStatement() { } } - var sb = new StringBuilder(EST_STATEMENT_SIZE + columns.length()) + var sb = new StringBuilder(EST_STATEMENT_SIZE + columns.length() + tableName.estimatedLength()) .append("select ").append(columns).append("\nfrom "); - quoteStrategy.appendQuoted(tableName, sb).append('\n'); + tableName.append(sb, quoteStrategy).append('\n'); appendWhereClause(sb); return sb.toString(); } diff --git a/src/test/org/firebirdsql/jaybird/util/QualifiedNameTest.java b/src/test/org/firebirdsql/jaybird/util/QualifiedNameTest.java new file mode 100644 index 000000000..5b4e5630a --- /dev/null +++ b/src/test/org/firebirdsql/jaybird/util/QualifiedNameTest.java @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.encodings.EncodingFactory; +import org.firebirdsql.gds.ng.DatatypeCoder; +import org.firebirdsql.gds.ng.DefaultDatatypeCoder; +import org.firebirdsql.gds.ng.fields.FieldDescriptor; +import org.firebirdsql.gds.ng.fields.RowDescriptorBuilder; +import org.firebirdsql.jdbc.QuoteStrategy; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EmptySource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class QualifiedNameTest { + + private static final DatatypeCoder datatypeCoder = + DefaultDatatypeCoder.forEncodingFactory(EncodingFactory.createInstance(StandardCharsets.UTF_8)); + + @ParameterizedTest + @NullAndEmptySource + void nullOrEmptySchemaReportedAsEmpty(@Nullable String schema) { + var name = new QualifiedName(schema, "COLUMN"); + assertEquals("COLUMN", name.object()); + assertEquals("", name.schema()); + } + + @ParameterizedTest + @EmptySource + @ValueSource(strings = " ") + void blankObject_throwsException(String object) { + assertThrows(IllegalArgumentException.class, () -> new QualifiedName("SCHEMA_NAME", object)); + } + + @Test + @SuppressWarnings("DataFlowIssue") + void nullObject_throwsException() { + assertThrows(NullPointerException.class, () -> new QualifiedName("SCHEMA_NAME", null)); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schema, object, quoteStrategy, expectedLength, expectedIdentifierChain + , OBJECT, DIALECT_3, 8, "OBJECT" + '', OBJECT, DIALECT_1, 8, OBJECT + SCHEMA, OBJECT, DIALECT_3, 17, "SCHEMA"."OBJECT" + SCHEMA, OBJECT, DIALECT_1, 17, SCHEMA.OBJECT + # Weird case + SCHEM"QUOTE, OBJ"QUOTE, DIALECT_3, 25, "SCHEM""QUOTE"."OBJ""QUOTE" + """) + void identifierChainProduction(@Nullable String schema, String object, QuoteStrategy quoteStrategy, + int expectedLength, String expectedIdentifierChain) { + var name = new QualifiedName(schema, object); + assertAll( + () -> assertEquals(expectedLength, name.estimatedLength(), "estimatedLength"), + () -> assertEquals(expectedIdentifierChain, name.toString(quoteStrategy), "toString(QuoteStrategy)"), + () -> { + var sb = new StringBuilder(name.estimatedLength()); + name.append(sb, quoteStrategy); + assertEquals(expectedIdentifierChain, sb.toString(), "append(StringBuilder, QuoteStrategy)"); + } + ); + } + + @SuppressWarnings("OptionalGetWithoutIsPresent") + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schema, object, expectedEmpty + , TABLE, false + '', TABLE, false + SCHEMA, TABLE, false + SCHEMA, , true + SCHEMA, '', true + , , true + '', '', true + """) + void testOf(@Nullable String schema, String object, boolean expectedEmpty) { + FieldDescriptor fieldDescriptor = new RowDescriptorBuilder(1, datatypeCoder) + .setOriginalSchema(schema) + .setOriginalTableName(object) + .toFieldDescriptor(); + Optional optName = QualifiedName.of(fieldDescriptor); + assertEquals(expectedEmpty, optName.isEmpty(), "empty"); + if (!expectedEmpty) { + QualifiedName name = optName.get(); + String expectedSchema = schema == null || schema.isBlank() ? "" : schema; + assertEquals(expectedSchema, name.schema(), "schema"); + assertEquals(object, name.object(), "object"); + } + } + +} \ No newline at end of file diff --git a/src/test/org/firebirdsql/jdbc/FBResultSetTest.java b/src/test/org/firebirdsql/jdbc/FBResultSetTest.java index bf9dc174c..ad6960a4b 100644 --- a/src/test/org/firebirdsql/jdbc/FBResultSetTest.java +++ b/src/test/org/firebirdsql/jdbc/FBResultSetTest.java @@ -630,14 +630,20 @@ void testUpdatableStatementResultSetDowngradeToReadOnlyWhenQueryNotUpdatable( executeCreateTable(connection, CREATE_TABLE_STATEMENT); executeCreateTable(connection, CREATE_TABLE_STATEMENT2); - try (var stmt = connection.createStatement(TYPE_SCROLL_INSENSITIVE, CONCUR_UPDATABLE); - var rs = stmt.executeQuery("select * from test_table t1 left join test_table2 t2 on t1.id = t2.id")) { - assertThat(stmt.getWarnings(), allOf( - notNullValue(), - fbMessageStartsWith(JaybirdErrorCodes.jb_concurrencyResetReadOnlyReasonNotUpdatable))); + assertDowngradeToReadOnly(connection, + "select * from test_table t1 left join test_table2 t2 on t1.id = t2.id"); + } + } - assertEquals(CONCUR_READ_ONLY, rs.getConcurrency(), "Expected downgrade to CONCUR_READ_ONLY"); - } + private static void assertDowngradeToReadOnly(Connection connection, String statementText) + throws SQLException { + try (var stmt = connection.createStatement(TYPE_SCROLL_INSENSITIVE, CONCUR_UPDATABLE); + var rs = stmt.executeQuery(statementText)) { + assertThat(stmt.getWarnings(), allOf( + notNullValue(), + fbMessageStartsWith(JaybirdErrorCodes.jb_concurrencyResetReadOnlyReasonNotUpdatable))); + + assertEquals(CONCUR_READ_ONLY, rs.getConcurrency(), "Expected downgrade to CONCUR_READ_ONLY"); } } @@ -652,14 +658,8 @@ void testUpdatableStatementResultSetDowngradeToReadOnlyWhenQueryNotUpdatable( void testUpdatableStatementPrefixPK_downgradeToReadOnly(String scrollableCursorPropertyValue) throws Exception { try (Connection connection = createConnection(scrollableCursorPropertyValue)) { executeCreateTable(connection, CREATE_WITH_COMPOSITE_PK); - try (var stmt = connection.createStatement(TYPE_SCROLL_INSENSITIVE, CONCUR_UPDATABLE); - var rs = stmt.executeQuery("select id1, val from WITH_COMPOSITE_PK")) { - assertThat(stmt.getWarnings(), allOf( - notNullValue(), - fbMessageStartsWith(JaybirdErrorCodes.jb_concurrencyResetReadOnlyReasonNotUpdatable))); - assertEquals(CONCUR_READ_ONLY, rs.getConcurrency(), "Expected downgrade to CONCUR_READ_ONLY"); - } + assertDowngradeToReadOnly(connection, "select id1, val from WITH_COMPOSITE_PK"); } } @@ -674,14 +674,38 @@ void testUpdatableStatementPrefixPK_downgradeToReadOnly(String scrollableCursorP void testUpdatableStatementSuffixPK_downgradeToReadOnly(String scrollableCursorPropertyValue) throws Exception { try (Connection connection = createConnection(scrollableCursorPropertyValue)) { executeCreateTable(connection, CREATE_WITH_COMPOSITE_PK); - try (var stmt = connection.createStatement(TYPE_SCROLL_INSENSITIVE, CONCUR_UPDATABLE); - var rs = stmt.executeQuery("select id2, val from WITH_COMPOSITE_PK")) { - assertThat(stmt.getWarnings(), allOf( - notNullValue(), - fbMessageStartsWith(JaybirdErrorCodes.jb_concurrencyResetReadOnlyReasonNotUpdatable))); - assertEquals(CONCUR_READ_ONLY, rs.getConcurrency(), "Expected downgrade to CONCUR_READ_ONLY"); - } + assertDowngradeToReadOnly(connection, "select id2, val from WITH_COMPOSITE_PK"); + } + } + + /** + * Tests if a statement that contains columns not directly referencing table columns before those that do, + * will be downgraded to read-only. + */ + @ParameterizedTest + @MethodSource("scrollableCursorPropertyValues") + void testUpdatableStatementSuffixExpression_downgradeToReadOnly(String scrollableCursorPropertyValue) + throws Exception { + try (var connection = createConnection(scrollableCursorPropertyValue)) { + executeCreateTable(connection, CREATE_TABLE_STATEMENT); + + assertDowngradeToReadOnly(connection, "select id + 1, id, str from test_table"); + } + } + + /** + * Tests if a statement that contains columns not directly referencing table columns after at least one + * that does, will be downgraded to read-only. + */ + @ParameterizedTest + @MethodSource("scrollableCursorPropertyValues") + void testUpdatableStatementPrefixExpression_downgradeToReadOnly(String scrollableCursorPropertyValue) + throws Exception { + try (var connection = createConnection(scrollableCursorPropertyValue)) { + executeCreateTable(connection, CREATE_TABLE_STATEMENT); + + assertDowngradeToReadOnly(connection, "select id, str, id + 1 from test_table"); } } From 1f839f0c43851a99e1a9806a1f7a388744d556bd Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 13 Jul 2025 15:39:39 +0200 Subject: [PATCH 34/64] Deduplicate strings in StatementInfoProcessor --- .../gds/ng/StatementInfoProcessor.java | 11 +- .../jaybird/util/StringDeduplicator.java | 129 ++++++++++++++++++ .../jaybird/util/StringDeduplicatorTest.java | 113 +++++++++++++++ 3 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 src/main/org/firebirdsql/jaybird/util/StringDeduplicator.java create mode 100644 src/test/org/firebirdsql/jaybird/util/StringDeduplicatorTest.java diff --git a/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java b/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java index ac38482ed..b06d66952 100644 --- a/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java +++ b/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java @@ -5,8 +5,10 @@ import org.firebirdsql.gds.ISCConstants; import org.firebirdsql.gds.ng.fields.RowDescriptor; import org.firebirdsql.gds.ng.fields.RowDescriptorBuilder; +import org.firebirdsql.jaybird.util.StringDeduplicator; import java.sql.SQLException; +import java.util.List; import static org.firebirdsql.gds.VaxEncoding.iscVaxInteger; import static org.firebirdsql.gds.VaxEncoding.iscVaxInteger2; @@ -21,9 +23,11 @@ public final class StatementInfoProcessor implements InfoProcessor { private static final System.Logger log = System.getLogger(StatementInfoProcessor.class.getName()); + private static final List DEDUPLICATOR_PRESET = List.of("SYSTEM", "PUBLIC", "SYSDBA"); private final AbstractFbStatement statement; private final FbDatabase database; + private final StringDeduplicator stringDeduplicator = StringDeduplicator.of(DEDUPLICATOR_PRESET); /** * Creates an instance of this class. @@ -85,10 +89,8 @@ private void handleTruncatedInfo(final StatementInfo info) throws SQLException { newInfoItems[newIndex++] = 2; // size of short newInfoItems[newIndex++] = (byte) (descriptorIndex & 0xFF); newInfoItems[newIndex++] = (byte) (descriptorIndex >> 8); - newInfoItems[newIndex++] = infoItem; - } else { - newInfoItems[newIndex++] = infoItem; } + newInfoItems[newIndex++] = infoItem; } assert newIndex == newInfoItems.length : "newInfoItems size too long"; // Doubling request buffer up to the maximum @@ -212,10 +214,11 @@ private int readIntValue(StatementInfo info) { private String readStringValue(StatementInfo info) { int len = iscVaxInteger2(info.buffer, info.currentIndex); info.currentIndex += 2; + if (len == 0) return ""; // TODO Is it correct to use the connection encoding here, or should we use UTF-8 always? String value = database.getEncoding().decodeFromCharset(info.buffer, info.currentIndex, len); info.currentIndex += len; - return value; + return stringDeduplicator.get(value); } /** diff --git a/src/main/org/firebirdsql/jaybird/util/StringDeduplicator.java b/src/main/org/firebirdsql/jaybird/util/StringDeduplicator.java new file mode 100644 index 000000000..6294d10a3 --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/util/StringDeduplicator.java @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.jspecify.annotations.Nullable; + +import java.io.Serial; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Best-effort string deduplicator. + *

+ * Given this class uses an LRU-evicted map with a maximum capacity internally, 100% deduplication of strings is + * not guaranteed. + *

+ *

+ * This class is not thread-safe. + *

+ * + * @author Mark Rotteveel + * @since 7 + */ +public final class StringDeduplicator { + + private static final int DEFAULT_MAX_CAPACITY = 50; + + private final Map cache; + + private StringDeduplicator(int maxCapacity, Collection preset) { + cache = new StringCache(maxCapacity); + // NOTE: if preset.size() is greater than maxCapacity, prefix will be immediately evicted + preset.forEach(v -> cache.put(v, v)); + } + + /** + * Deduplicates this value if already cached, otherwise caches and returns {@code value}. + * + * @param value + * value to deduplicate (can be {@code null}) + * @return previous cached value equal to {@code value}, or {@code value} itself + */ + public @Nullable String get(@Nullable String value) { + if (value == null) return null; + if (value.isEmpty()) return ""; + return cache.computeIfAbsent(value, Function.identity()); + } + + /** + * @return a string deduplicator with a default max capacity + */ + public static StringDeduplicator of() { + return of(DEFAULT_MAX_CAPACITY, List.of()); + } + + /** + * @param preset + * values to initially add to the cache + * @return a string deduplicator with a default max capacity, initialized with {@code preset} + */ + public static StringDeduplicator of(String... preset) { + return of(Arrays.asList(preset)); + } + + /** + * @param preset + * values to initially add to the cache + * @return a string deduplicator with a default max capacity, initialized with {@code preset} + */ + public static StringDeduplicator of(Collection preset) { + return of(DEFAULT_MAX_CAPACITY, preset); + } + + /** + * @param maxCapacity + * maximum capacity + * @param preset + * values to initially add to the cache + * @return a string deduplicator with max capacity {@code maxCapacity}, initialized with {@code preset} + */ + public static StringDeduplicator of(int maxCapacity, String... preset) { + return of(maxCapacity, Arrays.asList(preset)); + } + + /** + * @param maxCapacity + * maximum capacity + * @param preset + * values to initially add to the cache + * @return a string deduplicator with max capacity {@code maxCapacity}, initialized with {@code preset} + */ + public static StringDeduplicator of(int maxCapacity, Collection preset) { + return new StringDeduplicator(maxCapacity, preset); + } + + /** + * String cache with LRU (Least-Recently Used) eviction order. + */ + private static final class StringCache extends LinkedHashMap { + + @Serial + private static final long serialVersionUID = -201579959301651333L; + + private final int maxCapacity; + + private StringCache(int maxCapacity) { + super(initialCapacity(maxCapacity), 0.75f, true); + if (maxCapacity <= 0) { + throw new IllegalArgumentException("maxCapacity must be greater than 0, was: " + maxCapacity); + } + this.maxCapacity = maxCapacity; + } + + private static int initialCapacity(int maxCapacity) { + return Math.max(8, maxCapacity / 2); + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxCapacity; + } + + } + +} diff --git a/src/test/org/firebirdsql/jaybird/util/StringDeduplicatorTest.java b/src/test/org/firebirdsql/jaybird/util/StringDeduplicatorTest.java new file mode 100644 index 000000000..75b6ab2c5 --- /dev/null +++ b/src/test/org/firebirdsql/jaybird/util/StringDeduplicatorTest.java @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; +import java.util.stream.IntStream; + +import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link StringDeduplicator}. + */ +class StringDeduplicatorTest { + + @Test + void deduplicate() { + var deduplicator = StringDeduplicator.of(); + + final String value1 = "value1"; + final String value1Copy = copyOf(value1); + + // Check our assumptions (indirectly test copyOf(...)) + assumeEqualNotSameInstance(value1, value1Copy, "value1Copy should be a distinct instance"); + + assertSame(value1, deduplicator.get(value1)); + assertSame(value1, deduplicator.get(value1Copy)); + } + + @Test + void deduplicateToPreset() { + var deduplicator = StringDeduplicator.of("PRESET_1", "PRESET_2"); + + assertSame("PRESET_1", deduplicator.get(copyOf("PRESET_1"))); + assertSame("PRESET_2", deduplicator.get(copyOf("PRESET_2"))); + } + + @Test + void eviction() { + final int maxCapacity = 5; + var deduplicator = StringDeduplicator.of(maxCapacity, List.of()); + + final String value1 = "value1"; + final String value1Copy = copyOf(value1); + + assertSame(value1, deduplicator.get(value1), value1); + // Deduplicate other values to evict value1 + IntStream.rangeClosed(2, maxCapacity + 1).forEach(i -> { + String value = "value" + i; + assertSame(value, deduplicator.get(value), value); + }); + + assertSame(value1Copy, deduplicator.get(value1Copy), "expected value1Copy as value1 has been evicted"); + } + + @Test + void deduplicateNull() { + var deduplicator = StringDeduplicator.of(); + + assertNull(deduplicator.get(null)); + } + + @Test + void deduplicateEmptyString_toLiteralEmpty() { + var deduplicator = StringDeduplicator.of(); + + final String emptyCopy = copyOf(""); + assumeEqualNotSameInstance("", emptyCopy, "emptyCopy should be a distinct instance"); + + assertSame("", deduplicator.get(emptyCopy), "should deduplicate to empty literal instance, not copy"); + } + + @ParameterizedTest + @ValueSource(ints = { -2, -1, 0 }) + void cannotCreateWithMaxCapacityZeroOrSmaller(int maxCapacity) { + assertThrows(IllegalArgumentException.class, () -> StringDeduplicator.of(maxCapacity, List.of())); + } + + @ParameterizedTest + @ValueSource(ints = { 1, 2, 10 }) + void canCreateWithMaxCapacityOneOrGreater(int maxCapacity) { + assertDoesNotThrow(() -> StringDeduplicator.of(maxCapacity, List.of())); + } + + /** + * Creates a distinct copy of {@code value}. + * + * @param value + * value to copy + * @return distinct copy of {@code value} (i.e. {@code new String(value)}) + */ + @SuppressWarnings("StringOperationCanBeSimplified") + private static String copyOf(String value) { + return new String(value); + } + + private void assumeEqualNotSameInstance(String v1, String v2, String message) { + assumeThat(message, v2, + allOf(equalTo(v1), not(sameInstance(v1)))); + } + +} \ No newline at end of file From fbff0a60cdaa81b1049dfb129f416dfebae75ad0 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 14 Jul 2025 10:01:46 +0200 Subject: [PATCH 35/64] Add some todo for later --- src/main/org/firebirdsql/jaybird/parser/StatementDetector.java | 2 ++ src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/org/firebirdsql/jaybird/parser/StatementDetector.java b/src/main/org/firebirdsql/jaybird/parser/StatementDetector.java index 8bf545596..649577137 100644 --- a/src/main/org/firebirdsql/jaybird/parser/StatementDetector.java +++ b/src/main/org/firebirdsql/jaybird/parser/StatementDetector.java @@ -27,6 +27,8 @@ @InternalApi public final class StatementDetector implements TokenVisitor { + // TODO Add schema support: identify (optional) schema + private static final StateAfterStart INITIAL_OTHER = new StateAfterStart(ParserState.OTHER, LocalStatementType.OTHER); private static final Map NEXT_AFTER_START; diff --git a/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java b/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java index 370a643d0..35b7d1ea8 100644 --- a/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java +++ b/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java @@ -23,6 +23,8 @@ */ final class GeneratedKeysQueryBuilder { + // TODO Add schema support: parse schema from query, and find a way to identify table if unqualified (needed for + // mapColumnNamesByIndex) // TODO Add caching for column info private static final System.Logger logger = System.getLogger(GeneratedKeysQueryBuilder.class.getName()); From 10ed6548f6d72dfdd044530435ff11f7653c8940 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 17 Jul 2025 10:59:05 +0200 Subject: [PATCH 36/64] #882 Improve schema test coverage --- .../jaybird/util/CollectionUtils.java | 20 +- .../jaybird/util/CollectionUtilsTest.java | 10 +- .../FBDatabaseMetaDataAbstractKeysTest.java | 139 +++++---- ...BDatabaseMetaDataColumnPrivilegesTest.java | 109 +++++-- .../jdbc/FBDatabaseMetaDataColumnsTest.java | 227 +++++++++----- .../FBDatabaseMetaDataCrossReferenceTest.java | 40 ++- .../FBDatabaseMetaDataExportedKeysTest.java | 32 +- ...FBDatabaseMetaDataFunctionColumnsTest.java | 290 +++++++++++------- .../FBDatabaseMetaDataImportedKeysTest.java | 31 +- 9 files changed, 595 insertions(+), 303 deletions(-) diff --git a/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java b/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java index 115a9f760..45db08795 100644 --- a/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java +++ b/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2023-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2023-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.util; @@ -69,4 +69,22 @@ public static void growToSize(final List list, final int size) { return size > 0 ? list.get(size - 1) : null; } + /** + * Concatenates two lists to a new modifiable list. + * + * @param list1 + * list 1 + * @param list2 + * list 2 + * @param + * type parameter of {@code list1}, and parent type parameter of {@code list2} + * @return concatenation of {@code list1} and {@code list2} + */ + public static List concat(List list1, List list2) { + var newList = new ArrayList(list1.size() + list2.size()); + newList.addAll(list1); + newList.addAll(list2); + return newList; + } + } diff --git a/src/test/org/firebirdsql/jaybird/util/CollectionUtilsTest.java b/src/test/org/firebirdsql/jaybird/util/CollectionUtilsTest.java index 28ff16654..c3e5a00c0 100644 --- a/src/test/org/firebirdsql/jaybird/util/CollectionUtilsTest.java +++ b/src/test/org/firebirdsql/jaybird/util/CollectionUtilsTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2023-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2023-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.util; @@ -89,6 +89,14 @@ void getLast_multipleItems() { assertEquals(item2, CollectionUtils.getLast(List.of(item1, item2))); } + @Test + void concat() { + var list1 = List.of("item1", "item2"); + var list2 = List.of("item3", "item4"); + + assertEquals(List.of("item1", "item2", "item3", "item4"), CollectionUtils.concat(list1, list2)); + } + static Stream listFactories() { return Stream.of( Arguments.of(factory(ArrayList::new)), diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java index c25a1d6ab..c265878d2 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java @@ -12,6 +12,7 @@ import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import java.util.Arrays; import java.util.EnumMap; import java.util.List; @@ -19,6 +20,7 @@ import static java.util.Collections.unmodifiableMap; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNoNextRow; @@ -35,59 +37,9 @@ abstract class FBDatabaseMetaDataAbstractKeysTest { private static final String UNNAMED_PK_INDEX_PREFIX = "RDB$PRIMARY"; private static final String UNNAMED_FK_INDEX_PREFIX = "RDB$FOREIGN"; - //@formatter:off @RegisterExtension static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( - """ - create table TABLE_1 ( - ID integer constraint PK_TABLE_1 primary key - )""", - """ - create table TABLE_2 ( - ID1 integer not null, - ID2 integer not null, - TABLE_1_ID integer constraint FK_TABLE_2_TO_1 references TABLE_1 (ID), - constraint PK_TABLE_2 unique (ID1, ID2) using index ALT_INDEX_NAME_2 - )""", - """ - create table TABLE_3 ( - ID integer constraint PK_TABLE_3 primary key using index ALT_INDEX_NAME_3, - TABLE_2_ID1 integer, - TABLE_2_ID2 integer, - constraint FK_TABLE_3_TO_2 foreign key (TABLE_2_ID1, TABLE_2_ID2) references TABLE_2 (ID1, ID2) - on delete cascade on update set default - )""", - """ - create table TABLE_4 ( - ID integer primary key using index ALT_INDEX_NAME_4, - TABLE_2_ID1 integer, - TABLE_2_ID2 integer, - constraint FK_TABLE_4_TO_2 foreign key (TABLE_2_ID1, TABLE_2_ID2) references TABLE_2 (ID1, ID2) - on delete set default on update set null - )""", - """ - create table TABLE_5 ( - ID integer primary key, - TABLE_2_ID1 integer, - TABLE_2_ID2 integer, - foreign key (TABLE_2_ID1, TABLE_2_ID2) references TABLE_2 (ID1, ID2) - on delete set null on update no action using index ALT_INDEX_NAME_5 - )""", - """ - create table TABLE_6 ( - ID integer primary key, - TABLE_2_ID1 integer, - TABLE_2_ID2 integer, - foreign key (TABLE_2_ID1, TABLE_2_ID2) references TABLE_2 (ID1, ID2) - on delete no action on update cascade - )""", - """ - create table TABLE_7 ( - ID integer primary key, - TABLE_6_ID integer constraint FK_TABLE_7_TO_6 references TABLE_6 (ID) on update cascade - )""" - ); - //@formatter:on + dbInitStatements()); protected static final MetadataResultSetDefinition keysDefinition = new MetadataResultSetDefinition(KeysMetaData.class); @@ -95,6 +47,76 @@ TABLE_6_ID integer constraint FK_TABLE_7_TO_6 references TABLE_6 (ID) on update protected static Connection con; protected static DatabaseMetaData dbmd; + private static List dbInitStatements() { + var statements = new ArrayList<>(Arrays.asList( + """ + create table TABLE_1 ( + ID integer constraint PK_TABLE_1 primary key + )""", + """ + create table TABLE_2 ( + ID1 integer not null, + ID2 integer not null, + TABLE_1_ID integer constraint FK_TABLE_2_TO_1 references TABLE_1 (ID), + constraint PK_TABLE_2 unique (ID1, ID2) using index ALT_INDEX_NAME_2 + )""", + """ + create table TABLE_3 ( + ID integer constraint PK_TABLE_3 primary key using index ALT_INDEX_NAME_3, + TABLE_2_ID1 integer, + TABLE_2_ID2 integer, + constraint FK_TABLE_3_TO_2 foreign key (TABLE_2_ID1, TABLE_2_ID2) references TABLE_2 (ID1, ID2) + on delete cascade on update set default + )""", + """ + create table TABLE_4 ( + ID integer primary key using index ALT_INDEX_NAME_4, + TABLE_2_ID1 integer, + TABLE_2_ID2 integer, + constraint FK_TABLE_4_TO_2 foreign key (TABLE_2_ID1, TABLE_2_ID2) references TABLE_2 (ID1, ID2) + on delete set default on update set null + )""", + """ + create table TABLE_5 ( + ID integer primary key, + TABLE_2_ID1 integer, + TABLE_2_ID2 integer, + foreign key (TABLE_2_ID1, TABLE_2_ID2) references TABLE_2 (ID1, ID2) + on delete set null on update no action using index ALT_INDEX_NAME_5 + )""", + """ + create table TABLE_6 ( + ID integer primary key, + TABLE_2_ID1 integer, + TABLE_2_ID2 integer, + foreign key (TABLE_2_ID1, TABLE_2_ID2) references TABLE_2 (ID1, ID2) + on delete no action on update cascade + )""" + )); + if (!getDefaultSupportInfo().supportsSchemas()) { + statements.add(""" + create table TABLE_7 ( + ID integer primary key, + TABLE_6_ID integer constraint FK_TABLE_7_TO_6 references TABLE_6 (ID) on update cascade + )"""); + } else { + statements.add("create schema OTHER_SCHEMA"); + statements.add(""" + create table OTHER_SCHEMA.TABLE_8 ( + ID integer primary key, + TABLE_1_ID integer constraint FK_TABLE_8_TO_1 references PUBLIC.TABLE_1 (ID) + )"""); + statements.add(""" + create table TABLE_7 ( + ID integer primary key, + TABLE_6_ID integer constraint FK_TABLE_7_TO_6 references TABLE_6 (ID) on update cascade, + TABLE_8_ID integer constraint FK_TABLE_7_TO_8 references OTHER_SCHEMA.TABLE_8 (ID) on delete cascade + )"""); + } + + return statements; + } + @BeforeAll static void setupAll() throws SQLException { con = getConnectionViaDriverManager(); @@ -163,13 +185,26 @@ protected static List> table6Fks() { UNNAMED_CONSTRAINT_PREFIX, "ALT_INDEX_NAME_2", UNNAMED_FK_INDEX_PREFIX)); } - protected static List> table7Fks() { + protected static List> table7to6Fks() { return List.of( createKeysTestData("TABLE_6", "ID", "TABLE_7", "TABLE_6_ID", 1, DatabaseMetaData.importedKeyCascade, DatabaseMetaData.importedKeyNoAction, UNNAMED_CONSTRAINT_PREFIX, "FK_TABLE_7_TO_6", UNNAMED_PK_INDEX_PREFIX, "FK_TABLE_7_TO_6")); } + protected static List> table7to8Fks() { + return List.of(createKeysTestData("OTHER_SCHEMA", "TABLE_8", "ID", "PUBLIC", "TABLE_7", "TABLE_8_ID", 1, + DatabaseMetaData.importedKeyNoAction, DatabaseMetaData.importedKeyCascade, + UNNAMED_CONSTRAINT_PREFIX, "FK_TABLE_7_TO_8", UNNAMED_PK_INDEX_PREFIX, "FK_TABLE_7_TO_8")); + } + + protected static List> table8Fks() { + return List.of( + createKeysTestData("PUBLIC", "TABLE_1", "ID", "OTHER_SCHEMA", "TABLE_8", "TABLE_1_ID", 1, + DatabaseMetaData.importedKeyNoAction, DatabaseMetaData.importedKeyNoAction, + "PK_TABLE_1", "FK_TABLE_8_TO_1", "PK_TABLE_1", "FK_TABLE_8_TO_1")); + } + protected void validateExpectedKeys(ResultSet keys, List> expectedKeys) throws Exception { for (Map expectedColumn : expectedKeys) { diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java index 33358b0d4..b5039e1e0 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java @@ -4,18 +4,20 @@ import org.firebirdsql.common.FBTestProperties; import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.EnumMap; @@ -25,6 +27,7 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -36,22 +39,14 @@ */ class FBDatabaseMetaDataColumnPrivilegesTest { - // TODO Add schema support: tests involving other schema - private static final String SYSDBA = "SYSDBA"; private static final String USER1 = "USER1"; private static final String user2 = getDefaultSupportInfo().supportsCaseSensitiveUserNames() ? "user2" : "USER2"; private static final String PUBLIC = "PUBLIC"; @RegisterExtension - static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( - "create table TBL1 (COL1 integer, COL2 varchar(50), \"val3\" varchar(50))", - "create table \"tbl2\" (COL1 integer, COL2 varchar(50), \"val3\" varchar(50))", - "grant all on TBL1 to USER1", - "grant select on TBL1 to PUBLIC", - "grant update (COL1, \"val3\") on TBL1 to \"user2\"", - "grant select on \"tbl2\" to \"user2\" with grant option", - "grant references (COL1) on \"tbl2\" to USER1"); + static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = + UsesDatabaseExtension.usesDatabaseForAll(dbInitStatements()); private static final MetadataResultSetDefinition getColumnPrivilegesDefinition = new MetadataResultSetDefinition(ColumnPrivilegesMetadata.class); @@ -59,6 +54,28 @@ class FBDatabaseMetaDataColumnPrivilegesTest { private static Connection con; private static DatabaseMetaData dbmd; + private static List dbInitStatements() { + var statements = new ArrayList<>(Arrays.asList( + "create table TBL1 (COL1 integer, COL2 varchar(50), \"val3\" varchar(50))", + "create table \"tbl2\" (COL1 integer, COL2 varchar(50), \"val3\" varchar(50))", + "grant all on TBL1 to USER1", + "grant select on TBL1 to PUBLIC", + "grant update (COL1, \"val3\") on TBL1 to \"user2\"", + "grant select on \"tbl2\" to \"user2\" with grant option", + "grant references (COL1) on \"tbl2\" to USER1" + )); + if (getDefaultSupportInfo().supportsSchemas()) { + statements.addAll(Arrays.asList( + "create schema OTHER_SCHEMA", + "create table OTHER_SCHEMA.TBL3 (COL1 integer, COL2 varchar(50), \"val3\" varchar(50))", + "grant select on OTHER_SCHEMA.TBL3 to PUBLIC", + "grant update on OTHER_SCHEMA.TBL3 to USER1" + )); + } + + return statements; + } + @BeforeAll static void setupAll() throws SQLException { // Otherwise we need to take into account additional rules @@ -88,9 +105,14 @@ void testColumnPrivilegesMetaDataColumns() throws Exception { } @ParameterizedTest - @ValueSource(strings = "%") - @NullSource - void testColumnPrivileges_TBL1_all(String allPattern) throws Exception { + @CsvSource(useHeadersInDisplayName = true, nullValues = { "" }, textBlock = """ + schemaNull, columNameAllPattern + false, % + false, + true, % + true, + """) + void testColumnPrivileges_TBL1_all(boolean schemaNull, String columnNameAllPattern) throws Exception { List> rules = Arrays.asList( createRule("TBL1", "COL1", SYSDBA, true, "DELETE"), createRule("TBL1", "COL1", USER1, false, "DELETE"), @@ -128,13 +150,13 @@ void testColumnPrivileges_TBL1_all(String allPattern) throws Exception { createRule("TBL1", "val3", USER1, false, "UPDATE"), createRule("TBL1", "val3", user2, false, "UPDATE")); - validateExpectedColumnPrivileges(ifSchemaElse("PUBLIC", ""), "TBL1", allPattern, rules); - // schema = null should also find it: - validateExpectedColumnPrivileges(null, "TBL1", allPattern, rules); + validateExpectedColumnPrivileges(schemaNull ? null : ifSchemaElse("PUBLIC", ""), "TBL1", columnNameAllPattern, + rules); } - @Test - void testColumnPrivileges_TBL1_COL_wildcard() throws Exception { + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void testColumnPrivileges_TBL1_COL_wildcard(boolean schemaNull) throws Exception { List> rules = Arrays.asList( createRule("TBL1", "COL1", SYSDBA, true, "DELETE"), createRule("TBL1", "COL1", USER1, false, "DELETE"), @@ -160,13 +182,12 @@ void testColumnPrivileges_TBL1_COL_wildcard() throws Exception { createRule("TBL1", "COL2", SYSDBA, true, "UPDATE"), createRule("TBL1", "COL2", USER1, false, "UPDATE")); - validateExpectedColumnPrivileges(ifSchemaElse("PUBLIC", ""), "TBL1", "COL%", rules); - // schema = null should also find it: - validateExpectedColumnPrivileges(null, "TBL1", "COL%", rules); + validateExpectedColumnPrivileges(schemaNull ? null : ifSchemaElse("PUBLIC", ""), "TBL1", "COL%", rules); } - @Test - void testColumnPrivileges_tbl2_all() throws Exception { + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void testColumnPrivileges_tbl2_all(boolean schemaNull) throws Exception { List> rules = Arrays.asList( createRule("tbl2", "COL1", SYSDBA, true, "DELETE"), createRule("tbl2", "COL1", SYSDBA, true, "INSERT"), @@ -188,7 +209,45 @@ void testColumnPrivileges_tbl2_all() throws Exception { createRule("tbl2", "val3", user2, true, "SELECT"), createRule("tbl2", "val3", SYSDBA, true, "UPDATE")); - validateExpectedColumnPrivileges(ifSchemaElse("PUBLIC", ""), "tbl2", "%", rules); + validateExpectedColumnPrivileges(schemaNull ? null : ifSchemaElse("PUBLIC", ""), "tbl2", "%", rules); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = { "" }, textBlock = """ + schemaNull, columNameAllPattern + false, % + false, + true, % + true, + """) + void testColumnPrivileges_other_schema_tbl2_all(boolean schemaNull, String columnNameAllPattern) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + var rules = Arrays.asList( + createRule("OTHER_SCHEMA", "TBL3", "COL1", SYSDBA, true, "DELETE"), + createRule("OTHER_SCHEMA", "TBL3", "COL1", SYSDBA, true, "INSERT"), + createRule("OTHER_SCHEMA", "TBL3", "COL1", SYSDBA, true, "REFERENCES"), + createRule("OTHER_SCHEMA", "TBL3", "COL1", PUBLIC, false, "SELECT"), + createRule("OTHER_SCHEMA", "TBL3", "COL1", SYSDBA, true, "SELECT"), + createRule("OTHER_SCHEMA", "TBL3", "COL1", SYSDBA, true, "UPDATE"), + createRule("OTHER_SCHEMA", "TBL3", "COL1", USER1, false, "UPDATE"), + createRule("OTHER_SCHEMA", "TBL3", "COL2", SYSDBA, true, "DELETE"), + createRule("OTHER_SCHEMA", "TBL3", "COL2", SYSDBA, true, "INSERT"), + createRule("OTHER_SCHEMA", "TBL3", "COL2", SYSDBA, true, "REFERENCES"), + createRule("OTHER_SCHEMA", "TBL3", "COL2", PUBLIC, false, "SELECT"), + createRule("OTHER_SCHEMA", "TBL3", "COL2", SYSDBA, true, "SELECT"), + createRule("OTHER_SCHEMA", "TBL3", "COL2", SYSDBA, true, "UPDATE"), + createRule("OTHER_SCHEMA", "TBL3", "COL2", USER1, false, "UPDATE"), + createRule("OTHER_SCHEMA", "TBL3", "val3", SYSDBA, true, "DELETE"), + createRule("OTHER_SCHEMA", "TBL3", "val3", SYSDBA, true, "INSERT"), + createRule("OTHER_SCHEMA", "TBL3", "val3", SYSDBA, true, "REFERENCES"), + createRule("OTHER_SCHEMA", "TBL3", "val3", PUBLIC, false, "SELECT"), + createRule("OTHER_SCHEMA", "TBL3", "val3", SYSDBA, true, "SELECT"), + createRule("OTHER_SCHEMA", "TBL3", "val3", SYSDBA, true, "UPDATE"), + createRule("OTHER_SCHEMA", "TBL3", "val3", USER1, false, "UPDATE") + ); + + validateExpectedColumnPrivileges(schemaNull ? null : "OTHER_SCHEMA", "TBL3", columnNameAllPattern, + rules); } private Map createRule(String table, String columnName, String grantee, diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java index 01f384573..9117372f0 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2012-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; @@ -18,78 +18,86 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.jaybird.util.StringUtils.trimToNull; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assumptions.assumeTrue; /** * Tests for {@link FBDatabaseMetaData} for column related metadata. - * + * * @author Mark Rotteveel */ class FBDatabaseMetaDataColumnsTest { - // TODO Add schema support: tests involving other schema - private static final String TEST_TABLE = "TEST_COLUMN_METADATA"; private static final String CREATE_DOMAIN_WITH_DEFAULT = "CREATE DOMAIN DOMAIN_WITH_DEFAULT AS VARCHAR(100) DEFAULT 'this is a default'"; - //@formatter:off - private static final String CREATE_COLUMN_METADATA_TEST_TABLE = - "CREATE TABLE " + TEST_TABLE + " (" + - " col_integer INTEGER," + - " col_bigint BIGINT," + - " col_smallint SMALLINT," + - " col_double DOUBLE PRECISION," + - " col_float FLOAT," + - " col_dec18_2 DECIMAL(18,2)," + - " col_dec18_0 DECIMAL(18,0)," + - " col_dec7_3 DECIMAL(7,3)," + - " col_dec7_0 DECIMAL(7,0)," + - " col_dec4_3 DECIMAL(4,3), " + - " col_dec4_0 DECIMAL(4,0), " + - " col_num18_2 NUMERIC(18,2)," + - " col_num18_0 NUMERIC(18,0)," + - " col_num7_3 NUMERIC(7,3)," + - " col_num7_0 NUMERIC(7,0)," + - " col_num4_3 NUMERIC(4,3), " + - " col_num4_0 NUMERIC(4,0), " + - " col_date DATE," + - " col_time TIME," + - " col_timestamp TIMESTAMP," + - " col_char_10_utf8 CHAR(10) CHARACTER SET UTF8," + - " col_char_10_iso8859_1 CHAR(10) CHARACTER SET ISO8859_1," + - " col_char_10_octets CHAR(10) CHARACTER SET OCTETS," + - " col_varchar_10_utf8 VARCHAR(10) CHARACTER SET UTF8," + - " col_varchar_10_iso8859_1 VARCHAR(10) CHARACTER SET ISO8859_1," + - " col_varchar_10_octets VARCHAR(10) CHARACTER SET OCTETS," + - " col_blob_text_utf8 BLOB SUB_TYPE TEXT CHARACTER SET UTF8," + - " col_blob_text_iso8859_1 BLOB SUB_TYPE TEXT CHARACTER SET ISO8859_1," + - " col_blob_binary BLOB SUB_TYPE BINARY," + - " col_integer_not_null INTEGER NOT NULL," + - " col_varchar_not_null VARCHAR(100) NOT NULL," + - " col_integer_default_null INTEGER DEFAULT NULL," + - " col_integer_default_999 INTEGER DEFAULT 999," + - " col_varchar_default_null VARCHAR(100) DEFAULT NULL," + - " col_varchar_default_user VARCHAR(100) DEFAULT USER," + - " col_varchar_default_literal VARCHAR(100) DEFAULT 'literal'," + - " col_varchar_generated VARCHAR(200) COMPUTED BY (col_varchar_default_user || ' ' || col_varchar_default_literal)," + - " col_domain_with_default DOMAIN_WITH_DEFAULT," + - " col_domain_w_default_overridden DOMAIN_WITH_DEFAULT DEFAULT 'overridden default' " + - " /* boolean */ " + - " /* decfloat */ " + - " /* extended numerics */ " + - " /* time zone */ " + - " /* int128 */ " + - ")"; - //@formatter:on + private static final String CREATE_COLUMN_METADATA_TEST_TABLE = """ + CREATE TABLE TEST_COLUMN_METADATA ( + col_integer INTEGER, + col_bigint BIGINT, + col_smallint SMALLINT, + col_double DOUBLE PRECISION, + col_float FLOAT, + col_dec18_2 DECIMAL(18,2), + col_dec18_0 DECIMAL(18,0), + col_dec7_3 DECIMAL(7,3), + col_dec7_0 DECIMAL(7,0), + col_dec4_3 DECIMAL(4,3), + col_dec4_0 DECIMAL(4,0), + col_num18_2 NUMERIC(18,2), + col_num18_0 NUMERIC(18,0), + col_num7_3 NUMERIC(7,3), + col_num7_0 NUMERIC(7,0), + col_num4_3 NUMERIC(4,3), + col_num4_0 NUMERIC(4,0), + col_date DATE, + col_time TIME, + col_timestamp TIMESTAMP, + col_char_10_utf8 CHAR(10) CHARACTER SET UTF8, + col_char_10_iso8859_1 CHAR(10) CHARACTER SET ISO8859_1, + col_char_10_octets CHAR(10) CHARACTER SET OCTETS, + col_varchar_10_utf8 VARCHAR(10) CHARACTER SET UTF8, + col_varchar_10_iso8859_1 VARCHAR(10) CHARACTER SET ISO8859_1, + col_varchar_10_octets VARCHAR(10) CHARACTER SET OCTETS, + col_blob_text_utf8 BLOB SUB_TYPE TEXT CHARACTER SET UTF8, + col_blob_text_iso8859_1 BLOB SUB_TYPE TEXT CHARACTER SET ISO8859_1, + col_blob_binary BLOB SUB_TYPE BINARY, + col_integer_not_null INTEGER NOT NULL, + col_varchar_not_null VARCHAR(100) NOT NULL, + col_integer_default_null INTEGER DEFAULT NULL, + col_integer_default_999 INTEGER DEFAULT 999, + col_varchar_default_null VARCHAR(100) DEFAULT NULL, + col_varchar_default_user VARCHAR(100) DEFAULT USER, + col_varchar_default_literal VARCHAR(100) DEFAULT 'literal', + col_varchar_generated VARCHAR(200) COMPUTED BY (col_varchar_default_user || ' ' || col_varchar_default_literal), + col_domain_with_default DOMAIN_WITH_DEFAULT, + col_domain_w_default_overridden DOMAIN_WITH_DEFAULT DEFAULT 'overridden default' + /* boolean */ + /* decfloat */ + /* extended numerics */ + /* time zone */ + /* int128 */ + )"""; private static final String ADD_COMMENT_ON_COLUMN = "COMMENT ON COLUMN test_column_metadata.col_integer IS 'Some comment'"; + private static final String OTHER_SCHEMA = "OTHER_SCHEMA"; + + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + + private static final String OTHER_SCHEMA_TABLE = "OTHER_TABLE"; + + private static final String CREATE_OTHER_SCHEMA_TABLE = """ + create table OTHER_SCHEMA.OTHER_TABLE ( + ID integer constraint PK_OTHER_TABLE primary key, + COL2 varchar(50) + )"""; + private static final MetadataResultSetDefinition getColumnsDefinition = new MetadataResultSetDefinition(ColumnMetaData.class); @@ -145,14 +153,19 @@ private static List getCreateStatements() { createTable = createTable.replace("/* int128 */", ", col_int128 INT128"); } - statements.add(createTable); + if (supportInfo.supportsComment()) { statements.add(ADD_COMMENT_ON_COLUMN); } + if (supportInfo.supportsSchemas()) { + statements.add(CREATE_OTHER_SCHEMA); + statements.add(CREATE_OTHER_SCHEMA_TABLE); + } + return statements; } - + /** * Tests the ordinal positions and types for the metadata columns of getColumns(). */ @@ -750,7 +763,39 @@ void testInt128Column() throws Exception { validate(ifSchemaElse("PUBLIC", ""), TEST_TABLE, "COL_INT128", validationRules); } - + + @Test + void testOtherSchemaTable() throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + + List> validationRules = new ArrayList<>(); + Map idRules = getDefaultValueValidationRules(); + idRules.put(ColumnMetaData.TABLE_SCHEM, OTHER_SCHEMA); + idRules.put(ColumnMetaData.TABLE_NAME, OTHER_SCHEMA_TABLE); + idRules.put(ColumnMetaData.COLUMN_NAME, "ID"); + idRules.put(ColumnMetaData.DATA_TYPE, Types.INTEGER); + idRules.put(ColumnMetaData.TYPE_NAME, "INTEGER"); + idRules.put(ColumnMetaData.IS_NULLABLE, "NO"); + idRules.put(ColumnMetaData.NULLABLE, DatabaseMetaData.columnNoNulls); + idRules.put(ColumnMetaData.COLUMN_SIZE, 10); + idRules.put(ColumnMetaData.DECIMAL_DIGITS, 0); + idRules.put(ColumnMetaData.ORDINAL_POSITION, 1); + idRules.put(ColumnMetaData.IS_AUTOINCREMENT, ""); + validationRules.add(idRules); + Map col2Rules = getDefaultValueValidationRules(); + col2Rules.put(ColumnMetaData.TABLE_SCHEM, OTHER_SCHEMA); + col2Rules.put(ColumnMetaData.TABLE_NAME, OTHER_SCHEMA_TABLE); + col2Rules.put(ColumnMetaData.COLUMN_NAME, "COL2"); + col2Rules.put(ColumnMetaData.DATA_TYPE, Types.VARCHAR); + col2Rules.put(ColumnMetaData.TYPE_NAME, "VARCHAR"); + col2Rules.put(ColumnMetaData.COLUMN_SIZE, 50); + col2Rules.put(ColumnMetaData.CHAR_OCTET_LENGTH, 50); + col2Rules.put(ColumnMetaData.ORDINAL_POSITION, 2); + validationRules.add(col2Rules); + + validate(OTHER_SCHEMA, OTHER_SCHEMA_TABLE, "%", validationRules); + } + // TODO: Add more extensive tests of patterns /** @@ -773,10 +818,22 @@ private void validate(String schema, String tableName, String columnName, validationRules.put(ColumnMetaData.COLUMN_NAME, columnName); getColumnsDefinition.checkValidationRulesComplete(validationRules); - try (ResultSet columns = dbmd.getColumns(null, schema, tableName, columnName)) { - assertTrue(columns.next(), "Expected row in column metadata"); - getColumnsDefinition.validateRowValues(columns, validationRules); - assertFalse(columns.next(), "Expected only one row in resultset"); + validate(schema, tableName, columnName, List.of(validationRules)); + } + + private void validate(String schema, String tableName, String columnNamePattern, + List> expectedColumns) throws Exception { + try (ResultSet columns = dbmd.getColumns(null, schema, tableName, columnNamePattern)) { + int columnCount = 0; + while (columns.next()) { + if (columnCount < expectedColumns.size()) { + Map rules = expectedColumns.get(columnCount); + getColumnsDefinition.checkValidationRulesComplete(rules); + getColumnsDefinition.validateRowValues(columns, rules); + } + columnCount++; + } + assertEquals(expectedColumns.size(), columnCount, "Unexpected number of columns"); } } @@ -810,34 +867,34 @@ private void validate(String schema, String tableName, String columnName, private static Map getDefaultValueValidationRules() { return new EnumMap<>(DEFAULT_COLUMN_VALUES); } - + /** * Columns defined for the getColumns() metadata. */ private enum ColumnMetaData implements MetaDataInfo { - TABLE_CAT(1, String.class), - TABLE_SCHEM(2, String.class), - TABLE_NAME(3, String.class), - COLUMN_NAME(4, String.class), - DATA_TYPE(5, Integer.class), - TYPE_NAME(6, String.class), - COLUMN_SIZE(7, Integer.class), - BUFFER_LENGTH(8, Integer.class), - DECIMAL_DIGITS(9, Integer.class), - NUM_PREC_RADIX(10, Integer.class), - NULLABLE(11, Integer.class), - REMARKS(12, String.class), - COLUMN_DEF(13, String.class), - SQL_DATA_TYPE(14, Integer.class), - SQL_DATETIME_SUB(15, Integer.class), - CHAR_OCTET_LENGTH(16, Integer.class), - ORDINAL_POSITION(17, Integer.class), - IS_NULLABLE(18, String.class), + TABLE_CAT(1, String.class), + TABLE_SCHEM(2, String.class), + TABLE_NAME(3, String.class), + COLUMN_NAME(4, String.class), + DATA_TYPE(5, Integer.class), + TYPE_NAME(6, String.class), + COLUMN_SIZE(7, Integer.class), + BUFFER_LENGTH(8, Integer.class), + DECIMAL_DIGITS(9, Integer.class), + NUM_PREC_RADIX(10, Integer.class), + NULLABLE(11, Integer.class), + REMARKS(12, String.class), + COLUMN_DEF(13, String.class), + SQL_DATA_TYPE(14, Integer.class), + SQL_DATETIME_SUB(15, Integer.class), + CHAR_OCTET_LENGTH(16, Integer.class), + ORDINAL_POSITION(17, Integer.class), + IS_NULLABLE(18, String.class), SCOPE_CATALOG(19, String.class), - SCOPE_SCHEMA(20, String.class), - SCOPE_TABLE(21, String.class), - SOURCE_DATA_TYPE(22, Short.class), - IS_AUTOINCREMENT(23, String.class), + SCOPE_SCHEMA(20, String.class), + SCOPE_TABLE(21, String.class), + SOURCE_DATA_TYPE(22, Short.class), + IS_AUTOINCREMENT(23, String.class), IS_GENERATEDCOLUMN(24,String.class), JB_IS_IDENTITY(25,String.class), JB_IDENTITY_TYPE(26,String.class); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java index 041bc72a6..4e015b49b 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -12,6 +13,7 @@ import java.util.Map; import java.util.stream.Stream; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; /** @@ -21,8 +23,6 @@ */ class FBDatabaseMetaDataCrossReferenceTest extends FBDatabaseMetaDataAbstractKeysTest { - // TODO Add schema support: tests involving other schema - @Test void testCrossReferenceMetaDataColumns() throws Exception { try (ResultSet crossReference = dbmd.getCrossReference( @@ -31,33 +31,55 @@ void testCrossReferenceMetaDataColumns() throws Exception { } } - @ParameterizedTest(name = "{0} - {1}") + @ParameterizedTest(name = "({0}, {1}) - ({2}, {3})") @MethodSource - void testCrossReference(String parentTable, String foreignTable, List> expectedKeys) + void testCrossReference(@Nullable String parentSchema, String parentTable, + @Nullable String foreignSchema, String foreignTable, + List> expectedKeys) throws Exception { - try (ResultSet crossReference = dbmd.getCrossReference(null, ifSchemaElse("PUBLIC", ""), parentTable, null, - ifSchemaElse("PUBLIC", ""), foreignTable)) { + try (ResultSet crossReference = dbmd.getCrossReference(null, parentSchema, parentTable, + null, foreignSchema, foreignTable)) { validateExpectedKeys(crossReference, expectedKeys); } } static Stream testCrossReference() { - return Stream.of( + var generalArguments = Stream.of( crossRefTestCase("TABLE_1", "TABLE_2", table2Fks()), + crossRefTestCase(null, "TABLE_1", null, "TABLE_2", table2Fks()), + crossRefTestCase(ifSchemaElse("PUBLIC", ""), "TABLE_1", null, "TABLE_2", table2Fks()), + crossRefTestCase(null, "TABLE_1", ifSchemaElse("PUBLIC", ""), "TABLE_2", table2Fks()), crossRefTestCase("TABLE_2", "TABLE_1", List.of()), crossRefTestCase("TABLE_1", "TABLE_3", List.of()), crossRefTestCase("TABLE_2", "TABLE_3", table3Fks()), crossRefTestCase("TABLE_2", "TABLE_4", table4Fks()), crossRefTestCase("TABLE_2", "TABLE_5", table5Fks()), crossRefTestCase("TABLE_2", "TABLE_6", table6Fks()), - crossRefTestCase("TABLE_6", "TABLE_7", table7Fks()), + crossRefTestCase("TABLE_6", "TABLE_7", table7to6Fks()), crossRefTestCase("TABLE_1", "doesnotexist", List.of()), crossRefTestCase("doesnotexist", "TABLE_2", List.of())); + if (!getDefaultSupportInfo().supportsSchemas()) { + return generalArguments; + } + return Stream.concat(generalArguments, Stream.of( + crossRefTestCase("PUBLIC", "TABLE_1", "OTHER_SCHEMA", "TABLE_8", table8Fks()), + crossRefTestCase(null, "TABLE_1", null, "TABLE_8", table8Fks()), + crossRefTestCase("PUBLIC", "TABLE_1", null, "TABLE_8", table8Fks()), + crossRefTestCase(null, "TABLE_1", "OTHER_SCHEMA", "TABLE_8", table8Fks()), + crossRefTestCase("OTHER_SCHEMA", "TABLE_8", "PUBLIC", "TABLE_7", table7to8Fks()) + )); } private static Arguments crossRefTestCase(String parentTable, String foreignTable, List> expectedKeys) { - return Arguments.of(parentTable, foreignTable, expectedKeys); + String defaultSchema = ifSchemaElse("PUBLIC", ""); + return crossRefTestCase(defaultSchema, parentTable, defaultSchema, foreignTable, expectedKeys); + } + + private static Arguments crossRefTestCase(@Nullable String parentSchema, String parentTable, + @Nullable String foreignSchema, String foreignTable, + List> expectedKeys) { + return Arguments.of(parentSchema, parentTable, foreignSchema, foreignTable, expectedKeys); } } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataExportedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataExportedKeysTest.java index 3b4217ebe..16632bcbf 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataExportedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataExportedKeysTest.java @@ -8,12 +8,13 @@ import org.junit.jupiter.params.provider.MethodSource; import java.sql.ResultSet; -import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.stream.Stream; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; /** @@ -23,8 +24,6 @@ */ class FBDatabaseMetaDataExportedKeysTest extends FBDatabaseMetaDataAbstractKeysTest { - // TODO Add schema support: tests involving other schema - @Test void testExportedKeysMetaDataColumns() throws Exception { try (ResultSet exportedKeys = dbmd.getExportedKeys(null, null, "doesnotexit")) { @@ -32,7 +31,7 @@ void testExportedKeysMetaDataColumns() throws Exception { } } - @ParameterizedTest + @ParameterizedTest(name = "({0}, {1})") @MethodSource void testExportedKeys(String schema, String table, List> expectedKeys) throws Exception { try (ResultSet exportedKeys = dbmd.getExportedKeys(null, schema, table)) { @@ -41,12 +40,19 @@ void testExportedKeys(String schema, String table, List testExportedKeys() { - return Stream.of( - exportedKeysTestCase("TABLE_1", table2Fks()), + var generalArguments = Stream.of( + exportedKeysTestCase("TABLE_1", ifSchemaElse(table8Fks(), List.of()), table2Fks()), + exportedKeysTestCase(null, "TABLE_1", ifSchemaElse(table8Fks(), List.of()), table2Fks()), exportedKeysTestCase("doesnotexist", List.of()), exportedKeysTestCase("TABLE_2", table3Fks(), table4Fks(), table5Fks(), table6Fks()), exportedKeysTestCase("TABLE_3", List.of()), - exportedKeysTestCase("TABLE_6", table7Fks())); + exportedKeysTestCase("TABLE_6", table7to6Fks())); + if (!getDefaultSupportInfo().supportsSchemas()) { + return generalArguments; + } + return Stream.concat(generalArguments, Stream.of( + exportedKeysTestCase("OTHER_SCHEMA", "TABLE_8", table7to8Fks()), + exportedKeysTestCase(null, "TABLE_8", table7to8Fks()))); } private static Arguments exportedKeysTestCase(String table, List> expectedKeys) { @@ -58,12 +64,16 @@ private static Arguments exportedKeysTestCase(String schema, String table, return Arguments.of(schema, table, expectedKeys); } - @SuppressWarnings("SameParameterValue") @SafeVarargs private static Arguments exportedKeysTestCase(String table, List>... expectedKeys) { - var combinedExpectedKeys = new ArrayList>(); - Arrays.stream(expectedKeys).forEach(combinedExpectedKeys::addAll); - return exportedKeysTestCase(table, combinedExpectedKeys); + return exportedKeysTestCase(ifSchemaElse("PUBLIC", ""), table, expectedKeys); + } + + @SafeVarargs + private static Arguments exportedKeysTestCase(String schema, String table, + List>... expectedKeys) { + var combinedExpectedKeys = Arrays.stream(expectedKeys).flatMap(Collection::stream).toList(); + return exportedKeysTestCase(schema, table, combinedExpectedKeys); } } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java index 9d20e0a40..b7a6ef492 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java @@ -4,6 +4,8 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.jaybird.props.PropertyNames; +import org.firebirdsql.jaybird.util.CollectionUtils; +import org.firebirdsql.jaybird.util.QualifiedName; import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -23,6 +25,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.jdbc.FBDatabaseMetaDataFunctionsTest.isIgnoredFunction; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.*; @@ -36,10 +39,9 @@ */ class FBDatabaseMetaDataFunctionColumnsTest { - // TODO Add schema support: tests involving other schema - private static final String PSQL_EXAMPLE_1 = "PSQL$EXAMPLE$1"; private static final String PSQL_EXAMPLE_2 = "PSQL$EXAMPLE$2"; + private static final String PSQL_EXAMPLE_3 = "PSQL$EXAMPLE$3"; private static final String UDF_EXAMPLE_1 = "UDF$EXAMPLE$1"; private static final String UDF_EXAMPLE_2 = "UDF$EXAMPLE$2"; private static final String UDF_EXAMPLE_3 = "UDF$EXAMPLE$3"; @@ -50,77 +52,93 @@ class FBDatabaseMetaDataFunctionColumnsTest { private static final String CREATE_DOMAIN_D_INTEGER = "create domain D_INTEGER as integer"; - private static final String CREATE_PSQL_EXAMPLE_1 = "create function " + PSQL_EXAMPLE_1 + "(" - + "C$01$FLOAT float not null," - + "C$02$DOUBLE double precision," - + "C$03$CHAR10 char(10)," - + "C$04$VARCHAR15 varchar(15) not null," - + "C$05$BINARY20 char(20) character set octets," - + "C$06$VARBINARY25 varchar(25) character set octets," - + "C$07$BIGINT bigint," - + "C$08$INTEGER integer," - + "C$09$SMALLINT smallint," - + "C$10$NUMERIC18$3 numeric(18,3)," - + "C$11$NUMERIC9$3 numeric(9,3)," - + "C$12$NUMERIC4$3 numeric(4,3)," - + "C$13$DECIMAL18$2 decimal(18,2)," - + "C$14$DECIMAL9$2 decimal(9,2)," - + "C$15$DECIMAL4$2 decimal(4,2)," - + "C$16$DATE date," - + "C$17$TIME time," - + "C$18$TIMESTAMP timestamp," - + "C$19$BOOLEAN boolean," - + "C$20$D_INTEGER_NOT_NULL D_INTEGER_NOT_NULL," - + "C$21$D_INTEGER_WITH_NOT_NULL D_INTEGER NOT NULL) " - + "returns varchar(100) " - + "as " - + "begin" - + " return 'a';" - + "end"; - - private static final String CREATE_PSQL_EXAMPLE_2 = "create function " + PSQL_EXAMPLE_2 + "(" - + "C$01$TIME_WITH_TIME_ZONE time with time zone," - + "C$02$TIMESTAMP_WITH_TIME_ZONE timestamp with time zone," - + "C$03$DECFLOAT decfloat," - + "C$04$DECFLOAT16 decfloat(16)," - + "C$05$DECFLOAT34 decfloat(34)," - + "C$06$NUMERIC21$5 numeric(21,5)," - + "C$07$DECIMAL34$19 decimal(34,19)) " - + "returns varchar(100) not null " - + "as " - + "begin" - + " return 'a';" - + "end"; - - private static final String CREATE_UDF_EXAMPLE_1 = "declare external function " + UDF_EXAMPLE_1 - + "/* 1*/ float by descriptor," - + "/* 2*/ double precision," - + "/* 3*/ char(10)," - + "/* 4*/ varchar(15) by descriptor," - + "/* 5*/ char(20) character set octets," - + "/* 6*/ varchar(25) character set octets," - + "/* 7*/ bigint," - + "/* 8*/ integer," - + "/* 9*/ smallint," - + "/*10*/ numeric(18,3) " - + "returns varchar(100) " - + "entry_point 'UDF$EXAMPLE$1' module_name 'module_1'"; - - private static final String CREATE_UDF_EXAMPLE_2 = "declare external function " + UDF_EXAMPLE_2 - + "/* 1*/ numeric(9,3)," - + "/* 2*/ numeric(4,3)," - + "/* 3*/ decimal(18,2)," - + "/* 4*/ decimal(9,2)," - + "/* 5*/ decimal(4,2)," - + "/* 6*/ date," - + "/* 7*/ time," - + "/* 8*/ timestamp " - + "returns varchar(100) by descriptor " - + "entry_point 'UDF$EXAMPLE$2' module_name 'module_1'"; - - private static final String CREATE_UDF_EXAMPLE_3 = "declare external function " + UDF_EXAMPLE_3 - + " returns cstring(100)" - + "entry_point 'UDF$EXAMPLE$3' module_name 'module_1'"; + private static final String CREATE_PSQL_EXAMPLE_1 = """ + create function PSQL$EXAMPLE$1( + C$01$FLOAT float not null, + C$02$DOUBLE double precision, + C$03$CHAR10 char(10), + C$04$VARCHAR15 varchar(15) not null, + C$05$BINARY20 char(20) character set octets, + C$06$VARBINARY25 varchar(25) character set octets, + C$07$BIGINT bigint, + C$08$INTEGER integer, + C$09$SMALLINT smallint, + C$10$NUMERIC18$3 numeric(18,3), + C$11$NUMERIC9$3 numeric(9,3), + C$12$NUMERIC4$3 numeric(4,3), + C$13$DECIMAL18$2 decimal(18,2), + C$14$DECIMAL9$2 decimal(9,2), + C$15$DECIMAL4$2 decimal(4,2), + C$16$DATE date, + C$17$TIME time, + C$18$TIMESTAMP timestamp, + C$19$BOOLEAN boolean, + C$20$D_INTEGER_NOT_NULL D_INTEGER_NOT_NULL, + C$21$D_INTEGER_WITH_NOT_NULL D_INTEGER NOT NULL) + returns varchar(100) + as + begin + return 'a'; + end"""; + + private static final String CREATE_PSQL_EXAMPLE_2 = """ + create function PSQL$EXAMPLE$2( + C$01$TIME_WITH_TIME_ZONE time with time zone, + C$02$TIMESTAMP_WITH_TIME_ZONE timestamp with time zone, + C$03$DECFLOAT decfloat, + C$04$DECFLOAT16 decfloat(16), + C$05$DECFLOAT34 decfloat(34), + C$06$NUMERIC21$5 numeric(21,5), + C$07$DECIMAL34$19 decimal(34,19)) + returns varchar(100) not null + as + begin + return 'a'; + end"""; + + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + + private static final String CREATE_PSQL_EXAMPLE_3 = """ + create function OTHER_SCHEMA.PSQL$EXAMPLE$3( + C$01$TIME_WITH_TIME_ZONE time with time zone) + returns varchar(100) not null + as + begin + return 'a'; + end"""; + + private static final String CREATE_UDF_EXAMPLE_1 = """ + declare external function UDF$EXAMPLE$1 + /* 1*/ float by descriptor, + /* 2*/ double precision, + /* 3*/ char(10), + /* 4*/ varchar(15) by descriptor, + /* 5*/ char(20) character set octets, + /* 6*/ varchar(25) character set octets, + /* 7*/ bigint, + /* 8*/ integer, + /* 9*/ smallint, + /*10*/ numeric(18,3) + returns varchar(100) + entry_point 'UDF$EXAMPLE$1' module_name 'module_1'"""; + + private static final String CREATE_UDF_EXAMPLE_2 = """ + declare external function UDF$EXAMPLE$2 + /* 1*/ numeric(9,3), + /* 2*/ numeric(4,3), + /* 3*/ decimal(18,2), + /* 4*/ decimal(9,2), + /* 5*/ decimal(4,2), + /* 6*/ date, + /* 7*/ time, + /* 8*/ timestamp + returns varchar(100) by descriptor + entry_point 'UDF$EXAMPLE$2' module_name 'module_1'"""; + + private static final String CREATE_UDF_EXAMPLE_3 = """ + declare external function UDF$EXAMPLE$3 + returns cstring(100) + entry_point 'UDF$EXAMPLE$3' module_name 'module_1'"""; private static final String CREATE_PACKAGE_WITH_FUNCTION = """ create package WITH$FUNCTION @@ -177,7 +195,7 @@ private static List getCreateStatements() { if (supportInfo.supportsPsqlFunctions()) { statements.add(CREATE_PSQL_EXAMPLE_1); - if (supportInfo.isVersionEqualOrAbove(4, 0)) { + if (supportInfo.isVersionEqualOrAbove(4)) { statements.add(CREATE_PSQL_EXAMPLE_2); } @@ -191,6 +209,11 @@ private static List getCreateStatements() { statements.add(CREATE_UDF_EXAMPLE_2); statements.add(CREATE_UDF_EXAMPLE_3); + if (supportInfo.supportsSchemas()) { + statements.add(CREATE_OTHER_SCHEMA); + statements.add(CREATE_PSQL_EXAMPLE_3); + } + return statements; } @@ -204,30 +227,12 @@ void testFunctionColumnMetaDataColumns() throws Exception { } } - @Test - void testFunctionColumnMetaData_everything_functionNamePattern_null() throws Exception { - validateFunctionColumnMetaData_everything(null); - } - - @Test - void testFunctionColumnMetaData_everything_functionNamePattern_allPattern() throws Exception { - validateFunctionColumnMetaData_everything("%"); - } - - private void validateFunctionColumnMetaData_everything(String functionNamePattern) + @ParameterizedTest + @NullSource + @ValueSource(strings = "%") + void testFunctionColumnMetaData_everything_functionNamePattern_all(String functionNamePattern) throws Exception { - FirebirdSupportInfo defaultSupportInfo = getDefaultSupportInfo(); - List> expectedColumns = new ArrayList<>(); - if (defaultSupportInfo.supportsPsqlFunctions()) { - expectedColumns.addAll(getPsqlExample1Columns()); - if (defaultSupportInfo.isVersionEqualOrAbove(4, 0)) { - expectedColumns.addAll(getPsqlExample2Columns()); - } - } - expectedColumns.addAll(getUdfExample1Columns()); - expectedColumns.addAll(getUdfExample2Columns()); - expectedColumns.add(createStringType(Types.VARCHAR, UDF_EXAMPLE_3, "PARAM_0", 0, 100, false)); - validateExpectedFunctionColumns(functionNamePattern, null, expectedColumns); + validateExpectedFunctionColumns(functionNamePattern, null, getAllNonPackagedFunctionColumns()); } @Test @@ -244,6 +249,25 @@ private void validateNoRows(String functionNamePattern, String columnNamePattern validateExpectedFunctionColumns(functionNamePattern, columnNamePattern, Collections.emptyList()); } + @ParameterizedTest + @NullSource + @ValueSource(strings = "%") + void testFunctionColumnMetaData_defaultSchema_functionNamePatternAll(String functionNamePattern) throws Exception { + validateExpectedFunctionColumns("", ifSchemaElse("PUBLIC", ""), functionNamePattern, "%", + getDefaultSchemaAllNonPackagedFunctionColumns()); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = "%") + void testFunctionColumnMetaData_otherSchema_functionNamePatternAll(String functionNamePattern) throws Exception { + // For 4.0 and older, we ignore the schemaPattern, so we need to skip this test, as the query would return + // functions (the "default schema" functions) instead of no functions + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + validateExpectedFunctionColumns("", "OTHER_SCHEMA", functionNamePattern, "%", + getOtherSchemaAllNonPackagedFunctionColumns()); + } + @Test void testFunctionColumnMetaData_PsqlExample1() throws Exception { assumeTrue(getDefaultSupportInfo().supportsPsqlFunctions(), "Requires PSQL function support"); @@ -303,14 +327,8 @@ void testFunctionColumnMetaData_useCatalogAsPackage_everything() throws Exceptio props.setProperty(PropertyNames.useCatalogAsPackage, "true"); try (var connection = DriverManager.getConnection(getUrl(), props)) { dbmd = connection.getMetaData(); - var expectedColumns = new ArrayList<>(getPsqlExample1Columns()); - if (supportInfo.isVersionEqualOrAbove(4)) { - expectedColumns.addAll(getPsqlExample2Columns()); - } - expectedColumns.addAll(getUdfExample1Columns()); - expectedColumns.addAll(getUdfExample2Columns()); - expectedColumns.add(createStringType(Types.VARCHAR, UDF_EXAMPLE_3, "PARAM_0", 0, 100, false)); - withCatalog("", expectedColumns); + List> expectedColumns = + withCatalog("", getAllNonPackagedFunctionColumns()); expectedColumns.addAll(getWithFunctionInPackageColumns()); validateExpectedFunctionColumns(null, null, null, expectedColumns); } @@ -370,14 +388,8 @@ void testFunctionColumnMetaData_useCatalogAsPackage_nonPackagedOnly() throws Exc props.setProperty(PropertyNames.useCatalogAsPackage, "true"); try (var connection = DriverManager.getConnection(getUrl(), props)) { dbmd = connection.getMetaData(); - var expectedColumns = new ArrayList<>(getPsqlExample1Columns()); - if (supportInfo.isVersionEqualOrAbove(4)) { - expectedColumns.addAll(getPsqlExample2Columns()); - } - expectedColumns.addAll(getUdfExample1Columns()); - expectedColumns.addAll(getUdfExample2Columns()); - expectedColumns.add(createStringType(Types.VARCHAR, UDF_EXAMPLE_3, "PARAM_0", 0, 100, false)); - withCatalog("", expectedColumns); + List> expectedColumns = + withCatalog("", getAllNonPackagedFunctionColumns()); validateExpectedFunctionColumns("", null, null, expectedColumns); } } @@ -418,6 +430,38 @@ private static void expectNoMoreRows(ResultSet rs) throws SQLException { } } + private static List> getAllNonPackagedFunctionColumns() { + return CollectionUtils.concat( + getOtherSchemaAllNonPackagedFunctionColumns(), getDefaultSchemaAllNonPackagedFunctionColumns()); + } + + /** + * NOTE: returns an empty list when schemas are not supported. + */ + private static List> getOtherSchemaAllNonPackagedFunctionColumns() { + FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); + List> expectedColumns = new ArrayList<>(); + if (supportInfo.supportsSchemas()) { + expectedColumns.addAll(getPsqlExample3Columns()); + } + return expectedColumns; + } + + private static List> getDefaultSchemaAllNonPackagedFunctionColumns() { + FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); + List> expectedColumns = new ArrayList<>(); + if (supportInfo.supportsPsqlFunctions()) { + expectedColumns.addAll(getPsqlExample1Columns()); + if (supportInfo.isVersionEqualOrAbove(4)) { + expectedColumns.addAll(getPsqlExample2Columns()); + } + } + expectedColumns.addAll(getUdfExample1Columns()); + expectedColumns.addAll(getUdfExample2Columns()); + expectedColumns.add(createStringType(Types.VARCHAR, UDF_EXAMPLE_3, "PARAM_0", 0, 100, false)); + return expectedColumns; + } + private static List> getPsqlExample1Columns() { return List.of( withColumnTypeFunctionReturn(createStringType(Types.VARCHAR, PSQL_EXAMPLE_1, "PARAM_0", 0, 100, true)), @@ -456,6 +500,12 @@ private static List> getPsqlExample2Columns( createNumericalType(Types.DECIMAL, PSQL_EXAMPLE_2, "C$07$DECIMAL34$19", 7, 34, 19, true)); } + private static List> getPsqlExample3Columns() { + return withSchema("OTHER_SCHEMA", List.of( + withColumnTypeFunctionReturn(createStringType(Types.VARCHAR, PSQL_EXAMPLE_3, "PARAM_0", 0, 100, false)), + createDateTime(Types.TIME_WITH_TIMEZONE, PSQL_EXAMPLE_3, "C$01$TIME_WITH_TIME_ZONE", 1, true))); + } + private static List> getUdfExample1Columns() { return List.of( withColumnTypeFunctionReturn(createStringType(Types.VARCHAR, UDF_EXAMPLE_1, "PARAM_0", 0, 100, false)), @@ -515,12 +565,24 @@ private static List> withSpecificName(String return rules; } + @SuppressWarnings("SameParameterValue") + private static List> withSchema(String schema, + List> rules) { + for (Map rowRule : rules) { + String functionName = (String) rowRule.get(FunctionColumnMetaData.FUNCTION_NAME); + rowRule.put(FunctionColumnMetaData.SPECIFIC_NAME, + new QualifiedName(schema, functionName).toString(QuoteStrategy.DIALECT_3)); + rowRule.put(FunctionColumnMetaData.FUNCTION_SCHEM, schema); + } + return rules; + } + private static Map createColumn(String functionName, String columnName, int ordinalPosition, boolean nullable) { Map rules = getDefaultValidationRules(); rules.put(FunctionColumnMetaData.FUNCTION_NAME, functionName); rules.put(FunctionColumnMetaData.SPECIFIC_NAME, ifSchemaElse( - "\"PUBLIC\"." + QuoteStrategy.DIALECT_3.quoteObjectName(functionName), functionName)); + new QualifiedName("PUBLIC", functionName).toString(QuoteStrategy.DIALECT_3), functionName)); rules.put(FunctionColumnMetaData.COLUMN_NAME, columnName); rules.put(FunctionColumnMetaData.ORDINAL_POSITION, ordinalPosition); if (nullable) { diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java index 296cd3b42..cafe2fa7e 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java @@ -8,10 +8,12 @@ import org.junit.jupiter.params.provider.MethodSource; import java.sql.ResultSet; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.stream.Stream; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; /** @@ -21,8 +23,6 @@ */ class FBDatabaseMetaDataImportedKeysTest extends FBDatabaseMetaDataAbstractKeysTest { - // TODO Add schema support: tests involving other schema - @Test void testExportedKeysMetaDataColumns() throws Exception { try (ResultSet importedKeys = dbmd.getImportedKeys(null, null, "doesnotexit")) { @@ -30,7 +30,7 @@ void testExportedKeysMetaDataColumns() throws Exception { } } - @ParameterizedTest + @ParameterizedTest(name = "({0}, {1})") @MethodSource void testImportedKeys(String schema, String table, List> expectedKeys) throws Exception { try (ResultSet importedKeys = dbmd.getImportedKeys(null, schema, table)) { @@ -39,15 +39,23 @@ void testImportedKeys(String schema, String table, List testImportedKeys() { - return Stream.of( + var generalArguments = Stream.of( importedKeysTestCase("TABLE_1", table1Fks()), + importedKeysTestCase(null, "TABLE_1", table1Fks()), importedKeysTestCase("doesnotexist", List.of()), importedKeysTestCase("TABLE_2", table2Fks()), + importedKeysTestCase(null, "TABLE_2", table2Fks()), importedKeysTestCase("TABLE_3", table3Fks()), importedKeysTestCase("TABLE_4", table4Fks()), importedKeysTestCase("TABLE_5", table5Fks()), importedKeysTestCase("TABLE_6", table6Fks()), - importedKeysTestCase("TABLE_7", table7Fks())); + importedKeysTestCase("TABLE_7", ifSchemaElse(table7to8Fks(), List.of()), table7to6Fks())); + if (!getDefaultSupportInfo().supportsSchemas()) { + return generalArguments; + } + return Stream.concat(generalArguments, Stream.of( + importedKeysTestCase("OTHER_SCHEMA", "TABLE_8", table8Fks()), + importedKeysTestCase(null, "TABLE_8", table8Fks()))); } private static Arguments importedKeysTestCase(String table, List> expectedKeys) { @@ -59,4 +67,17 @@ private static Arguments importedKeysTestCase(String schema, String table, return Arguments.of(schema, table, expectedKeys); } + @SuppressWarnings("SameParameterValue") + @SafeVarargs + private static Arguments importedKeysTestCase(String table, List>... expectedKeys) { + return importedKeysTestCase(ifSchemaElse("PUBLIC", ""), table, expectedKeys); + } + + @SafeVarargs + private static Arguments importedKeysTestCase(String schema, String table, + List>... expectedKeys) { + var combinedExpectedKeys = Stream.of(expectedKeys).flatMap(Collection::stream).toList(); + return importedKeysTestCase(schema, table, combinedExpectedKeys); + } + } From 3662432c5455a513124e9e4bca45ce01cb7652bc Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 17 Jul 2025 13:10:43 +0200 Subject: [PATCH 37/64] #882 Improve schema test coverage (getFunctions) --- .../jdbc/FBDatabaseMetaDataFunctionsTest.java | 281 ++++++++++++------ 1 file changed, 193 insertions(+), 88 deletions(-) diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java index ccd821777..5a05d2d70 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java @@ -4,26 +4,31 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.jaybird.props.PropertyNames; +import org.firebirdsql.jaybird.util.QualifiedName; import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; import java.sql.Connection; import java.sql.DatabaseMetaData; -import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Set; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; -import static org.firebirdsql.common.FBTestProperties.getDefaultPropertiesForConnection; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; -import static org.firebirdsql.common.FBTestProperties.getUrl; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -37,8 +42,6 @@ */ class FBDatabaseMetaDataFunctionsTest { - // TODO Add schema support: tests involving other schema - private static final String CREATE_UDF_EXAMPLE = """ declare external function UDF$EXAMPLE int by descriptor, int by descriptor @@ -55,6 +58,21 @@ class FBDatabaseMetaDataFunctionsTest { return X+1; end"""; + // Same name, different schema as CREATE_PSQL_EXAMPLE + private static final String CREATE_OTHER_SCHEMA_PSQL_EXAMPLE = """ + create function OTHER_SCHEMA.PSQL$EXAMPLE(X double precision) returns varchar(50) + as + begin + return cast(x as varchar(50)); + end"""; + + private static final String CREATE_OTHER_SCHEMA_PSQL_EXAMPLE2 = """ + create function OTHER_SCHEMA.PSQL$EXAMPLE2(X int) returns int + as + begin + return X+1; + end"""; + private static final String ADD_COMMENT_ON_PSQL_EXAMPLE = "comment on function PSQL$EXAMPLE is 'Comment on PSQL$EXAMPLE'"; @@ -76,6 +94,8 @@ class FBDatabaseMetaDataFunctionsTest { end end"""; + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + @RegisterExtension static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( getCreateStatements()); @@ -84,12 +104,19 @@ class FBDatabaseMetaDataFunctionsTest { new MetadataResultSetDefinition(FunctionMetaData.class); private static Connection con; - private static DatabaseMetaData dbmd; + private static DatabaseMetaData originalDbmd; + // may get replaced during a test + private DatabaseMetaData dbmd; @BeforeAll static void setupAll() throws SQLException { con = getConnectionViaDriverManager(); - dbmd = con.getMetaData(); + originalDbmd = con.getMetaData(); + } + + @BeforeEach + void setup() { + dbmd = originalDbmd; } @AfterAll @@ -98,7 +125,7 @@ static void tearDownAll() throws SQLException { con.close(); } finally { con = null; - dbmd = null; + originalDbmd = null; } } @@ -120,6 +147,12 @@ private static List getCreateStatements() { statements.add(CREATE_PACKAGE_BODY_WITH_FUNCTION); } } + if (supportInfo.supportsSchemas()) { + statements.add(CREATE_OTHER_SCHEMA); + statements.add(CREATE_OTHER_SCHEMA_PSQL_EXAMPLE); + statements.add(CREATE_OTHER_SCHEMA_PSQL_EXAMPLE2); + } + // TODO See if we can add a UDR example as well. return statements; } @@ -135,40 +168,63 @@ void testFunctionMetaDataColumns() throws Exception { } @ParameterizedTest - @ValueSource(strings = "%") - @NullSource - void testFunctionMetadata_everything_functionNamePattern(String functionNamePattern) throws Exception { - try (ResultSet functions = dbmd.getFunctions(null, null, functionNamePattern)) { - if (getDefaultSupportInfo().supportsPsqlFunctions()) { - expectNextFunction(functions); - validatePsqlExample(functions); - } - - // Verify UDF$EXAMPLE - expectNextFunction(functions); - validateUdfExample(functions); + @CsvSource(useHeadersInDisplayName = true, nullValues = { "" }, textBlock = """ + schemaPattern, functionNamePattern + , + , % + %, + %, % + """) + void testFunctionMetadata_everything_functionNamePattern(String schemaPattern, String functionNamePattern) + throws Exception { + validateExpectedFunctions(null, schemaPattern, functionNamePattern, getAllFunctionsNonPackaged()); + } - expectNoMoreRows(functions); - } + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = { "" }, textBlock = """ + schemaPattern, functionNamePattern + OTHER_SCHEMA, + OTHER_SCHEMA, % + OTHER_SCHEMA, PSQL$% + OTHER_%, + OTHER_%, % + OTHER_%, PSQL$% + """) + void testFunctionMetadata_everything_ofOtherSchema(String schemaPattern, String functionNamePattern) + throws Exception { + var expectedFunctions = List.of(getOtherSchemaPsqlExample(), getOtherSchemaPsqlExample2()); + validateExpectedFunctions(null, schemaPattern, functionNamePattern, expectedFunctions); } @Test void testFunctionMetaData_udfExampleOnly() throws Exception { - try (ResultSet functions = dbmd.getFunctions(null, null, "UDF$EXAMPLE")) { - assertTrue(functions.next(), "Expected a row"); - validateUdfExample(functions); - assertFalse(functions.next(), "Expected no more rows"); - } + validateExpectedFunctions(null, null, "UDF$EXAMPLE", List.of(getUdfExample())); } @Test - void testFunctionMetaData_psqlExampleOnly() throws Exception { + void testFunctionMetaData_defaultSchema_psqlExampleOnly() throws Exception { assumeTrue(getDefaultSupportInfo().supportsPsqlFunctions(), "Requires PSQL function support"); - try (ResultSet functions = dbmd.getFunctions(null, null, "PSQL$EXAMPLE")) { - assertTrue(functions.next(), "Expected a row"); - validatePsqlExample(functions); - assertFalse(functions.next(), "Expected no more rows"); + validateExpectedFunctions(null, ifSchemaElse("PUBLIC", ""), "PSQL$EXAMPLE", List.of(getPsqlExample())); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = "%") + void testFunctionMetaData_allSchema_psqlExampleOnly(String schemaPattern) throws Exception { + FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); + assumeTrue(supportInfo.supportsPsqlFunctions(), "Requires PSQL function support"); + var expectedFunctions = new ArrayList>(); + if (supportInfo.supportsSchemas()) { + expectedFunctions.add(getOtherSchemaPsqlExample()); } + expectedFunctions.add(getPsqlExample()); + validateExpectedFunctions(null, schemaPattern, "PSQL$EXAMPLE", expectedFunctions); + } + + @Test + void testFunctionMetaData_otherSchema_psqlExampleOnly() throws Exception { + assumeTrue(getDefaultSupportInfo().supportsSchemas(), "Requires schema support"); + validateExpectedFunctions(null, "OTHER_SCHEMA", "PSQL$EXAMPLE", List.of(getOtherSchemaPsqlExample())); } @Test @@ -191,37 +247,21 @@ void testFunctionMetaData_emptyString_noResults() throws Exception { @Test void testFunctionMetadata_useCatalogAsPackage_everything() throws Exception { assumeTrue(getDefaultSupportInfo().supportsPackages(), "Test requires package support"); - Properties props = getDefaultPropertiesForConnection(); - props.setProperty(PropertyNames.useCatalogAsPackage, "true"); - try (var connection = DriverManager.getConnection(getUrl(), props); - var functions = connection.getMetaData().getFunctions(null, null, "%")) { - expectNextFunction(functions); - validatePsqlExample(functions, true); - - // Verify UDF$EXAMPLE - expectNextFunction(functions); - validateUdfExample(functions, true); - - // Verify packaged function WITH$FUNCTION.IN$PACKAGE - expectNextFunction(functions); - validatePackageFunctionExample(functions); + List> expectedFunctions = getAllFunctionsNonPackaged(true); + expectedFunctions.add(getPackageFunctionExample()); - expectNoMoreRows(functions); + try (var connection = getConnectionViaDriverManager(PropertyNames.useCatalogAsPackage, "true")) { + dbmd = connection.getMetaData(); + validateExpectedFunctions(null, null, "%", expectedFunctions); } } @Test void testFunctionMetadata_useCatalogAsPackage_specificPackage() throws Exception { assumeTrue(getDefaultSupportInfo().supportsPackages(), "Test requires package support"); - Properties props = getDefaultPropertiesForConnection(); - props.setProperty(PropertyNames.useCatalogAsPackage, "true"); - try (var connection = DriverManager.getConnection(getUrl(), props); - var functions = connection.getMetaData().getFunctions("WITH$FUNCTION", null, "%")) { - // Verify packaged function WITH$FUNCTION.IN$PACKAGE - expectNextFunction(functions); - validatePackageFunctionExample(functions); - - expectNoMoreRows(functions); + try (var connection = getConnectionViaDriverManager(PropertyNames.useCatalogAsPackage, "true")) { + dbmd = connection.getMetaData(); + validateExpectedFunctions("WITH$FUNCTION", null, "%", List.of(getPackageFunctionExample())); } } @@ -230,34 +270,35 @@ void testFunctionMetadata_useCatalogAsPackage_specificPackage() throws Exception @ValueSource(strings = "WITH$FUNCTION") void testFunctionMetadata_useCatalogAsPackage_specificPackageFunction(String catalog) throws Exception { assumeTrue(getDefaultSupportInfo().supportsPackages(), "Test requires package support"); - Properties props = getDefaultPropertiesForConnection(); - props.setProperty(PropertyNames.useCatalogAsPackage, "true"); - try (var connection = DriverManager.getConnection(getUrl(), props); - var functions = connection.getMetaData().getFunctions(catalog, null, "IN$PACKAGE")) { - // Verify packaged function WITH$FUNCTION.IN$PACKAGE - expectNextFunction(functions); - validatePackageFunctionExample(functions); - - expectNoMoreRows(functions); + try (var connection = getConnectionViaDriverManager(PropertyNames.useCatalogAsPackage, "true")) { + dbmd = connection.getMetaData(); + validateExpectedFunctions(catalog, null, "IN$PACKAGE", List.of(getPackageFunctionExample())); } } @Test void testFunctionMetadata_useCatalogAsPackage_nonPackagedOnly() throws Exception { assumeTrue(getDefaultSupportInfo().supportsPackages(), "Test requires package support"); - Properties props = getDefaultPropertiesForConnection(); - props.setProperty(PropertyNames.useCatalogAsPackage, "true"); - try (var connection = DriverManager.getConnection(getUrl(), props); - var functions = connection.getMetaData().getFunctions("", null, "%")) { - expectNextFunction(functions); - validatePsqlExample(functions, true); + try (var connection = getConnectionViaDriverManager(PropertyNames.useCatalogAsPackage, "true")) { + dbmd = connection.getMetaData(); + validateExpectedFunctions("", null, "%", getAllFunctionsNonPackaged(true)); + } + } - // Verify UDF$EXAMPLE - expectNextFunction(functions); - validateUdfExample(functions, true); + private void validateExpectedFunctions(String catalog, String schemaPattern, String functionNamePattern, + List> expectedColumns) throws Exception { + try (ResultSet functions = dbmd.getFunctions(catalog, schemaPattern, functionNamePattern)) { + validateFunctions(functions, expectedColumns); + } + } - expectNoMoreRows(functions); + private static void validateFunctions(ResultSet functions, + List> expectedColumns) throws SQLException { + for (Map expectedColumn : expectedColumns) { + expectNextFunction(functions); + getFunctionsDefinition.validateRowValues(functions, expectedColumn); } + expectNoMoreRows(functions); } private void validateNoRows(String functionNamePattern) throws Exception { @@ -266,18 +307,39 @@ private void validateNoRows(String functionNamePattern) throws Exception { } } - private void validatePsqlExample(ResultSet functions) throws SQLException { - validatePsqlExample(functions, false); + private static List> getAllFunctionsNonPackaged() { + return getAllFunctionsNonPackaged(false); + } + + private static List> getAllFunctionsNonPackaged(boolean useCatalogAsPackage) { + FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); + var expectedFunctions = new ArrayList>(); + if (supportInfo.supportsSchemas()) { + expectedFunctions.add(getOtherSchemaPsqlExample(useCatalogAsPackage)); + expectedFunctions.add(getOtherSchemaPsqlExample2(useCatalogAsPackage)); + } + if (supportInfo.supportsPsqlFunctions()) { + expectedFunctions.add(getPsqlExample(useCatalogAsPackage)); + } + expectedFunctions.add(getUdfExample(useCatalogAsPackage)); + return expectedFunctions; + } + + + + private static Map getPsqlExample() { + return getPsqlExample(false); } - private void validatePsqlExample(ResultSet functions, boolean useCatalogAsPackage) throws SQLException { + private static Map getPsqlExample(boolean useCatalogAsPackage) { final boolean supportsComments = getDefaultSupportInfo().supportsComment(); Map rules = getDefaultValidationRules(); if (useCatalogAsPackage) { rules.put(FunctionMetaData.FUNCTION_CAT, ""); } rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse("\"PUBLIC\".\"PSQL$EXAMPLE\"", "PSQL$EXAMPLE")); + rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse( + new QualifiedName("PUBLIC", "PSQL$EXAMPLE").toString(QuoteStrategy.DIALECT_3), "PSQL$EXAMPLE")); if (supportsComments) { rules.put(FunctionMetaData.REMARKS, "Comment on PSQL$EXAMPLE"); } @@ -286,32 +348,74 @@ private void validatePsqlExample(ResultSet functions, boolean useCatalogAsPackag return X+1; end"""); rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); + return rules; + } - getFunctionsDefinition.validateRowValues(functions, rules); + private static Map getOtherSchemaPsqlExample() { + return getOtherSchemaPsqlExample(false); } - private void validateUdfExample(ResultSet functions) throws SQLException { - validateUdfExample(functions, false); + private static Map getOtherSchemaPsqlExample(boolean useCatalogAsPackage) { + Map rules = getDefaultValidationRules(); + if (useCatalogAsPackage) { + rules.put(FunctionMetaData.FUNCTION_CAT, ""); + } + rules.put(FunctionMetaData.FUNCTION_SCHEM, "OTHER_SCHEMA"); + rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE"); + rules.put(FunctionMetaData.SPECIFIC_NAME, + new QualifiedName("OTHER_SCHEMA", "PSQL$EXAMPLE").toString(QuoteStrategy.DIALECT_3)); + rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, """ + begin + return cast(x as varchar(50)); + end"""); + rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); + return rules; } - private void validateUdfExample(ResultSet functions, boolean useCatalogAsPackage) throws SQLException { + private static Map getOtherSchemaPsqlExample2() { + return getOtherSchemaPsqlExample2(false); + } + + private static Map getOtherSchemaPsqlExample2(boolean useCatalogAsPackage) { + Map rules = getDefaultValidationRules(); + if (useCatalogAsPackage) { + rules.put(FunctionMetaData.FUNCTION_CAT, ""); + } + rules.put(FunctionMetaData.FUNCTION_SCHEM, "OTHER_SCHEMA"); + rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE2"); + rules.put(FunctionMetaData.SPECIFIC_NAME, + new QualifiedName("OTHER_SCHEMA", "PSQL$EXAMPLE2").toString(QuoteStrategy.DIALECT_3)); + rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, """ + begin + return X+1; + end"""); + rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); + return rules; + } + + private static Map getUdfExample() { + return getUdfExample(false); + } + + private static Map getUdfExample(boolean useCatalogAsPackage) { final boolean supportsComments = getDefaultSupportInfo().supportsComment(); Map rules = getDefaultValidationRules(); if (useCatalogAsPackage) { rules.put(FunctionMetaData.FUNCTION_CAT, ""); } rules.put(FunctionMetaData.FUNCTION_NAME, "UDF$EXAMPLE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse("\"PUBLIC\".\"UDF$EXAMPLE\"", "UDF$EXAMPLE")); + rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse( + new QualifiedName("PUBLIC", "UDF$EXAMPLE").toString(QuoteStrategy.DIALECT_3), "UDF$EXAMPLE")); if (supportsComments) { rules.put(FunctionMetaData.REMARKS, "Comment on UDF$EXAMPLE"); } rules.put(FunctionMetaData.JB_FUNCTION_KIND, "UDF"); rules.put(FunctionMetaData.JB_MODULE_NAME, "fbudf"); rules.put(FunctionMetaData.JB_ENTRYPOINT, "idNvl"); - getFunctionsDefinition.validateRowValues(functions, rules); + return rules; } - private void validatePackageFunctionExample(ResultSet functions) throws SQLException { + private static Map getPackageFunctionExample() { Map rules = getDefaultValidationRules(); rules.put(FunctionMetaData.FUNCTION_CAT, "WITH$FUNCTION"); rules.put(FunctionMetaData.FUNCTION_NAME, "IN$PACKAGE"); @@ -319,7 +423,8 @@ private void validatePackageFunctionExample(ResultSet functions) throws SQLExcep // Stored with package rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, null); rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); - getFunctionsDefinition.validateRowValues(functions, rules); + + return rules; } private static void expectNextFunction(ResultSet rs) throws SQLException { From b83f2758475816747c152404006d923777f6b471 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Fri, 18 Jul 2025 16:58:10 +0200 Subject: [PATCH 38/64] Replace QualifiedName with ObjectReference --- .../jaybird/util/CollectionUtils.java | 27 +++ .../firebirdsql/jaybird/util/Identifier.java | 110 ++++++++++ .../jaybird/util/IdentifierChain.java | 88 ++++++++ .../jaybird/util/ObjectReference.java | 202 ++++++++++++++++++ .../jaybird/util/QualifiedName.java | 122 ----------- .../firebirdsql/jaybird/util/StringUtils.java | 16 +- .../org/firebirdsql/jdbc/FBRowUpdater.java | 38 ++-- .../jaybird/util/CollectionUtilsTest.java | 22 +- .../jaybird/util/IdentifierChainTest.java | 169 +++++++++++++++ .../jaybird/util/IdentifierTest.java | 150 +++++++++++++ .../jaybird/util/ObjectReferenceTest.java | 170 +++++++++++++++ .../jaybird/util/QualifiedNameTest.java | 103 --------- .../jaybird/util/StringUtilsTest.java | 23 +- ...FBDatabaseMetaDataFunctionColumnsTest.java | 13 +- .../jdbc/FBDatabaseMetaDataFunctionsTest.java | 15 +- 15 files changed, 1009 insertions(+), 259 deletions(-) create mode 100644 src/main/org/firebirdsql/jaybird/util/Identifier.java create mode 100644 src/main/org/firebirdsql/jaybird/util/IdentifierChain.java create mode 100644 src/main/org/firebirdsql/jaybird/util/ObjectReference.java delete mode 100644 src/main/org/firebirdsql/jaybird/util/QualifiedName.java create mode 100644 src/test/org/firebirdsql/jaybird/util/IdentifierChainTest.java create mode 100644 src/test/org/firebirdsql/jaybird/util/IdentifierTest.java create mode 100644 src/test/org/firebirdsql/jaybird/util/ObjectReferenceTest.java delete mode 100644 src/test/org/firebirdsql/jaybird/util/QualifiedNameTest.java diff --git a/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java b/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java index 45db08795..e45a1ef48 100644 --- a/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java +++ b/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Vector; +import java.util.stream.Stream; /** * Helper class for collections @@ -87,4 +88,30 @@ public static List concat(List list1, List list2) { return newList; } + /** + * Concatenates two or more lists to a new modifiable list. + *

+ * If there are no lists in {@code otherLists}, it will return a new list, with the contents of {@code list1}. + *

+ * + * @param list1 + * list 1 + * @param otherLists + * other lists + * @param + * type parameter of {@code list1}, and parent type parameter of lists in {@code otherLists} + * @return concatenation of {@code list1} and {@code otherLists} + * @see #concat(List, List) + */ + @SafeVarargs + public static List concat(List list1, List... otherLists) { + int listsSize = list1.size() + Stream.of(otherLists).mapToInt(List::size).sum(); + var newList = new ArrayList(listsSize); + newList.addAll(list1); + for (var list : otherLists) { + newList.addAll(list); + } + return newList; + } + } diff --git a/src/main/org/firebirdsql/jaybird/util/Identifier.java b/src/main/org/firebirdsql/jaybird/util/Identifier.java new file mode 100644 index 000000000..16b120635 --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/util/Identifier.java @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.jdbc.QuoteStrategy; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.stream.Stream; + +import static org.firebirdsql.jaybird.util.StringUtils.trimToNull; + +/** + * An identifier is an object reference consisting of a single name. + * + * @since 7 + * @see ObjectReference + */ +public final class Identifier extends ObjectReference { + + private final String name; + + public Identifier(String name) { + name = trimToNull(name); + if (name == null) { + throw new IllegalArgumentException("name cannot be null, empty, or blank"); + } + this.name = name; + } + + public String name() { + return name; + } + + @Override + public int size() { + return 1; + } + + @Override + public Identifier at(int index) { + if (index != 0) { + throw new IndexOutOfBoundsException(index); + } + return this; + } + + @Override + public Identifier first() { + return this; + } + + @Override + public Identifier last() { + return this; + } + + /** + * The name, quoted using {@code quoteStrategy}. + * + * @param quoteStrategy + * quote strategy + * @return name, possibly quoted + */ + public String toString(QuoteStrategy quoteStrategy) { + return quoteStrategy.quoteObjectName(name); + } + + /** + * Appends name to {@code sb} using {@code quoteStrategy}. + * + * @param sb + * string builder to append to + * @param quoteStrategy + * quote strategy + * @return {@code sb} for chaining + */ + public StringBuilder append(StringBuilder sb, QuoteStrategy quoteStrategy) { + return quoteStrategy.appendQuoted(name, sb); + } + + @Override + public Stream stream() { + return Stream.of(this); + } + + @Override + public List toList() { + return List.of(this); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj instanceof Identifier other) { + return name.equals(other.name); + } else if (obj instanceof ObjectReference otherRef) { + // We're using ObjectReference, not IdentifierChain, so it'll also work for future subclasses, if any + return otherRef.size() == 1 && name.equals(otherRef.at(0).name); + } + return false; + } + + @Override + public int hashCode() { + // This needs to be consistent with IdentifierChain.hashCode (as if this is a chain with a single item) + // We're clearing the sign bit, because that is what IdentifierChain does to avoid negative values + return (31 + name.hashCode()) & 0x7FFF_FFFF; + } + +} diff --git a/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java b/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java new file mode 100644 index 000000000..f4f0f5bc8 --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.jdbc.QuoteStrategy; + +import java.util.List; +import java.util.stream.Stream; + +/** + * An identifier chain is an object reference consisting of one or more identifiers. + *

+ * In practice, we'll use {@link Identifier} if there is only one identifier. + *

+ *

+ * The recommended way to create this object is through {@link ObjectReference#of(String...)} or + * {@link ObjectReference#of(List)}. + *

+ * + * @since 7 + * @see ObjectReference + */ +final class IdentifierChain extends ObjectReference { + + private final List identifiers; + // cached hashcode, -1 signals not yet cached + private int hashCode = -1; + + IdentifierChain(List identifiers) { + if (identifiers.isEmpty()) { + throw new IllegalArgumentException("identifier chain cannot be empty"); + } + this.identifiers = List.copyOf(identifiers); + } + + @Override + public int size() { + return identifiers.size(); + } + + @Override + public Identifier at(int index) { + return identifiers.get(index); + } + + @Override + public String toString(QuoteStrategy quoteStrategy) { + // Estimate 16 characters per element (including quotes and separator) + return append(new StringBuilder(size() * 16), quoteStrategy).toString(); + } + + @Override + public StringBuilder append(StringBuilder sb, QuoteStrategy quoteStrategy) { + for (Identifier identifier : identifiers) { + identifier.append(sb, quoteStrategy).append('.'); + } + // Remove last dot separator + sb.setLength(sb.length() - 1); + return sb; + } + + @Override + public Stream stream() { + return identifiers.stream(); + } + + @Override + public List toList() { + return identifiers; + } + + @Override + public int hashCode() { + int hashCode = this.hashCode; + return hashCode != -1 ? hashCode : hashCode0(); + } + + private int hashCode0() { + // This needs to be consistent with Identifier.hashCode for an instance with a single Identifier + int hashCode = 1; + for (Identifier identifier : identifiers) { + hashCode = 31 * hashCode + identifier.name().hashCode(); + } + // Clear sign bit to avoid -1 (and any other negative value) + return this.hashCode = hashCode & 0x7FFF_FFFF; + } + +} diff --git a/src/main/org/firebirdsql/jaybird/util/ObjectReference.java b/src/main/org/firebirdsql/jaybird/util/ObjectReference.java new file mode 100644 index 000000000..eb2e1d868 --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/util/ObjectReference.java @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.gds.ng.fields.FieldDescriptor; +import org.firebirdsql.jdbc.QuoteStrategy; +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * An object reference is a — possibly ambiguous — identification of an object, like a {@code column}, + * {@code table}, {@code table.column}, {@code schema.table.column}, {@code alias.column}, etc. + *

+ * An object reference consists of one or more identifiers. If it has a single identifier, it is + * an {@link Identifier}, otherwise it is an identifier chain. + *

+ * + * @since 7 + */ +public sealed abstract class ObjectReference permits Identifier, IdentifierChain { + + /** + * Creates an object reference ({@link Identifier}) from {@code name}. + * + * @param name + * name (cannot be {@code null}, empty, or blank) + * @return identifier + * @throws IllegalArgumentException + * if {@code name} is {@code null}, empty, or blank + */ + public static Identifier of(String name) { + return new Identifier(name); + } + + /** + * Creates an object reference (a single {@link Identifier} or an identifier chain) from {@code names}. + *

+ * The prefix of the {@code names} may be {@code null} or empty strings, these are ignored and excluded from + * the final object reference, as long as there is at least one non-blank name remaining (the suffix) + *

+ * + * @param names + * one or more names + * @return an object reference + * @throws IllegalArgumentException + * if {@code names} is empty, all names are {@code null} or empty, or at least one name in the suffix is + * blank or null + * @see #of(List) + */ + public static ObjectReference of(@Nullable String... names) { + return of(Arrays.asList(names)); + } + + /** + * Creates an object reference (a single {@link Identifier} or an identifier chain) from {@code names}. + * + * @param names + * one or more names + * @return an object reference + * @throws IllegalArgumentException + * if {@code names} is empty, all names are {@code null} or empty, or at least one name in the suffix is + * blank or null + */ + public static ObjectReference of(List<@Nullable String> names) { + //noinspection DataFlowIssue : Identifier(String) is @NonNull, and produce an IllegalArgumentException for null + List nameList = names.stream().dropWhile(StringUtils::isNullOrEmpty).map(Identifier::new).toList(); + if (nameList.size() == 1) { + return nameList.get(0); + } + return new IdentifierChain(nameList); + } + + /** + * Creates an object reference of the original table in {@code fieldDescriptor} (from {@code originalSchema} and + * {@code originalTableName}). + * + * @param fieldDescriptor + * field descriptor + * @return a possibly schema-qualified name of the original table from {@code fieldDescriptor} or empty if its + * {@code originalTableName} is empty string or {@code null} + */ + public static Optional ofTable(FieldDescriptor fieldDescriptor) { + String tableName = fieldDescriptor.getOriginalTableName(); + if (StringUtils.isNullOrEmpty(tableName)) { + return Optional.empty(); + } + // NOTE: This will produce an exception if tableName is blank and not empty, we accept that as that shouldn't + // happen in normal use + return Optional.of( + ObjectReference.of(fieldDescriptor.getOriginalSchema(), fieldDescriptor.getOriginalTableName())); + } + + /** + * @return number of identifiers in this object reference ({@code >= 1}) + */ + public abstract int size(); + + /** + * Gets identifier at 0-based position {@code index}. + * + * @param index + * index of the identifier + * @return the identifier + * @throws IndexOutOfBoundsException + * if {@code index < 0 || index > size()} + */ + public abstract Identifier at(int index); + + public Identifier first() { + return at(0); + } + + public Identifier last() { + return at(size() - 1); + } + + /** + * The name(s), quoted using {@code quoteStrategy}. + * + * @param quoteStrategy + * quote strategy + * @return name, possibly quoted + */ + public abstract String toString(QuoteStrategy quoteStrategy); + + /** + * Quoted name(s). + * + * @return quoted name(s) equivalent of {@link #toString(QuoteStrategy)} with {@link QuoteStrategy#DIALECT_3} + */ + @Override + public final String toString() { + return toString(QuoteStrategy.DIALECT_3); + } + + /** + * Appends name(s) to {@code sb} using {@code quoteStrategy}. + * + * @param sb + * string builder to append to + * @param quoteStrategy + * quote strategy + * @return {@code sb} for chaining + */ + public abstract StringBuilder append(StringBuilder sb, QuoteStrategy quoteStrategy); + + /** + * @return stream of identifiers in this object reference + */ + public abstract Stream stream(); + + /** + * @return list of identifiers in this object reference + */ + public abstract List toList(); + + /** + * Resolves the given object reference against this object reference. + *

+ * For example, if this object reference is {@code schema.table}, and {@code other} is {@code column}, then the + * result is {@code schema.table.column}. + *

+ *

+ * Or in other words, this method concatenates {@code this} and {@code other} and returns it as a new object + * reference. + *

+ * + * @param other + * other object reference + * @return new object reference (an identifier chain) + */ + public ObjectReference resolve(ObjectReference other) { + return new IdentifierChain(CollectionUtils.concat(toList(), other.toList())); + } + + /** + * {@inheritDoc} + *

+ * Implementation note: Subclasses need to ensure consistency of equals for logically equivalent references in + * different types (e.g. an Identifier and an IdentifierChain with a single Identifier). + *

+ */ + @Override + public boolean equals(@Nullable Object obj) { + return obj instanceof ObjectReference other && size() == other.size() && toList().equals(other.toList()); + } + + /** + * {@inheritDoc} + *

+ * Implementation note: Subclasses need to ensure consistency of hashCode for logically equivalent references in + * different types (e.g. an Identifier and an IdentifierChain with a single Identifier). + *

+ */ + @Override + public abstract int hashCode(); + +} \ No newline at end of file diff --git a/src/main/org/firebirdsql/jaybird/util/QualifiedName.java b/src/main/org/firebirdsql/jaybird/util/QualifiedName.java deleted file mode 100644 index cd63cd119..000000000 --- a/src/main/org/firebirdsql/jaybird/util/QualifiedName.java +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel -// SPDX-License-Identifier: LGPL-2.1-or-later -package org.firebirdsql.jaybird.util; - -import org.firebirdsql.gds.ng.fields.FieldDescriptor; -import org.firebirdsql.jdbc.QuoteStrategy; -import org.jspecify.annotations.Nullable; - -import java.util.Optional; - -import static java.util.Objects.requireNonNull; -import static java.util.Objects.requireNonNullElse; - -/** - * An object name qualified by a schema. - *

- * NOTE: This class is not usable for packaged objects, as it has only one level of qualification. - *

- * - * @param schema - * unquoted, case-sensitive schema name, {@code null} is returned as empty string ({@code ""}) - * @param object - * unquoted, case-sensitive object name (e.g. a table name) - * @since 7 - */ -public record QualifiedName(String schema, String object) { - - /** - * Canonical constructor. - * - * @param schema - * unquoted, case-sensitive schema name, {@code null} is returned as empty string ({@code ""}) - * @param object - * unquoted, case-sensitive object name (e.g. a table name) - * @throws IllegalArgumentException if {@code object} is blank - */ - public QualifiedName(@Nullable String schema, String object) { - // defined explicitly to annotate @Nullable on schema for constructor only - this.schema = requireNonNullElse(schema, ""); - if (object.isBlank()) { - throw new IllegalArgumentException("object cannot be blank"); - } - this.object = requireNonNull(object, "object"); - } - - @Override - public String schema() { - return schema; - } - - /** - * Estimated length of the quoted identifier. - *

- * The estimate might be of if {@link #schema()} or {@link #object()} contains double quotes, or if - * {@link QuoteStrategy#DIALECT_1} is used. - *

- *

- * This can be used for pre-sizing a string builder for {@link #append(StringBuilder, QuoteStrategy)}. - *

- * - * @return estimated length of the quoted identifier - */ - public int estimatedLength() { - // 2: double quotes, 1: separator - return (schema.isEmpty() ? 0 : 2 + schema.length() + 1) + 2 + object.length(); - } - - /** - * Produces the string of the identifier chain. - * - * @param quoteStrategy - * quote strategy to apply on {@code schema} and {@code object} - * @return identifier chain - */ - public String toString(QuoteStrategy quoteStrategy) { - if (!schema.isEmpty()) { - var sb = new StringBuilder(estimatedLength()); - quoteStrategy.appendQuoted(schema, sb).append('.'); - quoteStrategy.appendQuoted(object, sb); - return sb.toString(); - } - return quoteStrategy.quoteObjectName(object); - } - - /** - * Appends the identifier chain to {@code sb}, using {@code quoteStrategy}. - * - * @param sb - * StringBuilder for appending - * @param quoteStrategy - * quote strategy to apply on {@code schema} and {@code object} - * @return the StringBuilder for method chaining - * @see #estimatedLength() - */ - public StringBuilder append(StringBuilder sb, QuoteStrategy quoteStrategy) { - if (!schema.isEmpty()) { - quoteStrategy.appendQuoted(schema, sb).append('.'); - } - quoteStrategy.appendQuoted(object, sb); - return sb; - } - - /** - * Creates a qualified name from {@code originalSchema} and {@code originalTableName} of - * {@code fieldDescriptor}. - * - * @param fieldDescriptor - * field descriptor - * @return a schema-qualified name of the original table from {@code fieldDescriptor} or empty if its - * {@code originalTableName} is empty string or {@code null} - */ - public static Optional of(FieldDescriptor fieldDescriptor) { - String tableName = fieldDescriptor.getOriginalTableName(); - if (StringUtils.isNullOrEmpty(tableName)) { - return Optional.empty(); - } - // NOTE: This will produce an exception if tableName is blank and not empty, we accept that as that shouldn't - // happen in normal use - return Optional.of(new QualifiedName(fieldDescriptor.getOriginalSchema(), tableName)); - } - -} diff --git a/src/main/org/firebirdsql/jaybird/util/StringUtils.java b/src/main/org/firebirdsql/jaybird/util/StringUtils.java index 59dd19db6..a8bc50a2f 100644 --- a/src/main/org/firebirdsql/jaybird/util/StringUtils.java +++ b/src/main/org/firebirdsql/jaybird/util/StringUtils.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2019-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2019-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.util; @@ -40,13 +40,25 @@ private StringUtils() { * * @param value * value to test - * @return {@code true} if {@code value} is {@code null} or emoty, {@code false} for non-empty strings + * @return {@code true} if {@code value} is {@code null} or empty, {@code false} for non-empty strings * @since 6 */ public static boolean isNullOrEmpty(@Nullable String value) { return value == null || value.isEmpty(); } + /** + * Checks if {@code value} is {@code null} or blank. + * + * @param value + * value to test + * @return {@code true} if {@code value} is {@code null} or blank, {@code false} for non-blank strings + * @since 6 + */ + public static boolean isNullOrBlank(@Nullable String value) { + return value == null || value.isBlank(); + } + /** * Null-safe trim. * diff --git a/src/main/org/firebirdsql/jdbc/FBRowUpdater.java b/src/main/org/firebirdsql/jdbc/FBRowUpdater.java index bbe8923c4..3beae4086 100644 --- a/src/main/org/firebirdsql/jdbc/FBRowUpdater.java +++ b/src/main/org/firebirdsql/jdbc/FBRowUpdater.java @@ -10,8 +10,8 @@ import org.firebirdsql.gds.ng.fields.RowDescriptor; import org.firebirdsql.gds.ng.fields.RowValue; import org.firebirdsql.gds.ng.listeners.StatementListener; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.jaybird.util.SQLExceptionChainBuilder; -import org.firebirdsql.jaybird.util.QualifiedName; import org.firebirdsql.jaybird.util.UncheckedSQLException; import org.firebirdsql.jdbc.field.FBField; import org.firebirdsql.jdbc.field.FBFlushableField; @@ -68,7 +68,7 @@ final class FBRowUpdater implements FirebirdRowUpdater { private static final byte[][] EMPTY_2D_BYTES = new byte[0][]; - private final QualifiedName tableName; + private final ObjectReference tableName; private final FBObjectListener.ResultSetListener rsListener; private final GDSHelper gdsHelper; private final RowDescriptor rowDescriptor; @@ -127,12 +127,12 @@ private FBField createFieldUnchecked(FieldDescriptor fieldDescriptor, boolean ca * @throws SQLException * if {@code rowDescriptor} references multiple tables or has derived columns */ - private static QualifiedName requireSingleTable(RowDescriptor rowDescriptor, QuoteStrategy quoteStrategy) + private static ObjectReference requireSingleTable(RowDescriptor rowDescriptor, QuoteStrategy quoteStrategy) throws SQLException { // find the tableName (there can be only one tableName per updatable result set) - QualifiedName tableName = null; + ObjectReference tableName = null; for (FieldDescriptor fieldDescriptor : rowDescriptor) { - var currentTable = QualifiedName.of(fieldDescriptor).orElse(null); + var currentTable = ObjectReference.ofTable(fieldDescriptor).orElse(null); if (currentTable == null) { // No table => derived column => not updatable throw new FBResultSetNotUpdatableException( @@ -220,7 +220,7 @@ public FBField getField(int fieldPosition) { * @throws SQLException * for errors looking up the best row identifier */ - private static List deriveKeyColumns(QualifiedName table, RowDescriptor rowDescriptor, + private static List deriveKeyColumns(ObjectReference table, RowDescriptor rowDescriptor, DatabaseMetaData dbmd) throws SQLException { // first try best row identifier List keyColumns = keyColumnsOfBestRowIdentifier(table, rowDescriptor, dbmd); @@ -251,10 +251,22 @@ private static List deriveKeyColumns(QualifiedName table, RowDe * @throws SQLException * for errors looking up the best row identifier */ - private static List keyColumnsOfBestRowIdentifier(QualifiedName table, + private static List keyColumnsOfBestRowIdentifier(ObjectReference table, RowDescriptor rowDescriptor, DatabaseMetaData dbmd) throws SQLException { + // Method local wrapper to extract schema and table name from an ObjectReference + // NOTE: We're assuming, but not verifying, a size of 1 or 2. + record TableRef(ObjectReference ref) { + String schema() { + return ref.size() == 1 ? "" : ref.first().name(); + } + + String tableName() { + return ref.size() == 1 ? ref.first().name() : ref.last().name(); + } + } + TableRef tableRef = new TableRef(table); try (ResultSet bestRowIdentifier = dbmd.getBestRowIdentifier( - "", table.schema(), table.object(), DatabaseMetaData.bestRowTransaction, true)) { + "", tableRef.schema(), tableRef.tableName(), DatabaseMetaData.bestRowTransaction, true)) { int bestRowIdentifierColumnCount = 0; List keyColumns = new ArrayList<>(); while (bestRowIdentifier.next()) { @@ -378,10 +390,11 @@ private String buildInsertStatement() { params.append('?'); } - // 27 = length of appended literals + 2 quote characters - var sb = new StringBuilder(27 + tableName.estimatedLength() + columns.length() + params.length()) + // 25 = length of appended literals, 32 = guesstimate for (schema +) table + var sb = new StringBuilder(25 + 32 + columns.length() + params.length()) .append("insert into "); - tableName.append(sb,quoteStrategy).append(" (").append(columns).append(") values (").append(params).append(')'); + tableName.append(sb, quoteStrategy) + .append(" (").append(columns).append(") values (").append(params).append(')'); return sb.toString(); } @@ -405,7 +418,8 @@ private String buildSelectStatement() { } } - var sb = new StringBuilder(EST_STATEMENT_SIZE + columns.length() + tableName.estimatedLength()) + // 32 = guesstimate for (schema +) table + var sb = new StringBuilder(EST_STATEMENT_SIZE + columns.length() + 32) .append("select ").append(columns).append("\nfrom "); tableName.append(sb, quoteStrategy).append('\n'); appendWhereClause(sb); diff --git a/src/test/org/firebirdsql/jaybird/util/CollectionUtilsTest.java b/src/test/org/firebirdsql/jaybird/util/CollectionUtilsTest.java index c3e5a00c0..78ba8a674 100644 --- a/src/test/org/firebirdsql/jaybird/util/CollectionUtilsTest.java +++ b/src/test/org/firebirdsql/jaybird/util/CollectionUtilsTest.java @@ -18,6 +18,7 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -90,13 +91,32 @@ void getLast_multipleItems() { } @Test - void concat() { + void concat_oneList() { + var list1 = List.of("item1", "item2"); + + List concatList = CollectionUtils.concat(list1); + assertEquals(list1, concatList); + assertNotSame(list1, concatList, "Expected a different instance"); + } + + @Test + void concat_twoLists() { var list1 = List.of("item1", "item2"); var list2 = List.of("item3", "item4"); assertEquals(List.of("item1", "item2", "item3", "item4"), CollectionUtils.concat(list1, list2)); } + @Test + void concat_threeLists() { + var list1 = List.of("item1", "item2"); + var list2 = List.of("item3", "item4"); + var list3 = List.of("item5", "item6"); + + assertEquals(List.of("item1", "item2", "item3", "item4", "item5", "item6"), + CollectionUtils.concat(list1, list2, list3)); + } + static Stream listFactories() { return Stream.of( Arguments.of(factory(ArrayList::new)), diff --git a/src/test/org/firebirdsql/jaybird/util/IdentifierChainTest.java b/src/test/org/firebirdsql/jaybird/util/IdentifierChainTest.java new file mode 100644 index 000000000..b248f1e8a --- /dev/null +++ b/src/test/org/firebirdsql/jaybird/util/IdentifierChainTest.java @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.jdbc.QuoteStrategy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; +import java.util.stream.Stream; + +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link IdentifierChain}. + */ +class IdentifierChainTest { + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + nameList, expectedSize, expectedDialect3 + SIMPLE_NAME, 1, "SIMPLE_NAME" + SIMPLE_NAME.lower_case, 2, "SIMPLE_NAME"."lower_case" + ONE.TWO.THREE, 3, "ONE"."TWO"."THREE" + """) + void identifier(String nameList, int expectedSize, String expectedDialect3) { + List identifiers = toIdentifiers(nameList); + var chain = new IdentifierChain(identifiers); + + assertEquals(expectedDialect3, chain.toString(), "toString()"); + assertEquals(expectedDialect3, chain.toString(QuoteStrategy.DIALECT_3), "toString(DIALECT_3)"); + assertEquals(nameList, chain.toString(QuoteStrategy.DIALECT_1), "toString(DIALECT_1)"); + assertEquals(expectedDialect3, chain.append(new StringBuilder(), QuoteStrategy.DIALECT_3).toString(), + "append(..., DIALECT_3)"); + assertEquals(nameList, chain.append(new StringBuilder(), QuoteStrategy.DIALECT_1).toString(), + "append(..., DIALECT_1)"); + assertEquals(expectedSize, chain.size(), "size"); + assertEquals(identifiers, chain.toList(), "toList"); + } + + @Test + void emptyIdentifierList_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> new IdentifierChain(emptyList())); + } + + @SuppressWarnings({ "SimplifiableAssertion", "EqualsBetweenInconvertibleTypes" }) + @ParameterizedTest + @ValueSource(strings = { "SIMPLE_NAME", "lower_case", "Example3" }) + void equalsAndHashCode_betweenIdentifierAndIdentifierChain(String name) { + var identifier = new Identifier(name); + var chain = new IdentifierChain(List.of(identifier)); + + assertTrue(chain.equals(identifier), "chain.equals(identifier)"); + assertTrue(identifier.equals(chain), "identifier.equals(chain)"); + assertEquals(chain.hashCode(), identifier.hashCode(), "hashCode"); + } + + @SuppressWarnings("SimplifiableAssertion") + @Test + void equalsBetweenIdentifierChain() { + var identifiers = List.of(new Identifier("NAME1"), new Identifier("NAME2")); + + assertTrue(new IdentifierChain(identifiers).equals(new IdentifierChain(identifiers)), "chain.equals(chain)"); + } + + @SuppressWarnings({ "SimplifiableAssertion", "EqualsBetweenInconvertibleTypes" }) + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + nameList1, nameList2 + EXAMPLE, example + EXAMPLE, EXAMPLE.example + Example, exAmple + example.EXAMPLE, EXAMPLE.example + example.EXAMPLE, EXAMPLE.example.Example + example.EXAMPLE, EXAMPLE + """) + void notEquals(String nameList1, String nameList2) { + List identifiers1 = toIdentifiers(nameList1); + IdentifierChain chain1 = new IdentifierChain(identifiers1); + List identifiers2 = toIdentifiers(nameList2); + IdentifierChain chain2 = new IdentifierChain(identifiers2); + + assertFalse(chain1.equals(chain2), "equals"); + if (chain2.size() == 1) { + assertFalse(chain1.equals(chain2.at(0)), "equals with identifier"); + } + assertFalse(chain1.equals(new IdentifierChain(CollectionUtils.concat(identifiers1, identifiers2))), + "equals with chain with same prefix"); + } + + @Test + void toList() { + List identifiers = Stream.of("ONE", "TWO", "THREE").map(Identifier::new).toList(); + var chain = new IdentifierChain(identifiers); + + assertEquals(identifiers, chain.toList(), "toList"); + } + + @Test + void at() { + List identifiers = Stream.of("ONE", "TWO", "THREE").map(Identifier::new).toList(); + var chain = new IdentifierChain(identifiers); + + for (int i = 0; i < identifiers.size(); i++) { + assertEquals(identifiers.get(i), chain.at(i), "at(" + i + ")"); + } + } + + @ParameterizedTest + @ValueSource(ints = { -1, 3, 10 }) + void at_outOfRange(int index) { + List identifiers = Stream.of("ONE", "TWO", "THREE").map(Identifier::new).toList(); + var chain = new IdentifierChain(identifiers); + + assertThrows(IndexOutOfBoundsException.class, () -> chain.at(index)); + } + + @Test + void first() { + List identifiers = Stream.of("ONE", "TWO", "THREE").map(Identifier::new).toList(); + var chain = new IdentifierChain(identifiers); + + assertEquals(identifiers.get(0), chain.first(), "first"); + } + + @Test + void last() { + List identifiers = Stream.of("ONE", "TWO", "THREE").map(Identifier::new).toList(); + var chain = new IdentifierChain(identifiers); + + assertEquals(identifiers.get(2), chain.last(), "last"); + } + + @Test + void stream() { + List identifiers = Stream.of("ONE", "TWO", "THREE").map(Identifier::new).toList(); + var chain = new IdentifierChain(identifiers); + + assertEquals(identifiers, chain.stream().toList(), "stream"); + } + + @Test + void resolve_twoChains() { + List allIdentifiers = Stream.of("ONE", "TWO", "THREE", "FOUR").map(Identifier::new).toList(); + var chain1 = new IdentifierChain(allIdentifiers.subList(0, 2)); + var chain2 = new IdentifierChain(allIdentifiers.subList(2, 4)); + + assertEquals(new IdentifierChain(allIdentifiers), chain1.resolve(chain2), "resolve"); + } + + @Test + void resolve_chainAndIdentifier() { + List allIdentifiers = Stream.of("ONE", "TWO", "THREE").map(Identifier::new).toList(); + var chain = new IdentifierChain(allIdentifiers.subList(0, 2)); + var identifier3 = allIdentifiers.get(2); + + assertEquals(new IdentifierChain(allIdentifiers), chain.resolve(identifier3), "resolve"); + } + + private static List toIdentifiers(String nameList) { + return Stream.of(nameList.split("\\.")).map(Identifier::new).toList(); + } +} \ No newline at end of file diff --git a/src/test/org/firebirdsql/jaybird/util/IdentifierTest.java b/src/test/org/firebirdsql/jaybird/util/IdentifierTest.java new file mode 100644 index 000000000..7e023a3f7 --- /dev/null +++ b/src/test/org/firebirdsql/jaybird/util/IdentifierTest.java @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.jdbc.QuoteStrategy; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link Identifier}. + */ +class IdentifierTest { + + @SuppressWarnings("DataFlowIssue") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " ", " " }) + void nameNullEmptyOrBlank_notAllowed(@Nullable String name) { + assertThrows(IllegalArgumentException.class, () -> new Identifier(name)); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + name, expectedDialect3 + SIMPLE_NAME, "SIMPLE_NAME" + lower_case, "lower_case" + """) + void identifier(String name, String expectedDialect3) { + var identifier = new Identifier(name); + + assertEquals(name, identifier.name(), "name()"); + assertEquals(expectedDialect3, identifier.toString(), "toString()"); + assertEquals(expectedDialect3, identifier.toString(QuoteStrategy.DIALECT_3), "toString(DIALECT_3)"); + assertEquals(name, identifier.toString(QuoteStrategy.DIALECT_1), "toString(DIALECT_1)"); + assertEquals(expectedDialect3, identifier.append(new StringBuilder(), QuoteStrategy.DIALECT_3).toString(), + "append(..., DIALECT_3)"); + assertEquals(name, identifier.append(new StringBuilder(), QuoteStrategy.DIALECT_1).toString(), + "append(..., DIALECT_1)"); + assertEquals(1, identifier.size(), "size"); + } + + @ParameterizedTest + @ValueSource(strings = { " SPACE_PREFIX", "SPACE_SUFFIX ", " SPACE_BOTH " }) + void nameIsTrimmed(String name) { + var identifier = new Identifier(name); + + assertEquals(name.trim(), identifier.name()); + } + + @SuppressWarnings({ "SimplifiableAssertion", "EqualsBetweenInconvertibleTypes" }) + @ParameterizedTest + @ValueSource(strings = { "SIMPLE_NAME", "lower_case", "Example3" }) + void equalsAndHashCode_betweenIdentifierAndIdentifierChain(String name) { + var identifier = new Identifier(name); + var chain = new IdentifierChain(List.of(identifier)); + + assertTrue(identifier.equals(chain), "identifier.equals(chain)"); + assertTrue(chain.equals(identifier), "chain.equals(identifier)"); + assertEquals(identifier.hashCode(), chain.hashCode(), "hashCode"); + } + + @SuppressWarnings({ "SimplifiableAssertion", "EqualsBetweenInconvertibleTypes" }) + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + name1, name2 + EXAMPLE, example + Example, exAmple + """) + void notEquals(String name1, String name2) { + var identifier1 = new Identifier(name1); + var identifier2 = new Identifier(name2); + + assertFalse(identifier1.equals(identifier2), "equals"); + assertFalse(identifier1.equals(new IdentifierChain(List.of(identifier2))), "equals with chain"); + assertFalse(identifier1.equals(new IdentifierChain(List.of(identifier1, identifier2))), + "equals with chain with same prefix"); + } + + @Test + void toList() { + var identifier = new Identifier("EXAMPLE"); + + assertEquals(List.of(identifier), identifier.toList(), "toList"); + } + + @Test + void at() { + var identifier = new Identifier("EXAMPLE"); + + assertEquals(identifier, identifier.at(0), "at"); + } + + @ParameterizedTest + @ValueSource(ints = { -1, 1, 10}) + void at_outOfRange(int index) { + var identifier = new Identifier("EXAMPLE"); + + assertThrows(IndexOutOfBoundsException.class, () -> identifier.at(index)); + } + + @Test + void first() { + var identifier = new Identifier("EXAMPLE"); + + assertEquals(identifier, identifier.first(), "first"); + } + + @Test + void last() { + var identifier = new Identifier("EXAMPLE"); + + assertEquals(identifier, identifier.last(), "last"); + } + + @Test + void stream() { + var identifier = new Identifier("EXAMPLE"); + + assertEquals(List.of(identifier), identifier.stream().toList(), "stream"); + } + + @Test + void resolve_twoIdentifiers() { + var identifier1 = new Identifier("EXAMPLE_1"); + var identifier2 = new Identifier("EXAMPLE_2"); + + assertEquals(new IdentifierChain(List.of(identifier1, identifier2)), identifier1.resolve(identifier2), + "resolve"); + } + + @Test + void resolve_identifierAndChain() { + var identifier1 = new Identifier("EXAMPLE_1"); + var identifier2 = new Identifier("EXAMPLE_2"); + var identifier3 = new Identifier("EXAMPLE_3"); + var chain = new IdentifierChain(List.of(identifier2, identifier3)); + + assertEquals(new IdentifierChain(List.of(identifier1, identifier2, identifier3)), identifier1.resolve(chain), + "resolve"); + } + +} \ No newline at end of file diff --git a/src/test/org/firebirdsql/jaybird/util/ObjectReferenceTest.java b/src/test/org/firebirdsql/jaybird/util/ObjectReferenceTest.java new file mode 100644 index 000000000..e2b453645 --- /dev/null +++ b/src/test/org/firebirdsql/jaybird/util/ObjectReferenceTest.java @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.encodings.EncodingFactory; +import org.firebirdsql.gds.ng.DatatypeCoder; +import org.firebirdsql.gds.ng.DefaultDatatypeCoder; +import org.firebirdsql.gds.ng.fields.FieldDescriptor; +import org.firebirdsql.gds.ng.fields.RowDescriptorBuilder; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; + +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link ObjectReference}. + *

+ * Some parts are tested through {@link IdentifierTest} and {@link IdentifierChainTest}. + *

+ */ +class ObjectReferenceTest { + + private static final DatatypeCoder datatypeCoder = + DefaultDatatypeCoder.forEncodingFactory(EncodingFactory.createInstance(StandardCharsets.UTF_8)); + + @Test + void of_String() { + final String name = "TestName"; + Identifier identifier = ObjectReference.of(name); + assertEquals(name, identifier.name()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void of_String_nullOrEmptyOrBlank_throwsIllegalArgumentException(String name) { + assertThrows(IllegalArgumentException.class, () -> ObjectReference.of(name)); + } + + @Test + void of_StringArray_empty_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, ObjectReference::of); + } + + @Test + void of_StringArray_singleName_returnsIdentifier() { + String name = "TestName"; + var objectReference = ObjectReference.of(new String[] { name }); + + Identifier asIdentifier = assertInstanceOf(Identifier.class, objectReference); + assertEquals(name, asIdentifier.name()); + } + + @ParameterizedTest + @ValueSource(ints = { 2, 3, 10 }) + void of_StringArray_multipleNames_returnsIdentifierChain(int nameCount) { + String[] names = IntStream.rangeClosed(1, nameCount).mapToObj(i -> "name" + i).toArray(String[]::new); + ObjectReference objectReference = ObjectReference.of(names); + + IdentifierChain asChain = assertInstanceOf(IdentifierChain.class, objectReference); + List namesOfChain = asChain.stream().map(Identifier::name).toList(); + assertEquals(Arrays.asList(names), namesOfChain); + } + + // Given of(String...) calls of(List), we perform some tests of(List) through of(String...) + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + prefixCount, prefixValue, suffixList + 0, , NAME1.NAME2 + 1, , NAME1 + 2, '', NAME1.NAME2 + 3, '', NAME1.NAME2 + 3, , NAME1.NAME2.NAME3 + """) + void of_StringList_prefixMayBeNullOrEmpty(int prefixCount, String prefixValue, String suffixList) { + assertTrue(isNullOrEmpty(prefixValue), "prefixValue must be null or empty"); + List suffixNames = toNames(suffixList); + var names = new ArrayList(prefixCount + suffixNames.size()); + for (int i = 0; i < prefixCount; i++) { + names.add(prefixValue); + } + names.addAll(suffixNames); + + assertEquals(ObjectReference.of(suffixNames), ObjectReference.of(names)); + } + + @Test + void of_StringArray_prefixMayNotBeBlank() { + assertThrows(IllegalArgumentException.class, () -> ObjectReference.of(" ", "NAME")); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void of_StringList_allNullOrEmpty_throwsIllegalArgumentException(boolean useNull) { + List names = useNull ? Arrays.asList(null, null, null) : Arrays.asList("", "", ""); + + assertThrows(IllegalArgumentException.class, () -> ObjectReference.of(names)); + } + + @Test + void of_StringArr_suffixSingleName_createsIdentifier() { + var objectReference = ObjectReference.of("", null, "", "NAME"); + + Identifier asIdentifier = assertInstanceOf(Identifier.class, objectReference); + assertEquals("NAME", asIdentifier.name()); + } + + @Test + void of_StringArr_suffixMultipleNames_createsIdentifierChain() { + var objectReference = ObjectReference.of("", null, "", "NAME1", "NAME2"); + + IdentifierChain asIdentifierChain = assertInstanceOf(IdentifierChain.class, objectReference); + assertEquals(ObjectReference.of("NAME1", "NAME2"), asIdentifierChain); + } + + @SuppressWarnings("OptionalGetWithoutIsPresent") + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schema, object, expectedEmpty + , TABLE, false + '', TABLE, false + SCHEMA, TABLE, false + SCHEMA, , true + SCHEMA, '', true + , , true + '', '', true + """) + void testOfTable(@Nullable String schema, String object, boolean expectedEmpty) { + FieldDescriptor fieldDescriptor = new RowDescriptorBuilder(1, datatypeCoder) + .setOriginalSchema(schema) + .setOriginalTableName(object) + .toFieldDescriptor(); + Optional optName = ObjectReference.ofTable(fieldDescriptor); + assertEquals(expectedEmpty, optName.isEmpty(), "empty"); + if (!expectedEmpty) { + ObjectReference name = optName.get(); + int tableNameIndex; + if (isNullOrEmpty(schema)) { + assertEquals(1, name.size(), "size"); + tableNameIndex = 0; + } else { + assertEquals(2, name.size(), "size"); + assertEquals(schema, name.at(0).name(), "schema"); + tableNameIndex = 1; + } + assertEquals(object, name.at(tableNameIndex).name(), "object"); + } + } + + private static List toNames(String nameList) { + return List.of(nameList.split("\\.")); + } + +} \ No newline at end of file diff --git a/src/test/org/firebirdsql/jaybird/util/QualifiedNameTest.java b/src/test/org/firebirdsql/jaybird/util/QualifiedNameTest.java deleted file mode 100644 index 5b4e5630a..000000000 --- a/src/test/org/firebirdsql/jaybird/util/QualifiedNameTest.java +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel -// SPDX-License-Identifier: LGPL-2.1-or-later -package org.firebirdsql.jaybird.util; - -import org.firebirdsql.encodings.EncodingFactory; -import org.firebirdsql.gds.ng.DatatypeCoder; -import org.firebirdsql.gds.ng.DefaultDatatypeCoder; -import org.firebirdsql.gds.ng.fields.FieldDescriptor; -import org.firebirdsql.gds.ng.fields.RowDescriptorBuilder; -import org.firebirdsql.jdbc.QuoteStrategy; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.EmptySource; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; - -import java.nio.charset.StandardCharsets; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class QualifiedNameTest { - - private static final DatatypeCoder datatypeCoder = - DefaultDatatypeCoder.forEncodingFactory(EncodingFactory.createInstance(StandardCharsets.UTF_8)); - - @ParameterizedTest - @NullAndEmptySource - void nullOrEmptySchemaReportedAsEmpty(@Nullable String schema) { - var name = new QualifiedName(schema, "COLUMN"); - assertEquals("COLUMN", name.object()); - assertEquals("", name.schema()); - } - - @ParameterizedTest - @EmptySource - @ValueSource(strings = " ") - void blankObject_throwsException(String object) { - assertThrows(IllegalArgumentException.class, () -> new QualifiedName("SCHEMA_NAME", object)); - } - - @Test - @SuppressWarnings("DataFlowIssue") - void nullObject_throwsException() { - assertThrows(NullPointerException.class, () -> new QualifiedName("SCHEMA_NAME", null)); - } - - @ParameterizedTest - @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ - schema, object, quoteStrategy, expectedLength, expectedIdentifierChain - , OBJECT, DIALECT_3, 8, "OBJECT" - '', OBJECT, DIALECT_1, 8, OBJECT - SCHEMA, OBJECT, DIALECT_3, 17, "SCHEMA"."OBJECT" - SCHEMA, OBJECT, DIALECT_1, 17, SCHEMA.OBJECT - # Weird case - SCHEM"QUOTE, OBJ"QUOTE, DIALECT_3, 25, "SCHEM""QUOTE"."OBJ""QUOTE" - """) - void identifierChainProduction(@Nullable String schema, String object, QuoteStrategy quoteStrategy, - int expectedLength, String expectedIdentifierChain) { - var name = new QualifiedName(schema, object); - assertAll( - () -> assertEquals(expectedLength, name.estimatedLength(), "estimatedLength"), - () -> assertEquals(expectedIdentifierChain, name.toString(quoteStrategy), "toString(QuoteStrategy)"), - () -> { - var sb = new StringBuilder(name.estimatedLength()); - name.append(sb, quoteStrategy); - assertEquals(expectedIdentifierChain, sb.toString(), "append(StringBuilder, QuoteStrategy)"); - } - ); - } - - @SuppressWarnings("OptionalGetWithoutIsPresent") - @ParameterizedTest - @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ - schema, object, expectedEmpty - , TABLE, false - '', TABLE, false - SCHEMA, TABLE, false - SCHEMA, , true - SCHEMA, '', true - , , true - '', '', true - """) - void testOf(@Nullable String schema, String object, boolean expectedEmpty) { - FieldDescriptor fieldDescriptor = new RowDescriptorBuilder(1, datatypeCoder) - .setOriginalSchema(schema) - .setOriginalTableName(object) - .toFieldDescriptor(); - Optional optName = QualifiedName.of(fieldDescriptor); - assertEquals(expectedEmpty, optName.isEmpty(), "empty"); - if (!expectedEmpty) { - QualifiedName name = optName.get(); - String expectedSchema = schema == null || schema.isBlank() ? "" : schema; - assertEquals(expectedSchema, name.schema(), "schema"); - assertEquals(object, name.object(), "object"); - } - } - -} \ No newline at end of file diff --git a/src/test/org/firebirdsql/jaybird/util/StringUtilsTest.java b/src/test/org/firebirdsql/jaybird/util/StringUtilsTest.java index 5f1ba96c2..4bff4296c 100644 --- a/src/test/org/firebirdsql/jaybird/util/StringUtilsTest.java +++ b/src/test/org/firebirdsql/jaybird/util/StringUtilsTest.java @@ -1,11 +1,13 @@ -// SPDX-FileCopyrightText: Copyright 2019-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2019-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.util; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.EmptySource; +import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; @@ -54,16 +56,29 @@ void trimToNull_endsWithSpace_yields_valueWithoutSpace() { @ParameterizedTest @NullSource @EmptySource - void testIsNullOrEmpty_nullOrEmptyYieldsTrue(String value) { + void testIsNullOrEmpty_nullOrEmptyYieldsTrue(@Nullable String value) { assertTrue(StringUtils.isNullOrEmpty(value)); } @ParameterizedTest @ValueSource(strings = { " ", "a", "\0", "abc" }) - void testIsNullOrEmpty_nonEmptyYieldsFalse(String value) { + void testIsNullOrEmpty_nonEmptyYieldsFalse(@Nullable String value) { assertFalse(StringUtils.isNullOrEmpty(value)); } + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void testIsNullOrBlank_nullOrBlankYieldsTrue(@Nullable String value) { + assertTrue(StringUtils.isNullOrBlank(value)); + } + + @ParameterizedTest + @ValueSource(strings = { "a", "\0", "abc" }) + void testIsNullOrBlank_nonBlankYieldsFalse(@Nullable String value) { + assertFalse(StringUtils.isNullOrBlank(value)); + } + @ParameterizedTest @CsvSource(useHeadersInDisplayName = true, textBlock = """ input, expectedOutput @@ -74,7 +89,7 @@ void testIsNullOrEmpty_nonEmptyYieldsFalse(String value) { ' a', a ' a ', a """) - void testTrim(String input, String expectedOutput) { + void testTrim(@Nullable String input, @Nullable String expectedOutput) { assertEquals(expectedOutput, StringUtils.trim(input)); } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java index b7a6ef492..ad54514f5 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java @@ -5,7 +5,7 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.jaybird.props.PropertyNames; import org.firebirdsql.jaybird.util.CollectionUtils; -import org.firebirdsql.jaybird.util.QualifiedName; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -373,9 +373,9 @@ void testFunctionColumnMetaData_useCatalogAsPackage_specificPackageProcedureColu props.setProperty(PropertyNames.useCatalogAsPackage, "true"); try (var connection = DriverManager.getConnection(getUrl(), props)) { dbmd = connection.getMetaData(); - List> expectedColumns = withCatalog("WITH$FUNCTION", - withSpecificName(ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$FUNCTION\".\"IN$PACKAGE\"", - List.of(createNumericalType(Types.INTEGER, "IN$PACKAGE", "PARAM1", 1, 10, 0, true)))); + List> expectedColumns = withCatalog("WITH$FUNCTION", withSpecificName( + ObjectReference.of(ifSchemaElse("PUBLIC", ""), "WITH$FUNCTION", "IN$PACKAGE").toString(), + List.of(createNumericalType(Types.INTEGER, "IN$PACKAGE", "PARAM1", 1, 10, 0, true)))); validateExpectedFunctionColumns(catalog, "IN$PACKAGE", "PARAM1", expectedColumns); } } @@ -570,8 +570,7 @@ private static List> withSchema(String schem List> rules) { for (Map rowRule : rules) { String functionName = (String) rowRule.get(FunctionColumnMetaData.FUNCTION_NAME); - rowRule.put(FunctionColumnMetaData.SPECIFIC_NAME, - new QualifiedName(schema, functionName).toString(QuoteStrategy.DIALECT_3)); + rowRule.put(FunctionColumnMetaData.SPECIFIC_NAME, ObjectReference.of(schema, functionName).toString()); rowRule.put(FunctionColumnMetaData.FUNCTION_SCHEM, schema); } return rules; @@ -582,7 +581,7 @@ private static Map createColumn(String functionN Map rules = getDefaultValidationRules(); rules.put(FunctionColumnMetaData.FUNCTION_NAME, functionName); rules.put(FunctionColumnMetaData.SPECIFIC_NAME, ifSchemaElse( - new QualifiedName("PUBLIC", functionName).toString(QuoteStrategy.DIALECT_3), functionName)); + ObjectReference.of("PUBLIC", functionName).toString(), functionName)); rules.put(FunctionColumnMetaData.COLUMN_NAME, columnName); rules.put(FunctionColumnMetaData.ORDINAL_POSITION, ordinalPosition); if (nullable) { diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java index 5a05d2d70..8dbb33f0c 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java @@ -4,7 +4,7 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.jaybird.props.PropertyNames; -import org.firebirdsql.jaybird.util.QualifiedName; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -339,7 +339,7 @@ private static Map getPsqlExample(boolean useCatalogAs } rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE"); rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse( - new QualifiedName("PUBLIC", "PSQL$EXAMPLE").toString(QuoteStrategy.DIALECT_3), "PSQL$EXAMPLE")); + ObjectReference.of("PUBLIC", "PSQL$EXAMPLE").toString(), "PSQL$EXAMPLE")); if (supportsComments) { rules.put(FunctionMetaData.REMARKS, "Comment on PSQL$EXAMPLE"); } @@ -362,8 +362,7 @@ private static Map getOtherSchemaPsqlExample(boolean u } rules.put(FunctionMetaData.FUNCTION_SCHEM, "OTHER_SCHEMA"); rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, - new QualifiedName("OTHER_SCHEMA", "PSQL$EXAMPLE").toString(QuoteStrategy.DIALECT_3)); + rules.put(FunctionMetaData.SPECIFIC_NAME, ObjectReference.of("OTHER_SCHEMA", "PSQL$EXAMPLE").toString()); rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, """ begin return cast(x as varchar(50)); @@ -383,8 +382,7 @@ private static Map getOtherSchemaPsqlExample2(boolean } rules.put(FunctionMetaData.FUNCTION_SCHEM, "OTHER_SCHEMA"); rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE2"); - rules.put(FunctionMetaData.SPECIFIC_NAME, - new QualifiedName("OTHER_SCHEMA", "PSQL$EXAMPLE2").toString(QuoteStrategy.DIALECT_3)); + rules.put(FunctionMetaData.SPECIFIC_NAME, ObjectReference.of("OTHER_SCHEMA", "PSQL$EXAMPLE2").toString()); rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, """ begin return X+1; @@ -405,7 +403,7 @@ private static Map getUdfExample(boolean useCatalogAsP } rules.put(FunctionMetaData.FUNCTION_NAME, "UDF$EXAMPLE"); rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse( - new QualifiedName("PUBLIC", "UDF$EXAMPLE").toString(QuoteStrategy.DIALECT_3), "UDF$EXAMPLE")); + ObjectReference.of("PUBLIC", "UDF$EXAMPLE").toString(), "UDF$EXAMPLE")); if (supportsComments) { rules.put(FunctionMetaData.REMARKS, "Comment on UDF$EXAMPLE"); } @@ -419,7 +417,8 @@ private static Map getPackageFunctionExample() { Map rules = getDefaultValidationRules(); rules.put(FunctionMetaData.FUNCTION_CAT, "WITH$FUNCTION"); rules.put(FunctionMetaData.FUNCTION_NAME, "IN$PACKAGE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$FUNCTION\".\"IN$PACKAGE\""); + rules.put(FunctionMetaData.SPECIFIC_NAME, + ObjectReference.of(ifSchemaElse("PUBLIC", ""), "WITH$FUNCTION", "IN$PACKAGE").toString()); // Stored with package rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, null); rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); From 2cb017e5eba4f15f81edacd5dd0b9555755ed397 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Fri, 18 Jul 2025 17:51:45 +0200 Subject: [PATCH 39/64] Add schema support assumption to test --- .../org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java index 8dbb33f0c..c4818399f 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java @@ -30,6 +30,7 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -192,6 +193,7 @@ void testFunctionMetadata_everything_functionNamePattern(String schemaPattern, S """) void testFunctionMetadata_everything_ofOtherSchema(String schemaPattern, String functionNamePattern) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); var expectedFunctions = List.of(getOtherSchemaPsqlExample(), getOtherSchemaPsqlExample2()); validateExpectedFunctions(null, schemaPattern, functionNamePattern, expectedFunctions); } From b8769ad0511d4a988ee0579cd61ca15e61dc599f Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 22 Jul 2025 08:49:39 +0200 Subject: [PATCH 40/64] #882 Improve schema test coverage (getProcedures/getProcedureColumns) --- .../firebirdsql/common/FBTestProperties.java | 15 ++ ...BDatabaseMetaDataProcedureColumnsTest.java | 157 ++++++++++--- .../FBDatabaseMetaDataProceduresTest.java | 207 ++++++++++++------ 3 files changed, 273 insertions(+), 106 deletions(-) diff --git a/src/test/org/firebirdsql/common/FBTestProperties.java b/src/test/org/firebirdsql/common/FBTestProperties.java index 3a8c05c41..e34884dc5 100644 --- a/src/test/org/firebirdsql/common/FBTestProperties.java +++ b/src/test/org/firebirdsql/common/FBTestProperties.java @@ -382,6 +382,21 @@ public static T ifSchemaElse(T forSchema, T withoutSchema) { return getDefaultSupportInfo().ifSchemaElse(forSchema, withoutSchema); } + /** + * Helper method that replaces {@code "PUBLIC"} with {@code ""} if schemas are not supported. + * + * @param schemaName + * schema name + * @return {@code schemaName}, or — if {@code schemaName} is {@code "PUBLIC"} and schemas are not supported + * — {@code ""} + */ + public static String resolveSchema(String schemaName) { + if (!getDefaultSupportInfo().supportsSchemas() && "PUBLIC".equals(schemaName)) { + return ""; + } + return schemaName; + } + private FBTestProperties() { // No instantiation } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java index a8fe6f42d..afb3ac5f7 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java @@ -4,6 +4,7 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.jaybird.props.PropertyNames; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.jdbc.metadata.FbMetadataConstants; import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; @@ -12,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; @@ -24,6 +26,8 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FBTestProperties.resolveSchema; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.firebirdsql.jdbc.FBDatabaseMetaDataProceduresTest.isIgnoredProcedure; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.*; @@ -38,8 +42,7 @@ */ class FBDatabaseMetaDataProcedureColumnsTest { - // TODO Add schema support: tests involving other schema - // TODO This test will need to be expanded with version dependent features + // TODO This test will need to be expanded with version dependent features // (eg TYPE OF (2.1), TYPE OF COLUMN (2.5), NOT NULL (2.1), DEFAULT (2.0) private static final String CREATE_NORMAL_PROC_NO_ARG_NO_RETURN = """ @@ -104,6 +107,18 @@ param2 VARCHAR(100) default 'param2 default') end end"""; + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + + private static final String CREATE_OTHER_SCHEMA_PROC_WITH_RETURN = """ + create procedure OTHER_SCHEMA.PROC_WITH_RETURN + ( PARAM1 varchar(100), + PARAM2 decimal(18,2)) + RETURNS (return1 VARCHAR(200)) + AS + BEGIN + return1 = param1 || param1; + END"""; + private static final MetadataResultSetDefinition getProcedureColumnsDefinition = new MetadataResultSetDefinition(ProcedureColumnMetaData.class); @@ -147,6 +162,10 @@ private static List getCreateStatements() { statements.add(CREATE_PACKAGE_WITH_PROCEDURE); statements.add(CREATE_PACKAGE_BODY_WITH_PROCEDURE); } + if (supportInfo.supportsSchemas()) { + statements.add(CREATE_OTHER_SCHEMA); + statements.add(CREATE_OTHER_SCHEMA_PROC_WITH_RETURN); + } return statements; } @@ -174,11 +193,20 @@ void testProcedureColumns_noArg_noReturn() throws Exception { /** * Tests getProcedureColumn with normal_proc_no_return using all columnPattern, expecting result set with all defined rows. */ - @Test - void testProcedureColumns_normalProc_noReturn_allPattern() throws Exception { + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, columnNamePattern + , + %, + PUBLIC, % + , % + """) + void testProcedureColumns_normalProc_noReturn_allPattern(String schemaPattern, String columnNamePattern) + throws Exception { var expectedColumns = getNormalProcNoReturn_allColumns(); - ResultSet procedureColumns = dbmd.getProcedureColumns(null, null, "NORMAL_PROC_NO_RETURN", "%"); + ResultSet procedureColumns = dbmd + .getProcedureColumns(null, resolveSchema(schemaPattern), "NORMAL_PROC_NO_RETURN", columnNamePattern); validate(procedureColumns, expectedColumns); } @@ -273,7 +301,11 @@ void testProcedureColumns_useCatalogAsPackage_everything() throws Exception { try (var connection = DriverManager.getConnection(getUrl(), props)) { dbmd = connection.getMetaData(); - var expectedColumns = new ArrayList<>(getNormalProcNoReturn_allColumns()); + var expectedColumns = new ArrayList>(); + if (supportInfo.supportsSchemas()) { + expectedColumns.addAll(getOtherSchemaProcWithReturn_allColumns()); + } + expectedColumns.addAll(getNormalProcNoReturn_allColumns()); expectedColumns.addAll(getNormalProcWithReturn_allColumns()); expectedColumns.addAll(getQuotedProcNoReturn_allColumns()); withCatalog("", expectedColumns); @@ -330,10 +362,10 @@ void testProcedureColumns_useCatalogAsPackage_specificPackageProcedureColumn(Str dbmd = connection.getMetaData(); List> expectedColumns = - withCatalog("WITH$PROCEDURE", - withSpecificName(ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$PROCEDURE\".\"IN$PACKAGE\"", - List.of(createNumericalType(Types.INTEGER, "IN$PACKAGE", "RETURN1", 1, 10, 0, true, - DatabaseMetaData.procedureColumnOut)))); + withCatalog("WITH$PROCEDURE", withSpecificName( + ObjectReference.of(ifSchemaElse("PUBLIC", ""), "WITH$PROCEDURE", "IN$PACKAGE").toString(), + List.of(createNumericalType(Types.INTEGER, "IN$PACKAGE", "RETURN1", 1, 10, 0, true, + DatabaseMetaData.procedureColumnOut)))); ResultSet procedureColumns = dbmd.getProcedureColumns(catalog, null, "IN$PACKAGE", "RETURN1"); validate(procedureColumns, expectedColumns); @@ -349,7 +381,11 @@ void testProcedureColumns_useCatalogAsPackage_nonPackagedOnly() throws Exception try (var connection = DriverManager.getConnection(getUrl(), props)) { dbmd = connection.getMetaData(); - var expectedColumns = new ArrayList<>(getNormalProcNoReturn_allColumns()); + var expectedColumns = new ArrayList>(); + if (supportInfo.supportsSchemas()) { + expectedColumns.addAll(getOtherSchemaProcWithReturn_allColumns()); + } + expectedColumns.addAll(getNormalProcNoReturn_allColumns()); expectedColumns.addAll(getNormalProcWithReturn_allColumns()); expectedColumns.addAll(getQuotedProcNoReturn_allColumns()); withCatalog("", expectedColumns); @@ -360,15 +396,47 @@ void testProcedureColumns_useCatalogAsPackage_nonPackagedOnly() throws Exception } private static List> getInPackage_allColumns() { - return withCatalog("WITH$PROCEDURE", - withSpecificName(ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$PROCEDURE\".\"IN$PACKAGE\"", - // TODO Having result columns first might be against JDBC spec - // TODO Describing result columns as procedureColumnOut might be against JDBC spec - List.of( - createNumericalType(Types.INTEGER, "IN$PACKAGE", "RETURN1", 1, 10, 0, true, - DatabaseMetaData.procedureColumnOut), - createNumericalType(Types.INTEGER, "IN$PACKAGE", "PARAM1", 1, 10, 0, true, - DatabaseMetaData.procedureColumnIn)))); + return withCatalog("WITH$PROCEDURE", withSpecificName( + ObjectReference.of(ifSchemaElse("PUBLIC", ""), "WITH$PROCEDURE", "IN$PACKAGE").toString(), + // TODO Having result columns first might be against JDBC spec + // TODO Describing result columns as procedureColumnOut might be against JDBC spec + List.of( + createNumericalType(Types.INTEGER, "IN$PACKAGE", "RETURN1", 1, 10, 0, true, + DatabaseMetaData.procedureColumnOut), + createNumericalType(Types.INTEGER, "IN$PACKAGE", "PARAM1", 1, 10, 0, true, + DatabaseMetaData.procedureColumnIn)))); + } + + /** + * Tests getProcedureColumn with OTHER_SCHEMA.PROC_WITH_RETURN, expecting result set with all defined rows. + */ + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, procedureNamePattern, columnNamePattern + OTHER_SCHEMA, PROC_WITH_RETURN, % + OTHER\\_SCHEMA, PROC\\_WITH\\_RETURN, + OTHER%, PROC\\_WITH\\_RETURN, + """) + void testProcedureColumns_otherSchemaProcWithReturn_all(String schemaPattern, String procedureNamePattern, + String columnNamePattern) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + var expectedColumns = getOtherSchemaProcWithReturn_allColumns(); + + ResultSet procedureColumns = dbmd + .getProcedureColumns(null, schemaPattern, procedureNamePattern, columnNamePattern); + validate(procedureColumns, expectedColumns); + } + + private static List> getOtherSchemaProcWithReturn_allColumns() { + return List.of( + // TODO Having result columns first might be against JDBC spec + // TODO Describing result columns as procedureColumnOut might be against JDBC spec + createStringType(Types.VARCHAR, "OTHER_SCHEMA", "PROC_WITH_RETURN", "RETURN1", 1, 200, true, + DatabaseMetaData.procedureColumnOut), + createStringType(Types.VARCHAR, "OTHER_SCHEMA", "PROC_WITH_RETURN", "PARAM1", 1, 100, true, + DatabaseMetaData.procedureColumnIn), + createNumericalType(Types.DECIMAL, "OTHER_SCHEMA", "PROC_WITH_RETURN", "PARAM2", 2, + NUMERIC_BIGINT_PRECISION, 2, true, DatabaseMetaData.procedureColumnIn)); } // TODO Add tests for more complex patterns for procedure and column @@ -392,12 +460,6 @@ private void validate(ResultSet procedureColumns, List createColumn(String procedureName, String columnName, - int ordinalPosition, boolean nullable, int columnType) { - return createColumn(ifSchemaElse("PUBLIC", null), procedureName, columnName, ordinalPosition, nullable, - columnType); - } - private static Map createColumn(String schema, String procedureName, String columnName, int ordinalPosition, boolean nullable, int columnType) { Map rules = getDefaultValueValidationRules(); @@ -416,19 +478,21 @@ private static Map createColumn(String schema, private static String getProcedureSpecificName(String schema, String procedureName) { if (schema == null || schema.isEmpty()) return procedureName; - var quote = QuoteStrategy.DIALECT_3; - // 5 = 4 quotes + 1 period - var sb = new StringBuilder(schema.length() + procedureName.length() + 5); - quote.appendQuoted(schema, sb).append('.'); - quote.appendQuoted(procedureName, sb); - return sb.toString(); + return ObjectReference.of(schema, procedureName).toString(); } @SuppressWarnings("SameParameterValue") private static Map createStringType(int jdbcType, String procedureName, String columnName, int ordinalPosition, int length, boolean nullable, int columnType) { + return createStringType(jdbcType, ifSchemaElse("PUBLIC", null), procedureName, columnName, ordinalPosition, length, + nullable, columnType); + } + + private static Map createStringType(int jdbcType, String schema, + String procedureName, String columnName, int ordinalPosition, int length, boolean nullable, + int columnType) { Map rules = - createColumn(procedureName, columnName, ordinalPosition, nullable, columnType); + createColumn(schema, procedureName, columnName, ordinalPosition, nullable, columnType); rules.put(ProcedureColumnMetaData.DATA_TYPE, jdbcType); String typeName = switch (jdbcType) { case Types.CHAR, Types.BINARY -> "CHAR"; @@ -445,8 +509,15 @@ private static Map createStringType(int jdbcTyp @SuppressWarnings("SameParameterValue") private static Map createNumericalType(int jdbcType, String procedureName, String columnName, int ordinalPosition, int precision, int scale, boolean nullable, int columnType) { + return createNumericalType(jdbcType, ifSchemaElse("PUBLIC", null), procedureName, columnName, ordinalPosition, + precision, scale, nullable, columnType); + } + + private static Map createNumericalType(int jdbcType, String schema, + String procedureName, String columnName, int ordinalPosition, int precision, int scale, boolean nullable, + int columnType) { Map rules = - createColumn(procedureName, columnName, ordinalPosition, nullable, columnType); + createColumn(schema, procedureName, columnName, ordinalPosition, nullable, columnType); rules.put(ProcedureColumnMetaData.DATA_TYPE, jdbcType); String typeName; int length; @@ -483,8 +554,15 @@ private static Map createNumericalType(int jdbc @SuppressWarnings("SameParameterValue") private static Map createDateTime(int jdbcType, String procedureName, String columnName, int ordinalPosition, boolean nullable, int columnType) { + return createDateTime(jdbcType, ifSchemaElse("PUBLIC", null), procedureName, columnName, ordinalPosition, + nullable, columnType); + } + + @SuppressWarnings("SameParameterValue") + private static Map createDateTime(int jdbcType, String schema, + String procedureName, String columnName, int ordinalPosition, boolean nullable, int columnType) { Map rules = - createColumn(procedureName, columnName, ordinalPosition, nullable, columnType); + createColumn(schema, procedureName, columnName, ordinalPosition, nullable, columnType); rules.put(ProcedureColumnMetaData.DATA_TYPE, jdbcType); String typeName; int precision; @@ -526,8 +604,14 @@ private static Map createDateTime(int jdbcType, @SuppressWarnings("SameParameterValue") private static Map createDouble(String procedureName, String columnName, int ordinalPosition, boolean nullable, int columnType) { + return createDouble(ifSchemaElse("PUBLIC", null), procedureName, columnName, ordinalPosition, nullable, + columnType); + } + + private static Map createDouble(String schema, String procedureName, + String columnName, int ordinalPosition, boolean nullable, int columnType) { Map rules = - createColumn(procedureName, columnName, ordinalPosition, nullable, columnType); + createColumn(schema, procedureName, columnName, ordinalPosition, nullable, columnType); rules.put(ProcedureColumnMetaData.DATA_TYPE, Types.DOUBLE); rules.put(ProcedureColumnMetaData.TYPE_NAME, "DOUBLE PRECISION"); if (getDefaultSupportInfo().supportsFloatBinaryPrecision()) { @@ -547,6 +631,7 @@ private static Map withRemark(Map withDefault(String defaultDefinition, Map rules) { rules.put(ProcedureColumnMetaData.COLUMN_DEF, defaultDefinition); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java index 293416ab0..10c519bbf 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java @@ -4,6 +4,7 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.jaybird.props.PropertyNames; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -11,6 +12,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; @@ -27,6 +30,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -38,8 +42,6 @@ */ class FBDatabaseMetaDataProceduresTest { - // TODO Add schema support: tests involving other schema - private static final String CREATE_NORMAL_PROC_NO_RETURN = """ CREATE PROCEDURE normal_proc_no_return ( param1 VARCHAR(100)) @@ -89,6 +91,17 @@ class FBDatabaseMetaDataProceduresTest { end end"""; + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + + private static final String CREATE_OTHER_SCHEMA_PROC_NO_RETURN = """ + create procedure OTHER_SCHEMA.PROC_NO_RETURN + ( PARAM1 varchar(100)) + as + declare variable DUMMY integer; + begin + DUMMY = 1 + 1; + end"""; + private static final MetadataResultSetDefinition getProceduresDefinition = new MetadataResultSetDefinition(ProcedureMetaData.class); @@ -121,6 +134,9 @@ static void tearDownAll() throws SQLException { private static List getCreateStatements() { FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); var createDDL = new ArrayList(); + if (supportInfo.supportsSchemas()) { + createDDL.add(CREATE_OTHER_SCHEMA); + } for (ProcedureTestData testData : ProcedureTestData.values()) { if (testData.include(supportInfo)) { createDDL.addAll(testData.getCreateDDL()); @@ -140,72 +156,70 @@ void testProcedureMetaDataColumns() throws Exception { } } - /** - * Tests getProcedures() with procedureName null, expecting all procedures to be returned. - */ - @Test - void testProcedureMetaData_all_procedureName_null() throws Exception { - validateProcedureMetaData_everything(null); - } - - /** - * Tests getProcedures() with procedureName all pattern (%), expecting all procedures to be returned. - */ - @Test - void testProcedureMetaData_all_procedureName_allPattern() throws Exception { - validateProcedureMetaData_everything("%"); - } - - private void validateProcedureMetaData_everything(String procedureNamePattern) throws Exception { - ResultSet procedures = dbmd.getProcedures(null, null, procedureNamePattern); - var expectedProcedures = List.of( - ProcedureTestData.NORMAL_PROC_NO_RETURN, - ProcedureTestData.NORMAL_PROC_WITH_RETURN, - ProcedureTestData.QUOTED_PROC_NO_RETURN); - try { + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, procedureNamePattern + , + %, + , % + %, % + """) + void testProcedureMetaData_all(String schemaPattern, String procedureNamePattern) + throws Exception { + var expectedProcedures = new ArrayList(); + if (getDefaultSupportInfo().supportsSchemas()) { + expectedProcedures.add(ProcedureTestData.OTHER_SCHEMA_PROC_NO_RETURN); + } + expectedProcedures.addAll(List.of( + ProcedureTestData.NORMAL_PROC_NO_RETURN, + ProcedureTestData.NORMAL_PROC_WITH_RETURN, + ProcedureTestData.QUOTED_PROC_NO_RETURN)); + try (ResultSet procedures = dbmd.getProcedures(null, schemaPattern, procedureNamePattern)) { validateProcedures(procedures, expectedProcedures); - } finally { - closeQuietly(procedures); } } /** * Tests getProcedures with specific procedure name, expecting only that specific procedure to be returned. */ - @Test - void testProcedureMetaData_specificProcedure() throws Exception { - var expectedProcedures = List.of(ProcedureTestData.NORMAL_PROC_WITH_RETURN); - ResultSet procedures = dbmd.getProcedures(null, null, expectedProcedures.get(0).getName()); - validateProcedures(procedures, expectedProcedures); + @ParameterizedTest + @EnumSource(value = ProcedureTestData.class, names = { "NORMAL_PROC_WITH_RETURN", "NORMAL_PROC_NO_RETURN", + "QUOTED_PROC_NO_RETURN", "OTHER_SCHEMA_PROC_NO_RETURN" }) + void testProcedureMetaData_specificProcedure(ProcedureTestData expectedProcedure) throws Exception { + assumeTrue(expectedProcedure.include(getDefaultSupportInfo()), + expectedProcedure + " requires unsupported feature"); + ResultSet procedures = dbmd.getProcedures(null, expectedProcedure.getSchema(), expectedProcedure.getName()); + validateProcedures(procedures, List.of(expectedProcedure)); } - /** - * Tests getProcedures with specific procedure name (quoted), expecting only that specific procedure to be returned. - */ - @Test - void testProcedureMetaData_specificProcedureQuoted() throws Exception { - var expectedProcedures = List.of(ProcedureTestData.QUOTED_PROC_NO_RETURN); - ResultSet procedures = dbmd.getProcedures(null, null, expectedProcedures.get(0).getName()); - validateProcedures(procedures, expectedProcedures); - } - - @Test - void testProcedureMetaData_useCatalogAsPackage_everything() throws Exception { + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, procedureNamePattern + , + %, + , % + %, % + """) + void testProcedureMetaData_useCatalogAsPackage_everything(String schemaPattern, String procedureNamePattern) + throws Exception { FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); assumeTrue(supportInfo.supportsPackages(), "Test requires package support"); + + var expectedProcedures = new ArrayList(); + if (supportInfo.supportsSchemas()) { + expectedProcedures.add(ProcedureTestData.OTHER_SCHEMA_PROC_NO_RETURN); + } + expectedProcedures.addAll(List.of( + ProcedureTestData.NORMAL_PROC_NO_RETURN, + ProcedureTestData.NORMAL_PROC_WITH_RETURN, + ProcedureTestData.QUOTED_PROC_NO_RETURN, + ProcedureTestData.PROCEDURE_IN_PACKAGE)); + Properties props = getDefaultPropertiesForConnection(); props.setProperty(PropertyNames.useCatalogAsPackage, "true"); try (var connection = DriverManager.getConnection(getUrl(), props)) { dbmd = connection.getMetaData(); - - var expectedProcedures = List.of( - ProcedureTestData.NORMAL_PROC_NO_RETURN, - ProcedureTestData.NORMAL_PROC_WITH_RETURN, - ProcedureTestData.QUOTED_PROC_NO_RETURN, - ProcedureTestData.PROCEDURE_IN_PACKAGE); - - ResultSet procedures = dbmd.getProcedures(null, null, null); - + ResultSet procedures = dbmd.getProcedures(null, schemaPattern, procedureNamePattern); validateProcedures(procedures, expectedProcedures, FBDatabaseMetaDataProceduresTest::modifyForUseCatalogAsPackage); } @@ -253,23 +267,46 @@ void testProcedureMetaData_useCatalogAsPackage_specificPackageProcedure(String c void testProcedureMetaData_useCatalogAsPackage_nonPackagedOnly() throws Exception { FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); assumeTrue(supportInfo.supportsPackages(), "Test requires package support"); + + var expectedProcedures = new ArrayList(); + if (supportInfo.supportsSchemas()) { + expectedProcedures.add(ProcedureTestData.OTHER_SCHEMA_PROC_NO_RETURN); + } + expectedProcedures.addAll(List.of( + ProcedureTestData.NORMAL_PROC_NO_RETURN, + ProcedureTestData.NORMAL_PROC_WITH_RETURN, + ProcedureTestData.QUOTED_PROC_NO_RETURN)); + Properties props = getDefaultPropertiesForConnection(); props.setProperty(PropertyNames.useCatalogAsPackage, "true"); try (var connection = DriverManager.getConnection(getUrl(), props)) { dbmd = connection.getMetaData(); - var expectedProcedures = List.of( - ProcedureTestData.NORMAL_PROC_NO_RETURN, - ProcedureTestData.NORMAL_PROC_WITH_RETURN, - ProcedureTestData.QUOTED_PROC_NO_RETURN); - ResultSet procedures = dbmd.getProcedures("", null, null); validateProcedures(procedures, expectedProcedures, FBDatabaseMetaDataProceduresTest::modifyForUseCatalogAsPackage); } } - + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, procedureNamePattern + OTHER_SCHEMA, + OTHER\\_SCHEMA, % + OTHER%, % + # NOTE: This case assumes all procedures in OTHER_SCHEMA start with PROC_ + OTHER\\_SCHEMA, PROC\\_% + """) + void testProcedureMetaData_otherSchema_all(String schemaPattern, String procedureNamePattern) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + var expectedProcedures = List.of(ProcedureTestData.OTHER_SCHEMA_PROC_NO_RETURN); + + try (var procedures = dbmd.getProcedures(null, schemaPattern, procedureNamePattern)) { + validateProcedures(procedures, expectedProcedures); + } + } + // TODO Add tests for more complex patterns /** @@ -374,13 +411,13 @@ public Class getColumnClass() { } private enum ProcedureTestData { - NORMAL_PROC_NO_RETURN("normal_proc_no_return", List.of(CREATE_NORMAL_PROC_NO_RETURN)) { + NORMAL_PROC_NO_RETURN("NORMAL_PROC_NO_RETURN", List.of(CREATE_NORMAL_PROC_NO_RETURN)) { @Override Map getSpecificValidationRules(Map rules) { rules.put(ProcedureMetaData.PROCEDURE_NAME, "NORMAL_PROC_NO_RETURN"); rules.put(ProcedureMetaData.PROCEDURE_TYPE, DatabaseMetaData.procedureNoResult); - rules.put(ProcedureMetaData.SPECIFIC_NAME, - ifSchemaElse("\"PUBLIC\".\"NORMAL_PROC_NO_RETURN\"", "NORMAL_PROC_NO_RETURN")); + rules.put(ProcedureMetaData.SPECIFIC_NAME, ifSchemaElse( + ObjectReference.of("PUBLIC", "NORMAL_PROC_NO_RETURN").toString(), "NORMAL_PROC_NO_RETURN")); return rules; } }, @@ -391,8 +428,8 @@ Map getSpecificValidationRules(Map getSpecificValidationRules(Map getSpecificValidationRules(Map rules) { rules.put(ProcedureMetaData.PROCEDURE_NAME, "quoted_proc_no_return"); rules.put(ProcedureMetaData.PROCEDURE_TYPE, DatabaseMetaData.procedureNoResult); - rules.put(ProcedureMetaData.SPECIFIC_NAME, - ifSchemaElse("\"PUBLIC\".\"quoted_proc_no_return\"", "quoted_proc_no_return")); + rules.put(ProcedureMetaData.SPECIFIC_NAME, ifSchemaElse( + ObjectReference.of("PUBLIC", "quoted_proc_no_return").toString(), "quoted_proc_no_return")); return rules; } }, @@ -414,8 +451,8 @@ Map getSpecificValidationRules(Map getSpecificValidationRules(Map rules) { + rules.put(ProcedureMetaData.PROCEDURE_SCHEM, "OTHER_SCHEMA"); + rules.put(ProcedureMetaData.PROCEDURE_NAME, "PROC_NO_RETURN"); + rules.put(ProcedureMetaData.PROCEDURE_TYPE, DatabaseMetaData.procedureNoResult); + rules.put(ProcedureMetaData.SPECIFIC_NAME, + ObjectReference.of("OTHER_SCHEMA", "PROC_NO_RETURN").toString()); + return rules; + } + @Override + boolean include(FirebirdSupportInfo supportInfo) { + return supportInfo.supportsSchemas(); + } + }, + ; + + private final String schema; private final String originalProcedureName; private final List createDDL; ProcedureTestData(String originalProcedureName, List createDDL) { + this(ifSchemaElse("PUBLIC", ""), originalProcedureName, createDDL); + } + + ProcedureTestData(String schema, String originalProcedureName, List createDDL) { + this.schema = schema; this.originalProcedureName = originalProcedureName; this.createDDL = createDDL; } /** - * @return Name of the procedure in as defined in the DDL script (including case). + * @return name of the schema (as stored in the metadata) + */ + String getSchema() { + return schema; + } + + /** + * @return name of the procedure (as stored in the metadata) */ String getName() { return originalProcedureName; From 099a4b27660eb0c0eb3ec015a91a19d6d345a390 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 22 Jul 2025 11:15:29 +0200 Subject: [PATCH 41/64] #882 Improve schema test coverage (getIndexInfo/getPrimaryKeys) --- .../jdbc/FBDatabaseMetaDataIndexInfoTest.java | 70 ++++++++--- .../FBDatabaseMetaDataPrimaryKeysTest.java | 114 ++++++++++++------ 2 files changed, 125 insertions(+), 59 deletions(-) diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java index 22780881c..0b7b79497 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java @@ -4,11 +4,14 @@ import org.firebirdsql.common.extension.RunEnvironmentExtension; import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.opentest4j.TestAbortedException; import java.nio.file.Files; @@ -27,6 +30,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalTo; @@ -41,8 +45,6 @@ */ class FBDatabaseMetaDataIndexInfoTest { - // TODO Add schema support: tests involving other schema - private static final String CREATE_INDEX_TEST_TABLE_1 = """ CREATE TABLE index_test_table_1 ( id INTEGER CONSTRAINT pk_idx_test_1_id PRIMARY KEY, @@ -85,6 +87,17 @@ CONSTRAINT fk_idx_test_2_column2_test_1 FOREIGN KEY (column2) REFERENCES index_t private static final String CREATE_PARTIAL_IDX_TBL_2 = "create index IDX_PARTIAL_IDX_TBL_2 on INDEX_TEST_TABLE_2 (COLUMN1) where COLUMN2 is not null"; + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + + private static final String CREATE_OTHER_SCHEMA_INDEX_TEST_TABLE_3 = """ + create table OTHER_SCHEMA.INDEX_TEST_TABLE_3 ( + ID integer constraint PK_IDX_TEST_3_ID primary key, + COLUMN1 VARCHAR(10) + )"""; + + private static final String CREATE_OTHER_SCHEMA_IDX_TBL3_COLUMN1 = + "create index OTHER_SCHEMA.IDX_TBL_3_COLUMN1 on OTHER_SCHEMA.INDEX_TEST_TABLE_3 (COLUMN1)"; + private static final MetadataResultSetDefinition getIndexInfoDefinition = new MetadataResultSetDefinition(IndexInfoMetaData.class); @@ -122,9 +135,16 @@ private static List getCreateStatements() { CREATE_IDX_TBL_2_COL1_AND_2, CREATE_DESC_COMPUTED_IDX_TBL_2, CREATE_UQ_DESC_IDX_TBL_2_COL3_AND_COL2)); - if (getDefaultSupportInfo().supportsPartialIndices()) { + FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); + if (supportInfo.supportsPartialIndices()) { statements.add(CREATE_PARTIAL_IDX_TBL_2); } + if (supportInfo.supportsSchemas()) { + statements.addAll(List.of( + CREATE_OTHER_SCHEMA, + CREATE_OTHER_SCHEMA_INDEX_TEST_TABLE_3, + CREATE_OTHER_SCHEMA_IDX_TBL3_COLUMN1)); + } return statements; } @@ -142,12 +162,10 @@ void testIndexInfoMetaDataColumns() throws Exception { /** * Tests getIndexInfo() for index_test_table_1 and unique false, expecting all indices, including those * defined by PK, FK and Unique constraint. - *

- * Secondary: uses lowercase name of the table and approximate false - *

*/ - @Test - void testIndexInfo_table1_all() throws Exception { + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void testIndexInfo_table1_all(boolean limitToSchema) throws Exception { List> expectedIndexInfo = new ArrayList<>(5); String tableName = "INDEX_TEST_TABLE_1"; expectedIndexInfo.add(createRule(tableName, true, "CMP_IDX_TEST_TABLE_1", "(UPPER(column1))", 1, true)); @@ -156,15 +174,13 @@ void testIndexInfo_table1_all() throws Exception { expectedIndexInfo.add(createRule(tableName, false, "UQ_COMP_IDX_TBL1", "(column2 + column3)", 1, true)); expectedIndexInfo.add(createRule(tableName, false, "UQ_IDX_TEST_1_COLUMN3", "COLUMN3", 1, true)); - ResultSet indexInfo = dbmd.getIndexInfo(null, null, "INDEX_TEST_TABLE_1", false, false); + ResultSet indexInfo = dbmd.getIndexInfo(null, limitToSchema ? ifSchemaElse("PUBLIC", "") : null, + "INDEX_TEST_TABLE_1", false, false); validate(indexInfo, expectedIndexInfo); } /** * Tests getIndexInfo() for index_test_table_1 and unique true, expecting only the unique indices. - *

- * Secondary: uses uppercase name of the table and approximate true - *

*/ @Test void testIndexInfo_table1_unique() throws Exception { @@ -181,9 +197,6 @@ void testIndexInfo_table1_unique() throws Exception { /** * Tests getIndexInfo() for index_test_table_2 and unique false, expecting all indices, including those * defined by PK, FK and Unique constraint. - *

- * Secondary: uses uppercase name of the table and approximate true - *

*/ @Test void testIndexInfo_table2_all() throws Exception { @@ -210,9 +223,6 @@ void testIndexInfo_table2_all() throws Exception { /** * Tests getIndexInfo() for index_test_table_2 and unique false, expecting all indices, including those * defined by PK, FK and Unique constraint. - *

- * Secondary: uses lowercase name of the table and approximate false - *

*/ @Test void testIndexInfo_table2_unique() throws Exception { @@ -233,7 +243,7 @@ void testIndexInfo_table2_unique() throws Exception { *

*

* This test is machine specific (or at least, environment-specific), as it requires a Firebird database with - * the path {@code E:\DB\FB4\FB4TESTDATABASE.FDB}. + * the path {@code C:\DATA\DB\FB4\FB4TESTDATABASE.FDB}. *

*/ @Test @@ -244,7 +254,7 @@ void indexInfoOfdOds13_0DbWithFirebirdSupportingPartialIndex(@TempDir Path tempD Path fb4DbPath; try { - fb4DbPath = Paths.get("E:/DB/FB4/FB4TESTDATABASE.FDB"); + fb4DbPath = Paths.get("C:/DATA/DB/FB4/FB4TESTDATABASE.FDB"); } catch (InvalidPathException e) { throw new TestAbortedException("Database path is invalid on this system", e); } @@ -265,6 +275,25 @@ void indexInfoOfdOds13_0DbWithFirebirdSupportingPartialIndex(@TempDir Path tempD } } } + + /** + * Tests getIndexInfo() for index_test_table_3 and unique false, expecting all indices, including those + * defined by PK constraint. + */ + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void testIndexInfo_table3_all(boolean limitToSchema) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + String schema = "OTHER_SCHEMA"; + String tableName = "INDEX_TEST_TABLE_3"; + var expectedIndexInfo = List.of( + createRule(schema, tableName, true, "IDX_TBL_3_COLUMN1", "COLUMN1", 1, true), + createRule(schema, tableName, false, "PK_IDX_TEST_3_ID", "ID", 1, true)); + + ResultSet indexInfo = dbmd + .getIndexInfo(null, limitToSchema ? schema : null, "INDEX_TEST_TABLE_3", false, false); + validate(indexInfo, expectedIndexInfo); + } // TODO Add tests with quoted identifiers @@ -314,6 +343,7 @@ private Map createRule(String schema, String tableNam * filter condition value * @return {@code rule} */ + @SuppressWarnings("SameParameterValue") private Map withFilterCondition(Map rule, String filterCondition) { rule.put(IndexInfoMetaData.FILTER_CONDITION, filterCondition); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java index 735fa96e7..836b443ec 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java @@ -3,16 +3,20 @@ package org.firebirdsql.jdbc; import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import java.util.Arrays; import java.util.EnumMap; import java.util.List; @@ -20,7 +24,9 @@ import static java.util.Collections.unmodifiableMap; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNoNextRow; @@ -31,44 +37,12 @@ */ class FBDatabaseMetaDataPrimaryKeysTest { - // TODO Add schema support: tests involving other schema - private static final String UNNAMED_CONSTRAINT_PREFIX = "INTEG_"; private static final String UNNAMED_PK_INDEX_PREFIX = "RDB$PRIMARY"; - //@formatter:off @RegisterExtension - static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( - """ - create table UNNAMED_SINGLE_COLUMN_PK ( - ID integer primary key - )""", - """ - create table UNNAMED_MULTI_COLUMN_PK ( - ID1 integer not null, - ID2 integer not null, - primary key (ID1, ID2) - )""", - """ - create table UNNAMED_PK_NAMED_INDEX ( - ID integer primary key using index ALT_NAMED_INDEX_3 - )""", - """ - create table NAMED_SINGLE_COLUMN_PK ( - ID integer constraint PK_NAMED_4 primary key - )""", - """ - create table NAMED_MULTI_COLUMN_PK ( - ID1 integer not null, - ID2 integer not null, - constraint PK_NAMED_5 primary key (ID1, ID2) - )""", - """ - create table NAMED_PK_NAMED_INDEX ( - ID integer constraint PK_NAMED_6 primary key using index ALT_NAMED_INDEX_6 - )""" - ); - //@formatter:on + static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = + UsesDatabaseExtension.usesDatabaseForAll(getDbInitStatements()); private static final MetadataResultSetDefinition getPrimaryKeysDefinition = new MetadataResultSetDefinition(PrimaryKeysMetaData.class); @@ -82,6 +56,48 @@ static void setupAll() throws SQLException { dbmd = con.getMetaData(); } + private static List getDbInitStatements() { + var statements = new ArrayList<>(List.of(""" + create table UNNAMED_SINGLE_COLUMN_PK ( + ID integer primary key + )""", + """ + create table UNNAMED_MULTI_COLUMN_PK ( + ID1 integer not null, + ID2 integer not null, + primary key (ID1, ID2) + )""", + """ + create table UNNAMED_PK_NAMED_INDEX ( + ID integer primary key using index ALT_NAMED_INDEX_3 + )""", + """ + create table NAMED_SINGLE_COLUMN_PK ( + ID integer constraint PK_NAMED_4 primary key + )""", + """ + create table NAMED_MULTI_COLUMN_PK ( + ID1 integer not null, + ID2 integer not null, + constraint PK_NAMED_5 primary key (ID1, ID2) + )""", + """ + create table NAMED_PK_NAMED_INDEX ( + ID integer constraint PK_NAMED_6 primary key using index ALT_NAMED_INDEX_6 + )""")); + if (getDefaultSupportInfo().supportsSchemas()) { + statements.addAll(List.of( + "create schema OTHER_SCHEMA", + """ + create table OTHER_SCHEMA.SCHEMA_NAMED_SINGLE_COLUMN_PK ( + ID integer constraint PK_NAMED_7 primary key + )""" + )); + } + + return statements; + } + @AfterAll static void tearDownAll() throws SQLException { try { @@ -102,10 +118,11 @@ void testPrimaryKeysMetaDataColumns() throws Exception { } } - @Test - void unnamedSingleColumnPk() throws Exception { - validateExpectedPrimaryKeys("UNNAMED_SINGLE_COLUMN_PK", List.of( - createPrimaryKeysRow("UNNAMED_SINGLE_COLUMN_PK", "ID", 1, UNNAMED_CONSTRAINT_PREFIX, + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void unnamedSingleColumnPk(boolean limitToSchema) throws Exception { + validateExpectedPrimaryKeys(limitToSchema ? ifSchemaElse("PUBLIC", "") : null, "UNNAMED_SINGLE_COLUMN_PK", + List.of(createPrimaryKeysRow("UNNAMED_SINGLE_COLUMN_PK", "ID", 1, UNNAMED_CONSTRAINT_PREFIX, UNNAMED_PK_INDEX_PREFIX))); } @@ -144,9 +161,23 @@ void namedPkNamedIndex() throws Exception { createPrimaryKeysRow("NAMED_PK_NAMED_INDEX", "ID", 1, "PK_NAMED_6", "ALT_NAMED_INDEX_6"))); } + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void schemaNamedSingleColumnPk(boolean limitToSchema) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + validateExpectedPrimaryKeys(limitToSchema ? "OTHER_SCHEMA" : null, "SCHEMA_NAMED_SINGLE_COLUMN_PK", + List.of(createPrimaryKeysRow("OTHER_SCHEMA", "SCHEMA_NAMED_SINGLE_COLUMN_PK", "ID", 1, "PK_NAMED_7", "PK_NAMED_7"))); + } + private static Map createPrimaryKeysRow(String tableName, String columnName, int keySeq, String pkName, String jbIndexName) { + return createPrimaryKeysRow(ifSchemaElse("PUBLIC", null), tableName, columnName, keySeq, pkName, jbIndexName); + } + + private static Map createPrimaryKeysRow(String schema, String tableName, + String columnName, int keySeq, String pkName, String jbIndexName) { Map rules = getDefaultValidationRules(); + rules.put(PrimaryKeysMetaData.TABLE_SCHEM, schema); rules.put(PrimaryKeysMetaData.TABLE_NAME, tableName); rules.put(PrimaryKeysMetaData.COLUMN_NAME, columnName); rules.put(PrimaryKeysMetaData.KEY_SEQ, (short) keySeq); @@ -159,7 +190,12 @@ private static Map createPrimaryKeysRow(String tabl private void validateExpectedPrimaryKeys(String tableName, List> expectedColumns) throws Exception { - try (ResultSet columns = dbmd.getPrimaryKeys(null, null, tableName)) { + validateExpectedPrimaryKeys(null, tableName, expectedColumns); + } + + private void validateExpectedPrimaryKeys(String schema, String tableName, + List> expectedColumns) throws Exception { + try (ResultSet columns = dbmd.getPrimaryKeys(null, schema, tableName)) { for (Map expectedColumn : expectedColumns) { assertNextRow(columns); getPrimaryKeysDefinition.validateRowValues(columns, expectedColumn); From 6a9febc2ffe1096f933be1c245be6568dfec61d8 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 22 Jul 2025 12:28:49 +0200 Subject: [PATCH 42/64] #882 Improve schema test coverage (getPseudoColumns/getTables) --- .../firebirdsql/common/FBTestProperties.java | 9 +- .../FBDatabaseMetaDataPseudoColumnsTest.java | 122 ++++++--- .../jdbc/FBDatabaseMetaDataTablesTest.java | 239 +++++++++--------- 3 files changed, 198 insertions(+), 172 deletions(-) diff --git a/src/test/org/firebirdsql/common/FBTestProperties.java b/src/test/org/firebirdsql/common/FBTestProperties.java index e34884dc5..68a396c47 100644 --- a/src/test/org/firebirdsql/common/FBTestProperties.java +++ b/src/test/org/firebirdsql/common/FBTestProperties.java @@ -383,15 +383,16 @@ public static T ifSchemaElse(T forSchema, T withoutSchema) { } /** - * Helper method that replaces {@code "PUBLIC"} with {@code ""} if schemas are not supported. + * Helper method that replaces {@code "PUBLIC"} or {@code "SYSTEM"} with {@code ""} if schemas are not supported. * * @param schemaName * schema name - * @return {@code schemaName}, or — if {@code schemaName} is {@code "PUBLIC"} and schemas are not supported - * — {@code ""} + * @return {@code schemaName}, or — if {@code schemaName} is {@code "PUBLIC"} or {@code "SYSTEM"} and schemas + * are not supported — {@code ""} */ public static String resolveSchema(String schemaName) { - if (!getDefaultSupportInfo().supportsSchemas() && "PUBLIC".equals(schemaName)) { + if (!getDefaultSupportInfo().supportsSchemas() + && ("PUBLIC".equals(schemaName) || "SYSTEM".equals(schemaName))) { return ""; } return schemaName; diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java index 0714e820b..679dbdb8e 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java @@ -3,10 +3,14 @@ package org.firebirdsql.jdbc; import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import java.sql.*; import java.util.*; @@ -14,59 +18,58 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FBTestProperties.resolveSchema; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeTrue; class FBDatabaseMetaDataPseudoColumnsTest { - // TODO Add schema support: tests involving other schema - - //@formatter:off private static final String NORMAL_TABLE_NAME = "NORMAL_TABLE"; - private static final String CREATE_NORMAL_TABLE = "create table " + NORMAL_TABLE_NAME + " ( " - + " ID integer primary key" - + ")"; - private static final String NORMAL_TABLE2_NAME = "NORMAL_TABLE2"; - private static final String CREATE_NORMAL_TABLE2 = "create table " + NORMAL_TABLE2_NAME + " ( " - + " ID integer primary key" - + ")"; + private static final String CREATE_NORMAL_TABLE = """ + create table NORMAL_TABLE ( + ID integer primary key + )"""; + private static final String CREATE_NORMAL_TABLE2 = """ + create table NORMAL_TABLE2 ( + ID integer primary key + )"""; private static final String SINGLE_VIEW_NAME = "SINGLE_VIEW"; private static final String CREATE_SINGLE_VIEW = - "create view " + SINGLE_VIEW_NAME + " as select id from " + NORMAL_TABLE_NAME; + "create view SINGLE_VIEW as select id from NORMAL_TABLE"; private static final String MULTI_VIEW_NAME = "MULTI_VIEW"; - private static final String CREATE_MULTI_VIEW = "create view " + MULTI_VIEW_NAME + " as " - + "select a.id as id1, b.id as id2 " - + "from " + NORMAL_TABLE_NAME + " as a, " + NORMAL_TABLE2_NAME + " as b"; + private static final String CREATE_MULTI_VIEW = + "create view MULTI_VIEW as select a.id as id1, b.id as id2 from NORMAL_TABLE as a, NORMAL_TABLE2 as b"; private static final String EXTERNAL_TABLE_NAME = "EXTERNAL_TABLE"; - private static final String CREATE_EXTERNAL_TABLE = "create table " + EXTERNAL_TABLE_NAME - + " external file 'test_external_tbl.dat' ( " - + " ID integer not null" - + ")"; + private static final String CREATE_EXTERNAL_TABLE = """ + create table EXTERNAL_TABLE + external file 'test_external_tbl.dat' ( + ID integer not null + )"""; private static final String GTT_PRESERVE_NAME = "GTT_PRESERVE"; - private static final String CREATE_GTT_PRESERVE = "create global temporary table " + GTT_PRESERVE_NAME + " (" - + " ID integer primary key" - + ") " - + " on commit preserve rows "; + private static final String CREATE_GTT_PRESERVE = """ + create global temporary table GTT_PRESERVE ( + ID integer primary key + ) on commit preserve rows"""; private static final String GTT_DELETE_NAME = "GTT_DELETE"; - private static final String CREATE_GTT_DELETE = "create global temporary table " + GTT_DELETE_NAME + " (" - + " ID integer primary key" - + ") " - + " on commit delete rows "; - //@formatter:on + private static final String CREATE_GTT_DELETE = """ + create global temporary table GTT_DELETE ( + ID integer primary key + ) on commit delete rows"""; + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + private static final String NORMAL_TABLE3_NAME = "NORMAL_TABLE3"; + private static final String CREATE_OTHER_SCHEMA_NORMAL_TABLE3 = """ + create table OTHER_SCHEMA.NORMAL_TABLE3 ( + ID integer primary key + )"""; private static final MetadataResultSetDefinition getPseudoColumnsDefinition = new MetadataResultSetDefinition(PseudoColumnMetaData.class); @RegisterExtension - static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( - CREATE_NORMAL_TABLE, - CREATE_NORMAL_TABLE2, - CREATE_SINGLE_VIEW, - CREATE_MULTI_VIEW, - CREATE_EXTERNAL_TABLE, - CREATE_GTT_PRESERVE, - CREATE_GTT_DELETE); + static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = + UsesDatabaseExtension.usesDatabaseForAll(getDbInitStatements()); private static final boolean supportsRecordVersion = getDefaultSupportInfo() .supportsRecordVersionPseudoColumn(); @@ -80,6 +83,24 @@ static void setupAll() throws SQLException { dbmd = con.getMetaData(); } + private static List getDbInitStatements() { + var statements = new ArrayList<>(List.of( + CREATE_NORMAL_TABLE, + CREATE_NORMAL_TABLE2, + CREATE_SINGLE_VIEW, + CREATE_MULTI_VIEW, + CREATE_EXTERNAL_TABLE, + CREATE_GTT_PRESERVE, + CREATE_GTT_DELETE)); + if (getDefaultSupportInfo().supportsSchemas()) { + statements.addAll(List.of( + CREATE_OTHER_SCHEMA, + CREATE_OTHER_SCHEMA_NORMAL_TABLE3)); + } + + return statements; + } + @AfterAll static void tearDownAll() throws SQLException { try { @@ -100,12 +121,14 @@ void testPseudoColumnsMetaDataColumns() throws Exception { } } - @Test - void testNormalTable_allPseudoColumns() throws Exception { + @ParameterizedTest + @NullSource + @ValueSource(strings = { "%", "PUBLIC" }) + void testNormalTable_allPseudoColumns(String schemaPattern) throws Exception { List> validationRules = createStandardValidationRules(NORMAL_TABLE_NAME, "NO"); - ResultSet pseudoColumns = dbmd.getPseudoColumns(null, null, NORMAL_TABLE_NAME, "%"); + ResultSet pseudoColumns = dbmd.getPseudoColumns(null, resolveSchema(schemaPattern), NORMAL_TABLE_NAME, "%"); validate(pseudoColumns, validationRules); } @@ -253,8 +276,9 @@ void testPattern_wildCardTable() throws Exception { tableCount += 1; } - // System tables + the 7 tables created for this test - assertEquals(getDefaultSupportInfo().getSystemTableCount() + 7, tableCount, + // System tables + the tables created for this test + int testTableCount = getDefaultSupportInfo().supportsSchemas() ? 8 : 7; + assertEquals(getDefaultSupportInfo().getSystemTableCount() + testTableCount, tableCount, "Unexpected number of pseudo columns"); } } @@ -267,12 +291,25 @@ void testPattern_nullTable() throws Exception { tableCount += 1; } - // System tables + the 7 tables created for this test - assertEquals(getDefaultSupportInfo().getSystemTableCount() + 7, tableCount, + // System tables + the tables created for this test + int testTableCount = getDefaultSupportInfo().supportsSchemas() ? 8 : 7; + assertEquals(getDefaultSupportInfo().getSystemTableCount() + testTableCount, tableCount, "Unexpected number of pseudo columns"); } } + @ParameterizedTest + @NullSource + @ValueSource(strings = { "%", "OTHER_SCHEMA", "OTHER\\_SCHEMA", "OTHER%" }) + void testOtherSchemaNormalTable2_allPseudoColumns(String schemaPattern) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + List> validationRules = + createStandardValidationRules("OTHER_SCHEMA", NORMAL_TABLE3_NAME, "NO"); + + ResultSet pseudoColumns = dbmd.getPseudoColumns(null, schemaPattern, NORMAL_TABLE3_NAME, "%"); + validate(pseudoColumns, validationRules); + } + private List> createStandardValidationRules(String tableName, String recordVersionNullable) { return createStandardValidationRules(ifSchemaElse("PUBLIC", null), tableName, recordVersionNullable); @@ -342,6 +379,7 @@ private Map createDbkeyValidationRules(String sche return rules; } + @SuppressWarnings("SameParameterValue") private Map createRecordVersionValidationRules(String tableName, String nullable) { return createRecordVersionValidationRules(ifSchemaElse("PUBLIC", null), tableName, nullable); } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java index f4bf1dfcb..fa2d0766b 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java @@ -3,10 +3,15 @@ package org.firebirdsql.jdbc; import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import java.sql.Connection; import java.sql.DatabaseMetaData; @@ -18,6 +23,8 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FBTestProperties.resolveSchema; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.hamcrest.CoreMatchers.anyOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -36,8 +43,6 @@ */ class FBDatabaseMetaDataTablesTest { - // TODO Add schema support: tests involving other schema - // Valid values for TABLE_TYPE (separate from those defined in FBDatabaseMetaData for testing) private static final String VIEW = "VIEW"; private static final String TABLE = "TABLE"; @@ -88,6 +93,14 @@ create global temporary table test_gtt_on_commit_preserve ( varchar_field VARCHAR(100) ) on commit delete rows"""; + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + + private static final String CREATE_OTHER_SCHEMA_NORMAL_TABLE2 = """ + create table OTHER_SCHEMA.TEST_NORMAL_TABLE2 ( + ID integer primary key, + VARCHAR_FIELD varchar(100) + )"""; + private static final MetadataResultSetDefinition getTablesDefinition = new MetadataResultSetDefinition(TableMetaData.class); @@ -121,10 +134,16 @@ private static List getCreateStatements() { CREATE_QUOTED_WITH_SLASH_NORMAL_TABLE, CREATE_NORMAL_VIEW, CREATE_QUOTED_NORMAL_VIEW)); - if (getDefaultSupportInfo().supportsGlobalTemporaryTables()) { + FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); + if (supportInfo.supportsGlobalTemporaryTables()) { createStatements.add(CREATE_GTT_ON_COMMIT_DELETE); createStatements.add(CREATE_GTT_ON_COMMIT_PRESERVE); } + if (supportInfo.supportsSchemas()) { + createStatements.addAll(List.of( + CREATE_OTHER_SCHEMA, + CREATE_OTHER_SCHEMA_NORMAL_TABLE2)); + } return createStatements; } @@ -139,53 +158,47 @@ void testTableMetaDataColumns() throws Exception { } } - /** - * Tests getTables() with tableName null and types null, expecting all - * tables of all types to be returned. - */ - @Test - void testTableMetaData_everything_tableName_null_types_null() throws Exception { - validateTableMetaData_everything(null, null); - } - - /** - * Tests getTables() with tableName null and types all (supported) types, - * expecting all tables of all types to be returned. - */ - @Test - void testTableMetaData_everything_tableName_null_allTypes() throws Exception { - validateTableMetaData_everything(null, new String[] { SYSTEM_TABLE, TABLE, VIEW, GLOBAL_TEMPORARY }); - } - - /** - * Tests getTables() with tableName all pattern (%) and types null, - * expecting all tables of all types to be returned. - */ - @Test - void testTableMetaData_everything_tableName_allPattern_types_null() throws Exception { - validateTableMetaData_everything("%", null); - } - - /** - * Helper method for test methods that retrieve table metadata for all - * tables of all types. - * - * @param tableNamePattern - * Pattern for the tableName (should be null, "%" only for this test) - * @param types - * Array of types to retrieve - */ - private void validateTableMetaData_everything(String tableNamePattern, String[] types) throws Exception { + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, tableNamePattern + , + %, + , % + %, % + """) + void testTableMetaData_everything_types_null(String schemaPattern, String tableNamePattern) throws Exception { + validateTableMetaData_everything(schemaPattern, tableNamePattern, null); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, tableNamePattern + , + %, + , % + %, % + """) + void testTableMetaData_everything_allTypes(String schemaPattern, String tableNamePattern) throws Exception { + validateTableMetaData_everything(schemaPattern, tableNamePattern, + new String[] { SYSTEM_TABLE, TABLE, VIEW, GLOBAL_TEMPORARY }); + } + + private void validateTableMetaData_everything(String schemaPattern, String tableNamePattern, String[] types) + throws Exception { // Expected user tables + a selection of expected system tables (some that existed in Firebird 1.0) // TODO Add test for order? Set expectedTables = new HashSet<>(Arrays.asList("TEST_NORMAL_TABLE", "test_quoted_normal_table", "testquotedwith\\table", "TEST_NORMAL_VIEW", "test_quoted_normal_view", "RDB$FIELDS", "RDB$GENERATORS", "RDB$ROLES", "RDB$DATABASE", "RDB$TRIGGERS")); - if (getDefaultSupportInfo().supportsGlobalTemporaryTables()) { + FirebirdSupportInfo supportInfo = getDefaultSupportInfo(); + if (supportInfo.supportsGlobalTemporaryTables()) { expectedTables.add("TEST_GTT_ON_COMMIT_DELETE"); expectedTables.add("TEST_GTT_ON_COMMIT_PRESERVE"); } - try (ResultSet tables = dbmd.getTables(null, null, tableNamePattern, types)) { + if (supportInfo.supportsSchemas()) { + expectedTables.add("TEST_NORMAL_TABLE2"); + } + try (ResultSet tables = dbmd.getTables(null, schemaPattern, tableNamePattern, types)) { while (tables.next()) { String tableName = tables.getString(TableMetaData.TABLE_NAME.name()); Map rules = getDefaultValueValidationRules(); @@ -202,43 +215,40 @@ private void validateTableMetaData_everything(String tableNamePattern, String[] } /** - * Tests getTables with tableName null and types SYSTEM TABLES, expecting - * only system tables to be returned. + * Tests getTables with a tableName all-pattern and types SYSTEM TABLES, expecting only system tables to be + * returned. *

* This method only checks the existence of a subset of the system tables *

*/ - @Test - void testTableMetaData_allSystemTables_tableName_null() throws Exception { - validateTableMetaData_allSystemTables(null); - } - - /** - * Tests getTables with tableName all pattern (%) and types SYSTEM TABLES, - * expecting only system tables to be returned. - *

- * This method only checks the existence of a subset of the system tables - *

- */ - @Test - void testTableMetaData_allSystemTables_tableName_allPattern() throws Exception { - validateTableMetaData_allSystemTables("%"); + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, tableNamePattern + , + %, % + SYSTEM, + SYSTEM, % + """) + void testTableMetaData_allSystemTables_tableName_null(String schemaPattern, String tableNamePattern) + throws Exception { + validateTableMetaData_allSystemTables(schemaPattern, tableNamePattern); } /** * Helper method for test methods that retrieve table metadata of all system tables. * * @param tableNamePattern - * Pattern for the tableName (should be null or"%" only for this test) + * Pattern for the tableName (should be null or "%" only for this test) */ - private void validateTableMetaData_allSystemTables(String tableNamePattern) throws Exception { + private void validateTableMetaData_allSystemTables(String schemaPattern, String tableNamePattern) throws Exception { // Expected selection of expected system tables (some that existed in Firebird 1.0); we don't check all system tables Set expectedTables = new HashSet<>(Arrays.asList("RDB$FIELDS", "RDB$GENERATORS", "RDB$ROLES", "RDB$DATABASE", "RDB$TRIGGERS")); Map rules = getDefaultValueValidationRules(); rules.put(TableMetaData.TABLE_SCHEM, ifSchemaElse("SYSTEM", null)); rules.put(TableMetaData.TABLE_TYPE, SYSTEM_TABLE); - try (ResultSet tables = dbmd.getTables(null, null, tableNamePattern, new String[] { SYSTEM_TABLE })) { + try (ResultSet tables = dbmd.getTables(null, resolveSchema(schemaPattern), tableNamePattern, + new String[] { SYSTEM_TABLE })) { while (tables.next()) { String tableName = tables.getString(TableMetaData.TABLE_NAME.name()); assertThat("TABLE_NAME is not allowed to be null or empty", tableName, not(emptyString())); @@ -255,41 +265,21 @@ private void validateTableMetaData_allSystemTables(String tableNamePattern) thro } /** - * Tests getTables with tableName null and types TABLE, expecting - * only normal tables to be returned. + * Tests getTables with tableName all-pattern and types TABLE, expecting only normal tables to be returned. *

* This method only checks the existence of a subset of the normal tables *

*/ - @Test - void testTableMetaData_allNormalTables_tableName_null() throws Exception { - validateTableMetaData_allNormalTables(null); - } - - /** - * Tests getTables with tableName all pattern (%) and types TABLE, - * expecting only normal tables to be returned. - *

- * This method only checks the existence of a subset of the normal tables - *

- */ - @Test - void testTableMetaData_allNormalTables_tableName_allPattern() throws Exception { - validateTableMetaData_allNormalTables("%"); - } - - /** - * Helper method for test methods that retrieve table metadata of all normal tables. - * - * @param tableNamePattern - * Pattern for the tableName (should be null, or "%" only for this test) - */ - private void validateTableMetaData_allNormalTables(String tableNamePattern) throws Exception { + @ParameterizedTest + @NullSource + @ValueSource(strings = "%") + void testTableMetaData_allNormalTables(String tableNamePattern) throws Exception { Set expectedNormalTables = new HashSet<>(Arrays.asList("TEST_NORMAL_TABLE", "test_quoted_normal_table", "testquotedwith\\table")); + if (getDefaultSupportInfo().supportsSchemas()) { + expectedNormalTables.add("TEST_NORMAL_TABLE2"); + } Set retrievedTables = new HashSet<>(); - Map rules = getDefaultValueValidationRules(); - rules.put(TableMetaData.TABLE_TYPE, TABLE); try (ResultSet tables = dbmd.getTables(null, null, tableNamePattern, new String[] { TABLE })) { while (tables.next()) { String tableName = tables.getString(TableMetaData.TABLE_NAME.name()); @@ -298,6 +288,8 @@ private void validateTableMetaData_allNormalTables(String tableNamePattern) thro assertThat("Only expect normal tables, not starting with RDB$, MON$ or SEC$", tableName, not(anyOf(startsWith("RDB$"), startsWith("MON$"), startsWith("SEC$")))); + Map rules = getDefaultValueValidationRules(); + updateTableRules(tableName, rules); getTablesDefinition.validateRowValues(tables, rules); } @@ -307,36 +299,15 @@ private void validateTableMetaData_allNormalTables(String tableNamePattern) thro } /** - * Tests getTables with tableName null and types VIEW, expecting - * only views to be returned. + * Tests getTables with tableName all-pattern and types VIEW, expecting only views to be returned. *

* This method only checks the existence of a subset of the views *

*/ - @Test - void testTableMetaData_allViews_tableName_null() throws Exception { - validateTableMetaData_allViews(null); - } - - /** - * Tests getTables with tableName all pattern (%) and types VIEW, - * expecting only views to be returned. - *

- * This method only checks the existence of a subset of the views - *

- */ - @Test - void testTableMetaData_allViews_tableName_allPattern() throws Exception { - validateTableMetaData_allViews("%"); - } - - /** - * Helper method for test methods that retrieve table metadata of all view tables. - * - * @param tableNamePattern - * Pattern for the tableName (should be null or "%" only for this test) - */ - private void validateTableMetaData_allViews(String tableNamePattern) throws Exception { + @ParameterizedTest + @NullSource + @ValueSource(strings = "%") + void testTableMetaData_allViews_tableName_null(String tableNamePattern) throws Exception { Set expectedViews = new HashSet<>(Arrays.asList("TEST_NORMAL_VIEW", "test_quoted_normal_view")); Set retrievedTables = new HashSet<>(); Map rules = getDefaultValueValidationRules(); @@ -438,21 +409,31 @@ void testTableMetaData_NormalQuotedTable_AllTypes() throws Exception { } /** - * Helper method for test methods that retrieve a single metadata row. - * - * @param tableNamePattern - * Pattern of the tablename - * @param types - * Table types to request - * @param validationRules - * Total (all required rows) map of the value validation rules - * for the single row. + * Tests getTables retrieving normal table that was created unquoted using + * its upper case name with types TABLE. */ + @ParameterizedTest + @NullSource + @ValueSource(strings = { "%", "OTHER_SCHEMA", "OTHER\\_SCHEMA", "OTHER%" }) + void testTableMetaData_OtherSchemaNormalTable_typesTABLE(String schemaPattern) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + Map validationRules = getDefaultValueValidationRules(); + validationRules.put(TableMetaData.TABLE_TYPE, TABLE); + validationRules.put(TableMetaData.TABLE_SCHEM, "OTHER_SCHEMA"); + validationRules.put(TableMetaData.TABLE_NAME, "TEST_NORMAL_TABLE2"); + + validateTableMetaDataSingleRow(schemaPattern, "TEST_NORMAL_TABLE2", new String[] { TABLE }, validationRules); + } + private void validateTableMetaDataSingleRow(String tableNamePattern, String[] types, Map validationRules) throws Exception { + validateTableMetaDataSingleRow(ifSchemaElse("PUBLIC", ""), tableNamePattern, types, validationRules); + } + private void validateTableMetaDataSingleRow(String schemaPattern, String tableNamePattern, String[] types, + Map validationRules) throws Exception { getTablesDefinition.checkValidationRulesComplete(validationRules); - try (ResultSet tables = dbmd.getTables(null, null, tableNamePattern, types)) { + try (ResultSet tables = dbmd.getTables(null, schemaPattern, tableNamePattern, types)) { assertTrue(tables.next(), "Expected row in table metadata"); getTablesDefinition.validateRowValues(tables, validationRules); assertFalse(tables.next(), "Expected only one row in result set"); @@ -514,6 +495,9 @@ void testTableMetaData_exceptSystemTable_sorted() throws Exception { expectedTables.add("TEST_GTT_ON_COMMIT_DELETE"); expectedTables.add("TEST_GTT_ON_COMMIT_PRESERVE"); } + if (getDefaultSupportInfo().supportsSchemas()) { + expectedTables.add("TEST_NORMAL_TABLE2"); + } expectedTables.add("TEST_NORMAL_TABLE"); expectedTables.add("test_quoted_normal_table"); expectedTables.add("testquotedwith\\table"); @@ -563,6 +547,9 @@ private void updateTableRules(String tableName, Map rules rules.put(TableMetaData.TABLE_TYPE, VIEW); } else if (tableName.startsWith("TEST_GTT")) { rules.put(TableMetaData.TABLE_TYPE, GLOBAL_TEMPORARY); + } else if (tableName.equals("TEST_NORMAL_TABLE2")) { + rules.put(TableMetaData.TABLE_SCHEM, "OTHER_SCHEMA"); + rules.put(TableMetaData.TABLE_TYPE, TABLE); } else { // Make sure we don't accidentally miss a table fail("Unexpected TABLE_NAME: " + tableName); From 48f4b7c42d36827a08f8a3f6b0447780114fff30 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 22 Jul 2025 13:12:37 +0200 Subject: [PATCH 43/64] #882 Improve schema test coverage (getTablePrivileges) --- ...FBDatabaseMetaDataTablePrivilegesTest.java | 178 +++++++++++++++--- 1 file changed, 153 insertions(+), 25 deletions(-) diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java index ebed9a646..ec4762d7e 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java @@ -4,16 +4,19 @@ import org.firebirdsql.common.FBTestProperties; import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.Arrays; +import java.util.ArrayList; import java.util.Collections; import java.util.EnumMap; import java.util.List; @@ -22,6 +25,8 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; +import static org.firebirdsql.common.FBTestProperties.resolveSchema; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -33,22 +38,14 @@ */ class FBDatabaseMetaDataTablePrivilegesTest { - // TODO Add schema support: tests involving other schema - private static final String SYSDBA = "SYSDBA"; private static final String USER1 = "USER1"; private static final String user2 = getDefaultSupportInfo().supportsCaseSensitiveUserNames() ? "user2" : "USER2"; private static final String PUBLIC = "PUBLIC"; @RegisterExtension - static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( - "create table TBL1 (COL1 integer, COL2 varchar(50), \"val3\" varchar(50))", - "create table \"tbl2\" (COL1 integer, COL2 varchar(50), \"val3\" varchar(50))", - "grant all on TBL1 to USER1", - "grant select on TBL1 to PUBLIC", - "grant update (COL1, \"val3\") on TBL1 to \"user2\"", - "grant select on \"tbl2\" to \"user2\" with grant option", - "grant references (COL1) on \"tbl2\" to USER1"); + static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = + UsesDatabaseExtension.usesDatabaseForAll(createDbInitStatements()); private static final MetadataResultSetDefinition getTablePrivilegesDefinition = new MetadataResultSetDefinition(TablePrivilegesMetadata.class); @@ -64,6 +61,26 @@ static void setupAll() throws SQLException { dbmd = con.getMetaData(); } + private static List createDbInitStatements() { + var statements = new ArrayList<>(List.of( + "create table TBL1 (COL1 integer, COL2 varchar(50), \"val3\" varchar(50))", + "create table \"tbl2\" (COL1 integer, COL2 varchar(50), \"val3\" varchar(50))", + "grant all on TBL1 to USER1", + "grant select on TBL1 to PUBLIC", + "grant update (COL1, \"val3\") on TBL1 to \"user2\"", + "grant select on \"tbl2\" to \"user2\" with grant option", + "grant references (COL1) on \"tbl2\" to USER1")); + if (getDefaultSupportInfo().supportsSchemas()) { + statements.addAll(List.of( + "create schema OTHER_SCHEMA", + "create table OTHER_SCHEMA.TBL3 (COL1 integer, COL2 varchar(50))", + "grant all on OTHER_SCHEMA.TBL3 to USER1", + "grant select on OTHER_SCHEMA.TBL3 to \"user2\"")); + } + + return statements; + } + @AfterAll static void tearDownAll() throws SQLException { try { @@ -84,9 +101,23 @@ void testTablePrivilegesMetaDataColumns() throws Exception { } } - @Test - void testTablePrivileges_TBL1_all() throws Exception { - List> rules = Arrays.asList( + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, tableNamePattern + , TBL1 + %, TBL1 + PUBLIC, TBL1 + #NOTE: Only works because there is no other TBL_ in default schema + PUBLIC, TBL_ + """) + void testTablePrivileges_TBL1_all(String schemaPattern, String tableNamePattern) throws Exception { + List> rules = getTBL1_all(); + + validateExpectedColumnPrivileges(schemaPattern, tableNamePattern, rules); + } + + private List> getTBL1_all() { + return List.of( createRule("TBL1", SYSDBA, true, "DELETE"), createRule("TBL1", USER1, false, "DELETE"), createRule("TBL1", SYSDBA, true, "INSERT"), @@ -99,13 +130,25 @@ void testTablePrivileges_TBL1_all() throws Exception { createRule("TBL1", SYSDBA, true, "UPDATE"), createRule("TBL1", USER1, false, "UPDATE"), createRule("TBL1", user2, false, "UPDATE")); + } - validateExpectedColumnPrivileges("TBL1", rules); + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, tableNamePattern + , tbl2 + %, tbl2 + PUBLIC, tbl2 + #NOTE: Only works because there is no other tbl_ in default schema + PUBLIC, tbl_ + """) + void testColumnPrivileges_tbl2_all(String schemaPattern, String tableNamePattern) throws Exception { + List> rules = getTbl2_all(); + + validateExpectedColumnPrivileges(schemaPattern, tableNamePattern, rules); } - @Test - void testColumnPrivileges_tbl2_all() throws Exception { - List> rules = Arrays.asList( + private List> getTbl2_all() { + return List.of( createRule("tbl2", SYSDBA, true, "DELETE"), createRule("tbl2", SYSDBA, true, "INSERT"), createRule("tbl2", SYSDBA, true, "REFERENCES"), @@ -113,8 +156,86 @@ void testColumnPrivileges_tbl2_all() throws Exception { createRule("tbl2", SYSDBA, true, "SELECT"), createRule("tbl2", user2, true, "SELECT"), createRule("tbl2", SYSDBA, true, "UPDATE")); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, tableNamePattern + , TBL3 + %, TBL3 + OTHER_SCHEMA, TBL3 + OTHER\\_SCHEMA, TBL3 + OTHER%, TBL3 + #NOTE: Only works because there is no other TBL_ in OTHER_SCHEMA + OTHER_SCHEMA, TBL_ + """) + void testColumnPrivileges_otherSchemaTBL3_all(String schemaPattern, String tableNamePattern) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + List> rules = getTBL3_all(); + + validateExpectedColumnPrivileges(schemaPattern, tableNamePattern, rules); + } - validateExpectedColumnPrivileges("tbl2", rules); + private List> getTBL3_all() { + return List.of( + createRule("OTHER_SCHEMA", "TBL3", SYSDBA, true, "DELETE"), + createRule("OTHER_SCHEMA", "TBL3", USER1, false, "DELETE"), + createRule("OTHER_SCHEMA", "TBL3", SYSDBA, true, "INSERT"), + createRule("OTHER_SCHEMA", "TBL3", USER1, false, "INSERT"), + createRule("OTHER_SCHEMA", "TBL3", SYSDBA, true, "REFERENCES"), + createRule("OTHER_SCHEMA", "TBL3", USER1, false, "REFERENCES"), + createRule("OTHER_SCHEMA", "TBL3", SYSDBA, true, "SELECT"), + createRule("OTHER_SCHEMA", "TBL3", USER1, false, "SELECT"), + createRule("OTHER_SCHEMA", "TBL3", user2, false, "SELECT"), + createRule("OTHER_SCHEMA", "TBL3", SYSDBA, true, "UPDATE"), + createRule("OTHER_SCHEMA", "TBL3", USER1, false, "UPDATE")); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, tableNamePattern + , + %, + , % + %, % + """) + void testColumnPrivileges_all(String schemaPattern, String tableNamePattern) throws Exception { + var rules = new ArrayList>(); + if (getDefaultSupportInfo().supportsSchemas()) { + rules.addAll(getTBL3_all()); + } + rules.addAll(getTBL1_all()); + rules.addAll(getTbl2_all()); + + validateExpectedColumnPrivileges(schemaPattern, tableNamePattern, rules); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, tableNamePattern + PUBLIC, + PUBLIC, % + """) + void testColumnPrivileges_defaultSchema_all(String schemaPattern, String tableNamePattern) throws Exception { + var rules = new ArrayList>(); + rules.addAll(getTBL1_all()); + rules.addAll(getTbl2_all()); + + validateExpectedColumnPrivileges(schemaPattern, tableNamePattern, rules); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + schemaPattern, tableNamePattern + OTHER_SCHEMA, + OTHER\\_SCHEMA, % + OTHER%, + """) + void testColumnPrivileges_otherSchema_all(String schemaPattern, String tableNamePattern) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + List> rules = getTBL3_all(); + + validateExpectedColumnPrivileges(schemaPattern, tableNamePattern, rules); } private Map createRule(String tableName, String grantee, boolean grantable, @@ -133,16 +254,16 @@ private Map createRule(String schema, String ta return rules; } - private void validateExpectedColumnPrivileges(String tableNamePattern, - List> expectedTablePrivileges) throws SQLException { - validateExpectedColumnPrivileges(null, tableNamePattern, expectedTablePrivileges); - } - private void validateExpectedColumnPrivileges(String schemaPattern, String tableNamePattern, List> expectedTablePrivileges) throws SQLException { - try (ResultSet tablePrivileges = dbmd.getTablePrivileges(null, schemaPattern, tableNamePattern)) { + try (ResultSet tablePrivileges = dbmd.getTablePrivileges(null, resolveSchema(schemaPattern), tableNamePattern)) { int privilegeCount = 0; while (tablePrivileges.next()) { + if (isProbablySystemTable(tablePrivileges.getString("TABLE_SCHEM"), + tablePrivileges.getString("TABLE_NAME"))) { + // skip system tables + continue; + } if (privilegeCount < expectedTablePrivileges.size()) { Map rules = expectedTablePrivileges.get(privilegeCount); getTablePrivilegesDefinition.checkValidationRulesComplete(rules); @@ -154,6 +275,13 @@ private void validateExpectedColumnPrivileges(String schemaPattern, String table } } + private static boolean isProbablySystemTable(String schema, String tableName) { + return "SYSTEM".equals(schema) + || tableName.startsWith("RDB$") + || tableName.startsWith("MON$") + || tableName.startsWith("SEC$"); + } + private static final Map DEFAULT_TABLE_PRIVILEGES_VALUES; static { Map defaults = new EnumMap<>(TablePrivilegesMetadata.class); From 24478cf3c9efde88d44b2fb24f41ea8c5e7f4ff6 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 29 Jul 2025 09:49:15 +0200 Subject: [PATCH 44/64] #882 schema support for generated keys --- .../jaybird/parser/StatementDetector.java | 90 ++++++-- .../parser/StatementIdentification.java | 56 ++++- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 23 +++ .../jdbc/FirebirdDatabaseMetaData.java | 52 ++++- .../jdbc/GeneratedKeysQueryBuilder.java | 87 ++++---- .../jaybird/parser/GrammarTest.java | 194 ++++++++++++------ .../jaybird/parser/StatementDetectorTest.java | 58 +++++- ...FBDatabaseMetaDataFindTableSchemaTest.java | 131 ++++++++++++ .../FBPreparedStatementGeneratedKeysTest.java | 104 +++++++--- .../jdbc/FBStatementGeneratedKeysTest.java | 136 ++++++++---- .../jdbc/FBTestGeneratedKeysBase.java | 51 ++++- .../jdbc/GeneratedKeysQueryTest.java | 16 +- 12 files changed, 779 insertions(+), 219 deletions(-) create mode 100644 src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFindTableSchemaTest.java diff --git a/src/main/org/firebirdsql/jaybird/parser/StatementDetector.java b/src/main/org/firebirdsql/jaybird/parser/StatementDetector.java index 649577137..df9f3cba8 100644 --- a/src/main/org/firebirdsql/jaybird/parser/StatementDetector.java +++ b/src/main/org/firebirdsql/jaybird/parser/StatementDetector.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2021-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2021-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.parser; @@ -14,8 +14,8 @@ * Detects the type of statement, and - optionally - whether a DML statement has a {@code RETURNING} clause. *

* If the detected statement type is {@code UPDATE}, {@code DELETE}, {@code INSERT}, {@code UPDATE OR INSERT} and - * {@code MERGE}, it identifies the affected table and - optionally - whether or not a {@code RETURNING} clause is - * present (delegated to a {@link ReturningClauseDetector}). + * {@code MERGE}, it identifies the affected table and - optionally - if a {@code RETURNING} clause is present + * (delegated to a {@link ReturningClauseDetector}). *

*

* The types of statements detected are informed by the needs of Jaybird, and may change between point releases. @@ -27,8 +27,6 @@ @InternalApi public final class StatementDetector implements TokenVisitor { - // TODO Add schema support: identify (optional) schema - private static final StateAfterStart INITIAL_OTHER = new StateAfterStart(ParserState.OTHER, LocalStatementType.OTHER); private static final Map NEXT_AFTER_START; @@ -55,6 +53,7 @@ public final class StatementDetector implements TokenVisitor { private final boolean detectReturning; private LocalStatementType statementType = LocalStatementType.UNKNOWN; private ParserState parserState = ParserState.START; + private Token schemaToken; private Token tableNameToken; private ReturningClauseDetector returningClauseDetector; @@ -105,10 +104,13 @@ public void visitToken(Token token, VisitorRegistrar visitorRegistrar) { if (parserState.isFinalState()) { // We're not interested anymore visitorRegistrar.removeVisitor(this); - } else if (parserState == ParserState.FIND_RETURNING) { - // We're not interested anymore - visitorRegistrar.removeVisitor(this); - if (detectReturning) { + } else if (parserState == ParserState.FIND_RETURNING + || parserState == ParserState.FIND_SCHEMA_SEPARATOR_OR_RETURNING) { + if (parserState == ParserState.FIND_RETURNING) { + // We're not interested anymore + visitorRegistrar.removeVisitor(this); + } + if (detectReturning && returningClauseDetector == null) { // Use ReturningClauseDetector to handle detection returningClauseDetector = new ReturningClauseDetector(); visitorRegistrar.addVisitor(returningClauseDetector); @@ -124,8 +126,7 @@ public void complete(VisitorRegistrar visitorRegistrar) { } public StatementIdentification toStatementIdentification() { - return new StatementIdentification(statementType, tableNameToken != null ? tableNameToken.text() : null, - returningClauseDetected()); + return new StatementIdentification(statementType, schemaToken, tableNameToken, returningClauseDetected()); } boolean returningClauseDetected() { @@ -139,6 +140,10 @@ public LocalStatementType getStatementType() { return statementType; } + Token getSchemaToken() { + return schemaToken; + } + Token getTableNameToken() { return tableNameToken; } @@ -151,6 +156,10 @@ private void updateStatementType(LocalStatementType statementType) { } } + private void setSchemaToken(Token schemaToken) { + this.schemaToken = schemaToken; + } + private void setTableNameToken(Token tableNameToken) { this.tableNameToken = tableNameToken; } @@ -213,12 +222,46 @@ ParserState next(Token token, StatementDetector detector) { }, // Shared by UPDATE, DELETE and MERGE DML_TARGET { + @Override + ParserState next(Token token, StatementDetector detector) { + if (token.isValidIdentifier()) { + detector.setTableNameToken(token); + return DML_SCHEMA_SEPARATOR_OR_POSSIBLE_ALIAS; + } + return forceOther(detector); + } + }, + // Shared by UPDATE, DELETE and MERGE + DML_SCHEMA_SEPARATOR_OR_POSSIBLE_ALIAS { + @Override + ParserState next(Token token, StatementDetector detector) { + if (token instanceof PeriodToken) { + // What was detected as table, is actually the schema + detector.setSchemaToken(detector.getTableNameToken()); + detector.setTableNameToken(null); + return DML_SCHEMA_QUALIFIED_TABLE_NAME; + } else if (token.isValidIdentifier()) { + // either alias or possibly returning clause + return FIND_RETURNING; + } else if (token instanceof ReservedToken) { + if (token.equalsIgnoreCase("AS")) { + return DML_ALIAS; + } + return FIND_RETURNING; + } + // Unexpected or invalid token at this point + return forceOther(detector); + } + }, + // Shared by UPDATE, DELETE and MERGE + DML_SCHEMA_QUALIFIED_TABLE_NAME { @Override ParserState next(Token token, StatementDetector detector) { if (token.isValidIdentifier()) { detector.setTableNameToken(token); return DML_POSSIBLE_ALIAS; } + // Unexpected or invalid token at this point return forceOther(detector); } }, @@ -263,7 +306,7 @@ ParserState next(Token token, StatementDetector detector) { ParserState next(Token token, StatementDetector detector) { if (token.isValidIdentifier()) { detector.setTableNameToken(token); - return FIND_RETURNING; + return FIND_SCHEMA_SEPARATOR_OR_RETURNING; } // Syntax error return forceOther(detector); @@ -279,6 +322,29 @@ ParserState next(Token token, StatementDetector detector) { return forceOther(detector); } }, + FIND_SCHEMA_SEPARATOR_OR_RETURNING { + @Override + ParserState next(Token token, StatementDetector detector) { + if (token instanceof PeriodToken) { + detector.setSchemaToken(detector.getTableNameToken()); + detector.setTableNameToken(null); + return FIND_SCHEMA_QUALIFIED_TABLE_OR_RETURNING; + } else { + return FIND_RETURNING; + } + } + }, + FIND_SCHEMA_QUALIFIED_TABLE_OR_RETURNING { + @Override + ParserState next(Token token, StatementDetector detector) { + if (token.isValidIdentifier()) { + detector.setTableNameToken(token); + return FIND_RETURNING; + } + // Syntax error + return forceOther(detector); + } + }, // finding itself is offloaded to ReturningClauseDetector FIND_RETURNING, COMMIT_ROLLBACK { diff --git a/src/main/org/firebirdsql/jaybird/parser/StatementIdentification.java b/src/main/org/firebirdsql/jaybird/parser/StatementIdentification.java index e4d7cbca3..4c5d3527d 100644 --- a/src/main/org/firebirdsql/jaybird/parser/StatementIdentification.java +++ b/src/main/org/firebirdsql/jaybird/parser/StatementIdentification.java @@ -1,8 +1,12 @@ -// SPDX-FileCopyrightText: Copyright 2021-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2021-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.parser; import org.firebirdsql.util.InternalApi; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Locale; import static java.util.Objects.requireNonNull; @@ -13,15 +17,19 @@ * @since 5 */ @InternalApi +@NullMarked public final class StatementIdentification { private final LocalStatementType statementType; - private final String tableName; + private final @Nullable String schema; + private final @Nullable String tableName; private final boolean returningClauseDetected; - StatementIdentification(LocalStatementType statementType, String tableName, boolean returningClauseDetected) { + StatementIdentification(LocalStatementType statementType, @Nullable Token schema, @Nullable Token tableName, + boolean returningClauseDetected) { this.statementType = requireNonNull(statementType, "statementType"); - this.tableName = tableName; + this.schema = normalizeObjectName(schema); + this.tableName = normalizeObjectName(tableName); this.returningClauseDetected = returningClauseDetected; } @@ -29,16 +37,54 @@ public LocalStatementType getStatementType() { return statementType; } + /** + * Schema, if this is a DML statement (other than {@code SELECT}), and if the table is qualified. + *

+ * It reports the name normalized to its metadata storage representation. + *

+ * + * @return Schema, {@code null} if the table was not qualified, or for {@code SELECT} and other non-DML statements + */ + public @Nullable String getSchema() { + return schema; + } + /** * Table name, if this is a DML statement (other than {@code SELECT}). + *

+ * It reports the name normalized to its metadata storage representation. + *

* * @return Table name, {@code null} for {@code SELECT} and other non-DML statements */ - public String getTableName() { + public @Nullable String getTableName() { return tableName; } public boolean returningClauseDetected() { return returningClauseDetected; } + + /** + * Normalizes an object name from the parser to its storage representation. + *

+ * Unquoted identifiers are uppercased, and quoted identifiers are returned with the quotes stripped and doubled + * double quotes replaced by a single double quote. + *

+ * + * @param objectToken + * token with the object name (can be {@code null}) + * @return normalized object name, or {@code null} if {@code objectToken} was {@code null} + */ + private static @Nullable String normalizeObjectName(@Nullable Token objectToken) { + if (objectToken == null) return null; + String objectName = objectToken.text().trim(); + if (objectName.length() > 2 + && objectName.charAt(0) == '"' + && objectName.charAt(objectName.length() - 1) == '"') { + return objectName.substring(1, objectName.length() - 1).replace("\"\"", "\""); + } + return objectName.toUpperCase(Locale.ROOT); + } + } diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index c294702cd..4f1c8ca48 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1258,6 +1258,29 @@ private GetTables createGetTablesInstance() { return GetTables.create(getDbMetadataMediator()); } + @Override + public Optional findTableSchema(String tableName) throws SQLException { + if (!supportsSchemasInDataManipulation()) return Optional.of(""); + final String findSchema = """ + with SEARCH_PATH as ( + select row_number() over() as PRIO, NAME as SCHEMA_NAME + from SYSTEM.RDB$SQL.PARSE_UNQUALIFIED_NAMES(rdb$get_context('SYSTEM', 'SEARCH_PATH')) + ) + select r.RDB$SCHEMA_NAME + from RDB$RELATIONS as r + inner join SEARCH_PATH s on r.RDB$SCHEMA_NAME = s.SCHEMA_NAME and r.RDB$RELATION_NAME = ? + order by s.PRIO + fetch first row only"""; + + var metadataQuery = new DbMetadataMediator.MetadataQuery(findSchema, List.of(tableName)); + try (ResultSet rs = getDbMetadataMediator().performMetaDataQuery(metadataQuery)) { + if (rs.next()) { + return Optional.of(rs.getString(1)); + } + return Optional.empty(); + } + } + @Override public ResultSet getSchemas() throws SQLException { return getSchemas(null, null); diff --git a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java index 3cf49ef2a..a231a640a 100644 --- a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java @@ -1,17 +1,18 @@ // SPDX-FileCopyrightText: Copyright 2005 Michael Romankiewicz // SPDX-FileCopyrightText: Copyright 2005 Roman Rokytskyy // SPDX-FileCopyrightText: Copyright 2007 Gabriel Reid -// SPDX-FileCopyrightText: Copyright 2012-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause package org.firebirdsql.jdbc; import java.sql.DatabaseMetaData; import java.sql.SQLException; +import java.util.Optional; /** * Extension of {@link DatabaseMetaData} interface providing access to Firebird * specific features. - * + * * @author Michael Romankiewicz */ @SuppressWarnings("unused") @@ -104,20 +105,22 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * @since 7 */ String getViewSourceCode(String schema, String viewName) throws SQLException; - + /** * Get the major version of the ODS (On-Disk Structure) of the database. - * + * * @return The major version number of the database itself - * @exception SQLException if a database access error occurs + * @throws SQLException + * if a database access error occurs */ int getOdsMajorVersion() throws SQLException; - + /** * Get the minor version of the ODS (On-Disk Structure) of the database. - * + * * @return The minor version number of the database itself - * @exception SQLException if a database access error occurs + * @throws SQLException + * if a database access error occurs */ int getOdsMinorVersion() throws SQLException; @@ -125,7 +128,8 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * Get the dialect of the database. * * @return The dialect of the database - * @throws SQLException if a database access error occurs + * @throws SQLException + * if a database access error occurs * @see #getConnectionDialect() */ int getDatabaseDialect() throws SQLException; @@ -137,7 +141,8 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { *

* * @return The dialect of the connection - * @throws SQLException if a database access error occurs + * @throws SQLException + * if a database access error occurs * @see #getDatabaseDialect() */ int getConnectionDialect() throws SQLException; @@ -157,7 +162,7 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * @throws SQLException * For problems determining supported table types * @see #getTableTypes() - * @since 4.0 + * @since 4 */ String[] getTableTypeNames() throws SQLException; @@ -172,4 +177,29 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { */ int getMaxObjectNameLength() throws SQLException; + /** + * Attempts to find the schema of {@code tableName} on the current search path. + *

+ * On Firebird versions that support schemas, this will return either a non-empty optional with the first schema + * containing {@code tableName}, or an empty optional if {@code tableName} was not found in the schemas on + * the search path. + *

+ *

+ * On Firebird versions that do not support schemas, this will always return a non-empty optional + * with an empty string ({@code ""}), meaning "table has no schema". This is an analogue to + * the meaning of empty string for {@code schema} or {@code schemaPattern} in other {@link DatabaseMetaData} + * methods. It will not query the server to check for existence of the table. + *

+ * + * @param tableName + * table name, matching exactly as stored in the metadata (not a like-pattern) + * @return the first schema name of the search path containing {@code tableName}, or empty string ({@code ""}) if + * schemas are not supported; returns an empty optional if schemas are supported, but {@code tableName} was not + * found on the search path + * @throws SQLException + * for database access errors + * @since 7 + */ + Optional findTableSchema(String tableName) throws SQLException; + } diff --git a/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java b/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java index 35b7d1ea8..02fca5fd0 100644 --- a/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java +++ b/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java @@ -1,30 +1,31 @@ -// SPDX-FileCopyrightText: Copyright 2018-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2018-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; import org.firebirdsql.gds.JaybirdErrorCodes; import org.firebirdsql.gds.ng.FbExceptionBuilder; -import org.firebirdsql.jdbc.metadata.MetadataPattern; import org.firebirdsql.jaybird.parser.StatementDetector; import org.firebirdsql.jaybird.parser.LocalStatementType; import org.firebirdsql.jaybird.parser.FirebirdReservedWords; import org.firebirdsql.jaybird.parser.SqlParser; import org.firebirdsql.jaybird.parser.StatementIdentification; +import org.firebirdsql.jaybird.util.ObjectReference; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; +import static java.util.Objects.requireNonNull; +import static org.firebirdsql.jdbc.metadata.MetadataPattern.escapeWildcards; + /** * Builds (updates) queries to add generated keys support. * * @author Mark Rotteveel - * @since 4.0 + * @since 4 */ final class GeneratedKeysQueryBuilder { - // TODO Add schema support: parse schema from query, and find a way to identify table if unqualified (needed for - // mapColumnNamesByIndex) // TODO Add caching for column info private static final System.Logger logger = System.getLogger(GeneratedKeysQueryBuilder.class.getName()); @@ -126,10 +127,7 @@ boolean isSupportedType() { * value {@code true} if the original statement already had a {@code RETURNING} clause. */ GeneratedKeysSupport.Query forNoGeneratedKeysOption() { - if (hasReturning()) { - return new GeneratedKeysSupport.Query(true, originalSql); - } - return new GeneratedKeysSupport.Query(false, originalSql); + return new GeneratedKeysSupport.Query(hasReturning(), originalSql); } /** @@ -146,9 +144,8 @@ GeneratedKeysSupport.Query forReturnGeneratedKeysOption(FirebirdDatabaseMetaData if (hasReturning()) { // See also comment on forNoGeneratedKeysOption return new GeneratedKeysSupport.Query(true, originalSql); - } - if (isSupportedType()) { - // TODO Use an strategy when creating this builder or even push this up to the GeneratedKeysSupportFactory? + } else if (isSupportedType()) { + // TODO Use a strategy when creating this builder or even push this up to the GeneratedKeysSupportFactory? if (supportsReturningAll(databaseMetaData)) { return useReturningAll(); } @@ -161,7 +158,7 @@ GeneratedKeysSupport.Query forReturnGeneratedKeysOption(FirebirdDatabaseMetaData * Determines support for {@code RETURNING *}. * * @param databaseMetaData - * Database meta data + * database metadata * @return {@code true} if this version of Firebird supports {@code RETURNING *}. * @throws SQLException * for database access errors @@ -184,7 +181,7 @@ private GeneratedKeysSupport.Query useReturningAll() { */ private GeneratedKeysSupport.Query useReturningAllColumnsByName(FirebirdDatabaseMetaData databaseMetaData) throws SQLException { - List columnNames = getAllColumnNames(statementIdentification.getTableName(), databaseMetaData); + List columnNames = getAllColumnNames(databaseMetaData); QuoteStrategy quoteStrategy = QuoteStrategy.forDialect(databaseMetaData.getConnectionDialect()); return addColumnsByNameImpl(columnNames, quoteStrategy); } @@ -214,8 +211,8 @@ GeneratedKeysSupport.Query forColumnsByIndex(int[] columnIndexes, FirebirdDataba .messageParameter("columnIndexes") .toSQLException(); } else if (isSupportedType()) { - List columnNames = getColumnNames(statementIdentification.getTableName(), columnIndexes, databaseMetaData); - QuoteStrategy quoteStrategy = QuoteStrategy.forDialect(databaseMetaData.getConnectionDialect()); + List columnNames = getColumnNames(columnIndexes, databaseMetaData); + var quoteStrategy = QuoteStrategy.forDialect(databaseMetaData.getConnectionDialect()); return addColumnsByNameImpl(columnNames, quoteStrategy); } else { // Unsupported type, ignore column indexes @@ -265,9 +262,7 @@ private GeneratedKeysSupport.Query addColumnsByNameImpl(List columnNames break; } } - returningQuery - .append('\n') - .append("RETURNING "); + returningQuery.append("\nRETURNING "); for (String columnName : columnNames) { quoteStrategy .appendQuoted(columnName, returningQuery) @@ -278,9 +273,10 @@ private GeneratedKeysSupport.Query addColumnsByNameImpl(List columnNames return new GeneratedKeysSupport.Query(true, returningQuery.toString()); } - private List getAllColumnNames(String tableName, FirebirdDatabaseMetaData databaseMetaData) - throws SQLException { - try (ResultSet rs = databaseMetaData.getColumns(null, null, normalizeObjectName(tableName), null)) { + private List getAllColumnNames(FirebirdDatabaseMetaData databaseMetaData) throws SQLException { + // We're not using schema, as this is only called for Firebird 3.0 and older (no RETURNING * support) + String tableName = statementIdentification.getTableName(); + try (ResultSet rs = databaseMetaData.getColumns(null, null, escapeWildcards(tableName), null)) { if (rs.next()) { List columns = new ArrayList<>(); do { @@ -294,17 +290,26 @@ private List getAllColumnNames(String tableName, FirebirdDatabaseMetaDat } } - private List getColumnNames(String tableName, int[] columnIndexes, - FirebirdDatabaseMetaData databaseMetaData) throws SQLException { - Map columnByIndex = mapColumnNamesByIndex(tableName, columnIndexes, databaseMetaData); + private List getColumnNames(int[] columnIndexes, FirebirdDatabaseMetaData databaseMetaData) + throws SQLException { + String tableName = requireNonNull(statementIdentification.getTableName()); + String schema = statementIdentification.getSchema(); + if (schema == null) { + schema = databaseMetaData.findTableSchema(tableName) + .orElseThrow(() -> FbExceptionBuilder + .forNonTransientException(JaybirdErrorCodes.jb_generatedKeysNoColumnsFound) + .messageParameter(ObjectReference.of(tableName), "schemaless table not on the search path") + .toSQLException()); + } + Map columnByIndex = mapColumnNamesByIndex(schema, tableName, columnIndexes, databaseMetaData); List columns = new ArrayList<>(columnIndexes.length); for (int indexToAdd : columnIndexes) { String columnName = columnByIndex.get(indexToAdd); if (columnName == null) { throw FbExceptionBuilder .forNonTransientException(JaybirdErrorCodes.jb_generatedKeysInvalidColumnPosition) - .messageParameter(indexToAdd, tableName) + .messageParameter(indexToAdd, ObjectReference.of(schema, tableName)) .toSQLException(); } columns.add(columnName); @@ -312,12 +317,13 @@ private List getColumnNames(String tableName, int[] columnIndexes, return columns; } - private Map mapColumnNamesByIndex(String tableName, int[] columnIndexes, + private Map mapColumnNamesByIndex(String schema, String tableName, int[] columnIndexes, FirebirdDatabaseMetaData databaseMetaData) throws SQLException { - try (ResultSet rs = databaseMetaData.getColumns(null, null, normalizeObjectName(tableName), null)) { + try (ResultSet rs = databaseMetaData.getColumns( + null, escapeWildcards(schema), escapeWildcards(tableName), null)) { if (!rs.next()) { throw FbExceptionBuilder.forNonTransientException(JaybirdErrorCodes.jb_generatedKeysNoColumnsFound) - .messageParameter(tableName) + .messageParameter(ObjectReference.of(schema, tableName)) .toSQLException(); } @@ -335,27 +341,4 @@ private Map mapColumnNamesByIndex(String tableName, int[] colum } } - /** - * Normalizes an object name from the parser. - *

- * Like-wildcard characters are escaped, and unquoted identifiers are uppercased, and quoted identifiers are - * returned with the quotes stripped and double double quotes replaced by a single double quote. - *

- * - * @param objectName - * Object name - * @return Normalized object name - */ - private String normalizeObjectName(String objectName) { - if (objectName == null) return null; - objectName = objectName.trim(); - objectName = MetadataPattern.escapeWildcards(objectName); - if (objectName.length() > 2 - && objectName.charAt(0) == '"' - && objectName.charAt(objectName.length() - 1) == '"') { - return objectName.substring(1, objectName.length() - 1).replace("\"\"", "\""); - } - return objectName.toUpperCase(Locale.ROOT); - } - } diff --git a/src/test/org/firebirdsql/jaybird/parser/GrammarTest.java b/src/test/org/firebirdsql/jaybird/parser/GrammarTest.java index a6fd4f3d0..919e67908 100644 --- a/src/test/org/firebirdsql/jaybird/parser/GrammarTest.java +++ b/src/test/org/firebirdsql/jaybird/parser/GrammarTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2021-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2021-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.parser; @@ -38,7 +38,8 @@ void insert_values() { "insert into someTable(a, \"\u0442\u0435\"\"\u0441\u0442\", aaa) values('a', -1.23, a(a,aa))"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName(), "Unexpected table name"); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName(), "Unexpected table name"); assertFalse(statementModel.returningClauseDetected(), "Statement should have no returning"); } @@ -48,7 +49,8 @@ void insert_values_quotedTable() { "insert into \"someTable\"(a, b, c) values('a', -1.23, a(a,aa))"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("\"someTable\"", statementModel.getTableName(), "Unexpected table name"); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("someTable", statementModel.getTableName(), "Unexpected table name"); assertFalse(statementModel.returningClauseDetected(), "Statement should have no returning"); } @@ -58,7 +60,8 @@ void insert_values_withReturning() { "insert into someTable(a, b, c) values('a', -1.23, a(a,aa)) returning id"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName(), "Unexpected table name"); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName(), "Unexpected table name"); assertTrue(statementModel.returningClauseDetected(), "Statement should have returning"); } @@ -68,7 +71,8 @@ void insert_values_withReturning_aliases() { "insert into someTable(a, b, c) values('a', -1.23, a(a,aa)) returning id as \"ID\", b,c no_as"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName(), "Unexpected table name"); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName(), "Unexpected table name"); assertTrue(statementModel.returningClauseDetected(), "Statement should have returning"); } @@ -78,7 +82,8 @@ void insert_values_commentedOutReturning_lineComment() { "insert into someTable(a, b, c) values('a', -1.23, a(a,aa)) -- returning id"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName(), "Unexpected table name"); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName(), "Unexpected table name"); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -88,7 +93,8 @@ void insert_values_commentedOutReturning_blockComment() { "insert into someTable(a, b, c) values('a', -1.23, a(a,aa)) /* returning id */"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName(), "Unexpected table name"); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName(), "Unexpected table name"); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -97,7 +103,8 @@ void insertIntoSelect() { StatementIdentification statementModel = parseStatement("Insert Into someTable Select * From anotherTable"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should have no returning"); } @@ -107,7 +114,8 @@ void insertIntoSelect_withReturning() { parseStatement("Insert Into someTable Select * From anotherTable returning id"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertTrue(statementModel.returningClauseDetected(), "Statement should have returning"); } @@ -117,7 +125,8 @@ void insertWithCase() { "Insert Into someTable ( col1, col2) values((case when a = 1 Then 2 else 3 end), 2)"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should have no returning"); } @@ -127,7 +136,8 @@ void insertReturningWithCase() { "Insert Into someTable ( col1, col2) values((case when a = 1 Then 2 else 3 end), 2) returning id"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertTrue(statementModel.returningClauseDetected(), "Statement should have returning"); } @@ -136,7 +146,8 @@ void insertDefaultValues() { StatementIdentification statementModel = parseStatement("INSERT INTO someTable DEFAULT VALUES"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should have no returning"); } @@ -146,7 +157,8 @@ void insertDefaultValues_withReturning() { parseStatement("INSERT INTO someTable DEFAULT VALUES RETURNING \"ID\""); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertTrue(statementModel.returningClauseDetected(), "Statement should have returning"); } @@ -156,7 +168,8 @@ void update() { "Update someTable Set col1 = 25, col2 = 'abc' Where 1=0"); assertEquals(UPDATE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -166,7 +179,8 @@ void update_quotedTableName() { "Update \"someTable\" Set col1 = 25, col2 = 'abc' Where 1=0"); assertEquals(UPDATE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("\"someTable\"", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("someTable", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -176,7 +190,8 @@ void update_quotedTableNameWithSpace() { "Update \"some Table\" Set col1 = 25, col2 = 'abc' Where 1=0"); assertEquals(UPDATE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("\"some Table\"", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("some Table", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -186,7 +201,8 @@ void update_withReturning() { "Update someTable Set col1 = 25, col2 = 'abc' Where 1=0 Returning col3"); assertEquals(UPDATE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertTrue(statementModel.returningClauseDetected(), "Statement should have returning"); } @@ -196,7 +212,8 @@ void delete() { "DELETE FROM someTable Where 1=0"); assertEquals(DELETE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -205,7 +222,8 @@ void delete_quotedTableName() { StatementIdentification statementModel = parseStatement("delete from \"someTable\""); assertEquals(DELETE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("\"someTable\"", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("someTable", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -214,7 +232,8 @@ void delete_withReturning() { StatementIdentification statementModel = parseStatement("Delete From someTable Returning col3"); assertEquals(DELETE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertTrue(statementModel.returningClauseDetected(), "Statement should have returning"); } @@ -223,7 +242,8 @@ void delete_withWhere_withReturning() { StatementIdentification statementModel = parseStatement("Delete From someTable where 1 = 1 Returning col3"); assertEquals(DELETE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertTrue(statementModel.returningClauseDetected(), "Statement should have returning"); } @@ -232,6 +252,7 @@ void select() { StatementIdentification statementModel = parseStatement("select * from RDB$DATABASE"); assertEquals(SELECT, statementModel.getStatementType(), "Expected SELECT statement type"); + assertNull(statementModel.getSchema(), "Unexpected schema"); assertNull(statementModel.getTableName(), "Expected no table name"); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -241,7 +262,8 @@ void insertWithQString() { StatementIdentification statementModel = parseStatement("insert into someTable values (Q'[a'bc]')"); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -251,7 +273,8 @@ void insertWithQStringWithReturning() { parseStatement("insert into someTable values (Q'[a'bc]') returning id, \"ABC\""); assertEquals(INSERT, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("someTable", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("SOMETABLE", statementModel.getTableName()); assertTrue(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -270,6 +293,7 @@ void testQLiterals() { checkQLiteralsStartEnd('>', '>'); } + @SuppressWarnings("resource") private void checkQLiteralsStartEnd(char start, char end) { final String input = "q'" + start + "a'bc" + end + "'"; Token token = SqlTokenizer.withReservedWords(FirebirdReservedWords.latest()) @@ -291,7 +315,8 @@ void merge() { INSERT (title, desc, bought) values (p.title, p.desc, p.bought)"""); assertEquals(MERGE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("books", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("BOOKS", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -307,7 +332,8 @@ void merge_quotedTableName() { INSERT (title, desc, bought) values (p.title, p.desc, p.bought)"""); assertEquals(MERGE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("\"books\"", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("books", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -323,7 +349,8 @@ void merge_quotedTableNameWithSpace() { INSERT (title, desc, bought) values (p.title, p.desc, p.bought)"""); assertEquals(MERGE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("\"more books\"", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("more books", statementModel.getTableName()); assertFalse(statementModel.returningClauseDetected(), "Statement should not have returning"); } @@ -341,49 +368,52 @@ void merge_withReturning() { """); assertEquals(MERGE, statementModel.getStatementType(), "Unexpected statement type"); - assertEquals("books", statementModel.getTableName()); + assertNull(statementModel.getSchema(), "Unexpected schema"); + assertEquals("BOOKS", statementModel.getTableName()); assertTrue(statementModel.returningClauseDetected(), "Statement should have returning"); } @ParameterizedTest @MethodSource("testData") - void testParser(boolean expectedReturning, LocalStatementType expectedStatementType, String expectedTableName, - String statementText) { + void testParser(boolean expectedReturning, LocalStatementType expectedStatementType, String expectedSchema, + String expectedTableName, String statementText) { StatementIdentification statementIdentification = parseStatement(statementText); assertEquals(expectedReturning, statementIdentification.returningClauseDetected(), "returningClauseDetected for: " + statementText); assertEquals(expectedStatementType, statementIdentification.getStatementType(), "statementType for: " + statementText); + assertEquals(expectedSchema, statementIdentification.getSchema(), "schema for: " + statementText); assertEquals(expectedTableName, statementIdentification.getTableName(), "tableName for: " + statementText); } static Stream testData() { return Stream.of( // @formatter:off - /* 0*/ testCase(false, SELECT, null, "select * from rdb$database"), - /* 1*/ testCase(false, INSERT, "\"TABLE\"", "insert into \"TABLE\" (x, y, z) values ('ab', ?, Q'[xyz]')"), - /* 2*/ testCase(true, INSERT, "\"TABLE\"", + /* 0*/ testCase(false, SELECT, null, null, "select * from rdb$database"), + /* 1*/ testCase(false, INSERT, null, "TABLE", "insert into \"TABLE\" (x, y, z) values ('ab', ?, Q'[xyz]')"), + /* 2*/ testCase(true, INSERT, null, "TABLE", "insert into \"TABLE\" (x, y, z) values ('ab', ?, Q'[xyz]') returning id"), - /* 3*/ testCase(false, UPDATE, "sometable", "update sometable set x = ?, y = Q'[xy'z]' where a and b > 1"), - /* 4*/ testCase(true, UPDATE, "SOMETABLE", + /* 3*/ testCase(false, UPDATE, null, "SOMETABLE", + "update sometable set x = ?, y = Q'[xy'z]' where a and b > 1"), + /* 4*/ testCase(true, UPDATE, null, "SOMETABLE", "update SOMETABLE set x = ?, y = Q'[xy'z]' where a and b > 1 returning \"A\" as a"), - /* 5*/ testCase(false, DELETE, "sometable", "DELETE FROM sometable where x"), - /* 6*/ testCase(true, DELETE, "sometable", """ + /* 5*/ testCase(false, DELETE, null, "SOMETABLE", "DELETE FROM sometable where x"), + /* 6*/ testCase(true, DELETE, null, "SOMETABLE", """ DELETE FROM sometable where x = (select y from "TABLE" where startdate = {d'2018-05-1'}) returning x, a, b "A\""""), - /* 7*/ testCase(false, UPDATE_OR_INSERT, "Cows", """ + /* 7*/ testCase(false, UPDATE_OR_INSERT, null, "COWS", """ UPDATE OR INSERT INTO Cows (Name, Number, Location) VALUES ('Suzy Creamcheese', 3278823, 'Green Pastures') MATCHING (Number);"""), - /* 8*/ testCase(true, UPDATE_OR_INSERT, "Cows", """ + /* 8*/ testCase(true, UPDATE_OR_INSERT, null, "COWS", """ UPDATE OR INSERT INTO Cows (Name, Number, Location) VALUES ('Suzy Creamcheese', 3278823, 'Green Pastures') MATCHING (Number) RETURNING rec_id;"""), - /* 9*/ testCase(false, MERGE, "customers", """ + /* 9*/ testCase(false, MERGE, null, "CUSTOMERS", """ MERGE INTO customers c USING (SELECT * FROM customers_delta WHERE id > 10) cd @@ -393,7 +423,7 @@ UPDATE OR INSERT INTO Cows (Name, Number, Location) WHEN NOT MATCHED THEN INSERT (id, name) VALUES (cd.id, cd.name)"""), - /*10*/ testCase(true, MERGE, "customers", """ + /*10*/ testCase(true, MERGE, null, "CUSTOMERS", """ MERGE INTO customers c USING (SELECT * FROM customers_delta WHERE id > 10) cd @@ -404,7 +434,7 @@ UPDATE OR INSERT INTO Cows (Name, Number, Location) INSERT (id, name) VALUES (cd.id, cd.name) RETURNING id"""), - /*11*/ testCase(false, MERGE, "customers", """ + /*11*/ testCase(false, MERGE, null, "CUSTOMERS", """ MERGE INTO customers c USING (SELECT * FROM customers_delta WHERE id > 10) cd @@ -415,22 +445,22 @@ UPDATE OR INSERT INTO Cows (Name, Number, Location) INSERT (id, name) VALUES (cd.id, cd.name) -- RETURNING id"""), - /*12*/ testCase(true, INSERT, "sometable", "insert into sometable default values returning *"), - /*13*/ testCase(true, INSERT, "sometable", "insert into sometable default values returning sometable.*"), - /*14*/ testCase(true, INSERT, "sometable", "insert into sometable(x, y, z) values(default, 1, 2) returning *"), - /*15*/ testCase(true, INSERT, "sometable", + /*12*/ testCase(true, INSERT, null, "SOMETABLE", "insert into sometable default values returning *"), + /*13*/ testCase(true, INSERT, null, "SOMETABLE", "insert into sometable default values returning sometable.*"), + /*14*/ testCase(true, INSERT, null, "SOMETABLE", "insert into sometable(x, y, z) values(default, 1, 2) returning *"), + /*15*/ testCase(true, INSERT, null, "SOMETABLE", "insert into sometable(x, y, z) values(default, 1, 2) returning sometable.*"), - /*16*/ testCase(true, UPDATE, "sometable", "update sometable set x = ? returning *"), - /*17*/ testCase(true, UPDATE, "sometable", "update sometable set x = ? returning sometable.*"), - /*18*/ testCase(true, UPDATE, "sometable", "update sometable a set x = ? returning a.*"), - /*19*/ testCase(true, UPDATE, "sometable", "update sometable as a set a.x = ? returning *"), - /*20*/ testCase(true, UPDATE, "sometable", "update sometable as a set a.x = ? returning a.id"), - /*21*/ testCase(true, DELETE, "sometable", "delete from sometable where x = ? returning *"), - /*22*/ testCase(true, DELETE, "sometable", "delete from sometable a where a.x = ? returning a.*"), - /*23*/ testCase(true, DELETE, "sometable", "delete from sometable a where a.x = ? returning *"), - /*24*/ testCase(true, DELETE, "sometable", "delete from sometable as a where a.x = ? returning a.id"), - /*25*/ testCase(true, DELETE, "sometable", "delete from sometable as a where a.x = ? returning *"), - /*26*/ testCase(true, MERGE, "customers", """ + /*16*/ testCase(true, UPDATE, null, "SOMETABLE", "update sometable set x = ? returning *"), + /*17*/ testCase(true, UPDATE, null, "SOMETABLE", "update sometable set x = ? returning sometable.*"), + /*18*/ testCase(true, UPDATE, null, "SOMETABLE", "update sometable a set x = ? returning a.*"), + /*19*/ testCase(true, UPDATE, null, "SOMETABLE", "update sometable as a set a.x = ? returning *"), + /*20*/ testCase(true, UPDATE, null, "SOMETABLE", "update sometable as a set a.x = ? returning a.id"), + /*21*/ testCase(true, DELETE, null, "SOMETABLE", "delete from sometable where x = ? returning *"), + /*22*/ testCase(true, DELETE, null, "SOMETABLE", "delete from sometable a where a.x = ? returning a.*"), + /*23*/ testCase(true, DELETE, null, "SOMETABLE", "delete from sometable a where a.x = ? returning *"), + /*24*/ testCase(true, DELETE, null, "SOMETABLE", "delete from sometable as a where a.x = ? returning a.id"), + /*25*/ testCase(true, DELETE, null, "SOMETABLE", "delete from sometable as a where a.x = ? returning *"), + /*26*/ testCase(true, MERGE, null, "CUSTOMERS", """ MERGE INTO customers USING (SELECT * FROM customers_delta WHERE id > 10) cd @@ -441,7 +471,7 @@ UPDATE OR INSERT INTO Cows (Name, Number, Location) INSERT (id, name) VALUES (cd.id, cd.name) RETURNING *"""), - /*27*/ testCase(true, MERGE, "customers", """ + /*27*/ testCase(true, MERGE, null, "CUSTOMERS", """ MERGE INTO customers as c USING (SELECT * FROM customers_delta WHERE id > 10) cd @@ -452,7 +482,7 @@ UPDATE OR INSERT INTO Cows (Name, Number, Location) INSERT (id, name) VALUES (cd.id, cd.name) RETURNING *"""), - /*28*/ testCase(true, MERGE, "customers", """ + /*28*/ testCase(true, MERGE, null, "CUSTOMERS", """ MERGE INTO customers c USING (SELECT * FROM customers_delta WHERE id > 10) cd @@ -463,7 +493,7 @@ UPDATE OR INSERT INTO Cows (Name, Number, Location) INSERT (id, name) VALUES (cd.id, cd.name) RETURNING c.*"""), - /*29*/ testCase(true, MERGE, "customers", """ + /*29*/ testCase(true, MERGE, null, "CUSTOMERS", """ MERGE INTO customers c USING (SELECT * FROM customers_delta WHERE id > 10) cd @@ -473,14 +503,54 @@ UPDATE OR INSERT INTO Cows (Name, Number, Location) WHEN NOT MATCHED THEN INSERT (id, name) VALUES (cd.id, cd.name) - RETURNING c.id""") + RETURNING c.id"""), + // cases with schema + /*30*/ testCase(false, INSERT, "other", "table", + "insert into \"other\".\"table\" (x, y, z) values ('ab', ?, Q'[xyz]')"), + /*31*/ testCase(false, UPDATE, "PUBLIC", "SOMETABLE", + "update public.sometable set x = ?, y = Q'[xy'z]' where a and b > 1"), + /*32*/ testCase(false, DELETE, "OTHER", "SOMETABLE", "DELETE FROM other.sometable where x"), + /*33*/ testCase(false, UPDATE_OR_INSERT, "PUBLIC", "COWS", """ + UPDATE OR INSERT INTO public.Cows as x (Name, Number, Location) + VALUES ('Suzy Creamcheese', 3278823, 'Green Pastures') + MATCHING (x.Number);"""), + /*34*/ testCase(false, MERGE, "OTHER", "CUSTOMERS", """ + MERGE INTO "OTHER".customers c + USING + (SELECT * FROM customers_delta WHERE id > 10) cd + ON (c.id = cd.id) + WHEN MATCHED THEN + UPDATE SET name = cd.name + WHEN NOT MATCHED THEN + INSERT (id, name) + VALUES (cd.id, cd.name)"""), + /*35*/ testCase(true, INSERT, "PUBLIC", "SOMETABLE", + "insert into public.sometable default values returning id, val"), + /*36*/ testCase(true, UPDATE, "OTHER", "SOMETABLE", + "update OTHER.SOMETABLE set x = ?, y = Q'[xy'z]' where a and b > 1 returning \"A\" as a"), + /*37*/ testCase(true, DELETE, "PUBLIC", "SOMETABLE", """ + DELETE FROM PUBLIC.sometable + where x = (select y from "TABLE" + where startdate = {d'2018-05-1'}) + returning x, a, b "A\""""), + /*38*/ testCase(true, MERGE, "with\"quote", "CUSTOMERS", """ + MERGE INTO "with""quote".customers + USING + (SELECT * FROM customers_delta WHERE id > 10) cd + ON (c.id = cd.id) + WHEN MATCHED THEN + UPDATE SET name = cd.name + WHEN NOT MATCHED THEN + INSERT (id, name) + VALUES (cd.id, cd.name) + RETURNING *""") // @formatter:on ); } private static Arguments testCase(boolean expectedReturning, LocalStatementType expectedStatementType, - String expectedTableName, String statementText) { - return Arguments.of(expectedReturning, expectedStatementType, expectedTableName, statementText); + String expectedSchema, String expectedTableName, String statementText) { + return Arguments.of(expectedReturning, expectedStatementType, expectedSchema, expectedTableName, statementText); } } diff --git a/src/test/org/firebirdsql/jaybird/parser/StatementDetectorTest.java b/src/test/org/firebirdsql/jaybird/parser/StatementDetectorTest.java index 06a31e5e4..5eeb0646f 100644 --- a/src/test/org/firebirdsql/jaybird/parser/StatementDetectorTest.java +++ b/src/test/org/firebirdsql/jaybird/parser/StatementDetectorTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2021-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2021-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.parser; @@ -28,13 +28,15 @@ void initialStatementType_typeUNKNOWN() { @ParameterizedTest @MethodSource("detectionCases") void testDetection(boolean detectReturning, String statement, LocalStatementType expectedType, - Token expectedTableNameToken, boolean expectedReturningDetected, boolean expectedParserCompleted) { + Token expectedSchemaToken, Token expectedTableNameToken, boolean expectedReturningDetected, + boolean expectedParserCompleted) { detector = new StatementDetector(detectReturning); SqlParser parser = parserFor(statement); parser.parse(); assertThat(detector.getStatementType()).describedAs("statementType").isEqualTo(expectedType); + assertThat(detector.getSchemaToken()).describedAs("schemaToken").isEqualTo(expectedSchemaToken); assertThat(detector.getTableNameToken()).describedAs("tableNameToken").isEqualTo(expectedTableNameToken); assertThat(detector.returningClauseDetected()) .describedAs("returningClauseDetected").isEqualTo(expectedReturningDetected); @@ -80,6 +82,15 @@ LocalStatementType.INSERT, new GenericToken(12, "sometable"), false, true), LocalStatementType.INSERT, new GenericToken(12, "sometable"), true, true), detectReturning("INSERT INTO TABLE_WITH_TRIGGER(TEXT) VALUES ('Some text to insert') RETURNING *", LocalStatementType.INSERT, new GenericToken(12, "TABLE_WITH_TRIGGER"), true, true), + detectReturning("insert into other_schema.sometable (id, column1, column2) values (?, ?, ?)", + LocalStatementType.INSERT, new GenericToken(12, "other_schema"), + new GenericToken(25, "sometable"), false, true), + detectReturning("insert into other_schema.\"sometable\" values (1, 2) returning id1, id2", + LocalStatementType.INSERT, new GenericToken(12, "other_schema"), + new QuotedIdentifierToken(25, "\"sometable\""), true, true), + noDetect("insert into other_schema.\"sometable\" values (1, 2) returning id1, id2", + LocalStatementType.INSERT, new GenericToken(12, "other_schema"), + new QuotedIdentifierToken(25, "\"sometable\""), false), // delete detectReturning("delete from sometable", @@ -91,6 +102,15 @@ LocalStatementType.DELETE, new GenericToken(12, "sometable"), true, true), LocalStatementType.DELETE, new GenericToken(12, "sometable"), false), detectReturning("delete from sometable as somealias where somealias.foo = 'bar'", LocalStatementType.DELETE, new GenericToken(12, "sometable"), false, true), + detectReturning("delete from \"OTHER_SCHEMA\".\"sometable\"", + LocalStatementType.DELETE, new QuotedIdentifierToken(12, "\"OTHER_SCHEMA\""), + new QuotedIdentifierToken(27, "\"sometable\""), false, true), + detectReturning("delete from \"OTHER_SCHEMA\".\"sometable\" returning column1", + LocalStatementType.DELETE, new QuotedIdentifierToken(12, "\"OTHER_SCHEMA\""), + new QuotedIdentifierToken(27, "\"sometable\""), true, true), + detectReturning("delete from \"OTHER_SCHEMA\".\"sometable\" as \"x\" returning column1", + LocalStatementType.DELETE, new QuotedIdentifierToken(12, "\"OTHER_SCHEMA\""), + new QuotedIdentifierToken(27, "\"sometable\""), true, true), // update detectReturning("update \"sometable\" set column1 = 1, column2 = column2 + 1 where x = y", @@ -105,6 +125,9 @@ LocalStatementType.UPDATE, new GenericToken(7, "sometable"), false), LocalStatementType.UPDATE, new GenericToken(7, "sometable"), true, true), detectReturning("update sometable \"withalias\" set column1 = 1 returning (id + 1) as foo", LocalStatementType.UPDATE, new GenericToken(7, "sometable"), true, true), + detectReturning("update PUBLIC.sometable set column1 = 2 returning calculated_column", + LocalStatementType.UPDATE, new GenericToken(7, "PUBLIC"), new GenericToken(14, "sometable"), + true, true), // update or insert detectReturning("update or insert into sometable (id, column1, column2) values (?, ?, (? * 2)) matching (id)", @@ -209,26 +232,45 @@ LocalStatementType.UPDATE, new GenericToken(7, "returning"), true, true), ); } - @SuppressWarnings("SameParameterValue") + private static Arguments detectReturning(String statement, LocalStatementType expectedType, + boolean expectedParserCompleted) { + return detectReturning(statement, expectedType, null, false, expectedParserCompleted); + } + private static Arguments detectReturning(String statement, LocalStatementType expectedType, Token expectedTableNameToken, boolean expectedReturningDetected, boolean expectedParserCompleted) { - return arguments(true, statement, expectedType, expectedTableNameToken, expectedReturningDetected, + return detectReturning(statement, expectedType, null, expectedTableNameToken, expectedReturningDetected, expectedParserCompleted); } private static Arguments detectReturning(String statement, LocalStatementType expectedType, + Token expectedSchemaToken, Token expectedTableNameToken, boolean expectedReturningDetected, + boolean expectedParserCompleted) { + return testCase(true, statement, expectedType, expectedSchemaToken, expectedTableNameToken, + expectedReturningDetected, expectedParserCompleted); + } + + private static Arguments noDetect(String statement, LocalStatementType expectedType, boolean expectedParserCompleted) { - return arguments(true, statement, expectedType, null, false, expectedParserCompleted); + return noDetect(statement, expectedType, null, null, expectedParserCompleted); } private static Arguments noDetect(String statement, LocalStatementType expectedType, Token expectedTableNameToken, boolean expectedParserCompleted) { - return arguments(false, statement, expectedType, expectedTableNameToken, false, expectedParserCompleted); + return noDetect(statement, expectedType, null, expectedTableNameToken, expectedParserCompleted); } - private static Arguments noDetect(String statement, LocalStatementType expectedType, + private static Arguments noDetect(String statement, LocalStatementType expectedType, Token expectedSchemaToken, + Token expectedTableNameToken, boolean expectedParserCompleted) { + return testCase(false, statement, expectedType, expectedSchemaToken, expectedTableNameToken, false, + expectedParserCompleted); + } + + private static Arguments testCase(boolean detectReturning, String statement, LocalStatementType expectedType, + Token expectedSchemaToken, Token expectedTableNameToken, boolean expectedReturningDetected, boolean expectedParserCompleted) { - return arguments(false, statement, expectedType, null, false, expectedParserCompleted); + return arguments(detectReturning, statement, expectedType, expectedSchemaToken, expectedTableNameToken, + expectedReturningDetected, expectedParserCompleted); } private SqlParser parserFor(String statementText) { diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFindTableSchemaTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFindTableSchemaTest.java new file mode 100644 index 000000000..f89eff1aa --- /dev/null +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFindTableSchemaTest.java @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jdbc; + +import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.util.FirebirdSupportInfo; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static com.spotify.hamcrest.optional.OptionalMatchers.emptyOptional; +import static com.spotify.hamcrest.optional.OptionalMatchers.optionalWithValue; +import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeFeatureMissing; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +/** + * Tests for {@link FBDatabaseMetaData#findTableSchema(String)}. + */ +class FBDatabaseMetaDataFindTableSchemaTest { + + private static final String CREATE_DEFAULT_SCHEMA_TABLE_ONE = "create table TABLE_ONE (ID integer)"; + private static final String CREATE_DEFAULT_SCHEMA_TABLE_TWO = "create table \"table_two\" (ID integer)"; + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + private static final String CREATE_OTHER_SCHEMA_TABLE_ONE = "create table OTHER_SCHEMA.TABLE_ONE (ID integer)"; + private static final String CREATE_OTHER_SCHEMA_TABLE_THREE = "create table OTHER_SCHEMA.TABLE_THREE (ID integer)"; + + private static final String NOT_FOUND_MARKER = "#NOT_FOUND#"; + + @RegisterExtension + static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( + getDbInitStatements()); + + private static List getDbInitStatements() { + var statements = new ArrayList<>(List.of( + CREATE_DEFAULT_SCHEMA_TABLE_ONE, + CREATE_DEFAULT_SCHEMA_TABLE_TWO)); + if (getDefaultSupportInfo().supportsSchemas()) { + statements.addAll(List.of( + CREATE_OTHER_SCHEMA, + CREATE_OTHER_SCHEMA_TABLE_ONE, + CREATE_OTHER_SCHEMA_TABLE_THREE)); + } + return statements; + } + + private static Connection connection; + private static FirebirdDatabaseMetaData dbmd; + private static PreparedStatement sessionResetStatement; + + @BeforeAll + static void setupAll() throws Exception { + connection = getConnectionViaDriverManager(); + dbmd = connection.getMetaData().unwrap(FirebirdDatabaseMetaData.class); + } + + @BeforeEach + void setupEach() throws Exception { + if (dbmd.supportsSchemasInDataManipulation()) { + if (sessionResetStatement == null) { + sessionResetStatement = connection.prepareStatement("alter session reset"); + } + // reset search path + sessionResetStatement.execute(); + } + } + + @AfterAll + static void tearDownAll() throws Exception { + connection.close(); + } + + @ParameterizedTest + @ValueSource(strings = { "TABLE_ONE", "table_two", "RDB$RELATIONS", "DOES_NOT_EXIST" }) + void findSchema_noTableSchemaSupport(String tableName) throws Exception { + assumeFeatureMissing(FirebirdSupportInfo::supportsSchemas, "Test requires no schema support"); + + Optional schemaOpt = dbmd.findTableSchema(tableName); + assertThat("expected schema empty string (no schema support)", schemaOpt, is(optionalWithValue(""))); + } + + @SuppressWarnings("SqlSourceToSinkFlow") + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + tableName, searchPath, expectedSchema + TABLE_ONE, , PUBLIC + TABLE_ONE, 'PUBLIC,OTHER_SCHEMA', PUBLIC + TABLE_ONE, 'OTHER_SCHEMA,PUBLIC', OTHER_SCHEMA + TABLE_ONE, OTHER_SCHEMA, OTHER_SCHEMA + table_two, , PUBLIC + table_two, 'OTHER_SCHEMA,PUBLIC', PUBLIC + table_two, OTHER_SCHEMA, #NOT_FOUND# + TABLE_THREE, , #NOT_FOUND# + TABLE_THREE, 'PUBLIC,OTHER_SCHEMA', OTHER_SCHEMA + TABLE_THREE, OTHER_SCHEMA, OTHER_SCHEMA + RDB$RELATIONS, , SYSTEM + RDB$RELATIONS, PUBLIC, SYSTEM + RDB$RELATIONS, 'SYSTEM,PUBLIC', SYSTEM + DOES_NOT_EXIST, , #NOT_FOUND# + DOES_NOT_EXIST, 'OTHER_SCHEMA,PUBLIC', #NOT_FOUND# + """) + void findSchema_Table_schemaSupport(String tableName, String searchPath, String expectedSchema) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + if (searchPath != null) { + try (var stmt = connection.createStatement()) { + stmt.execute("set search_path to " + searchPath); + } + } + + Optional schemaOpt = dbmd.findTableSchema(tableName); + if (NOT_FOUND_MARKER.equals(expectedSchema)) { + assertThat("schema should not be found", schemaOpt, is(emptyOptional())); + } else { + assertThat("unexpected schema", schemaOpt, is(optionalWithValue(expectedSchema))); + } + } + +} diff --git a/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java index dd4e8b2c1..f018ba5e1 100644 --- a/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java @@ -6,14 +6,18 @@ import org.firebirdsql.gds.ISCConstants; import org.firebirdsql.gds.JaybirdErrorCodes; import org.firebirdsql.jaybird.props.PropertyNames; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import java.sql.*; import java.util.Properties; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.FbAssumptions.assumeServerBatchSupport; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.matchers.SQLExceptionMatchers.*; @@ -37,6 +41,8 @@ class FBPreparedStatementGeneratedKeysTest extends FBTestGeneratedKeysBase { private static final String TEXT_VALUE = "Some text to insert"; private static final String TEST_INSERT_QUERY = "INSERT INTO TABLE_WITH_TRIGGER(TEXT) VALUES (?)"; + private static final String TEST_INSERT_QUERY_WITH_SCHEMA = + "INSERT INTO PUBLIC.TABLE_WITH_TRIGGER(TEXT) VALUES (?)"; /** * Test for PreparedStatement created through {@link FBConnection#prepareStatement(String, int)} with value {@link Statement#NO_GENERATED_KEYS}. @@ -44,9 +50,10 @@ class FBPreparedStatementGeneratedKeysTest extends FBTestGeneratedKeysBase { * Expected: INSERT statement type and empty generatedKeys result set. *

*/ - @Test - void testPrepare_INSERT_noGeneratedKeys() throws Exception { - try (PreparedStatement stmt = con.prepareStatement(TEST_INSERT_QUERY, Statement.NO_GENERATED_KEYS)) { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testPrepare_INSERT_noGeneratedKeys(boolean withSchema) throws Exception { + try (var stmt = con.prepareStatement(testInsertQuery(withSchema), Statement.NO_GENERATED_KEYS)) { assertEquals(FirebirdPreparedStatement.TYPE_INSERT, ((FirebirdPreparedStatement) stmt).getStatementType()); stmt.setString(1, TEXT_VALUE); @@ -67,15 +74,20 @@ void testPrepare_INSERT_noGeneratedKeys() throws Exception { } } + private static String testInsertQuery(boolean withSchema) { + return withSchema ? TEST_INSERT_QUERY_WITH_SCHEMA : TEST_INSERT_QUERY; + } + /** * Test for PreparedStatement created through {@link FBConnection#prepareStatement(String, int)} with {@link Statement#RETURN_GENERATED_KEYS}. *

* Expected: TYPE_EXEC_PROCEDURE statement type, all columns of table returned, single row result set *

*/ - @Test - void testPrepare_INSERT_returnGeneratedKeys() throws Exception { - try (PreparedStatement stmt = con.prepareStatement(TEST_INSERT_QUERY, Statement.RETURN_GENERATED_KEYS)) { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testPrepare_INSERT_returnGeneratedKeys(boolean withSchema) throws Exception { + try (var stmt = con.prepareStatement(testInsertQuery(withSchema), Statement.RETURN_GENERATED_KEYS)) { assertEquals(FirebirdPreparedStatement.TYPE_EXEC_PROCEDURE, ((FirebirdPreparedStatement) stmt).getStatementType()); stmt.setString(1, TEXT_VALUE); @@ -102,11 +114,12 @@ void testPrepare_INSERT_returnGeneratedKeys() throws Exception { } /** - * The same test as {@link #testPrepare_INSERT_returnGeneratedKeys()}, but with {@code executeUpdate}. + * The same test as {@link #testPrepare_INSERT_returnGeneratedKeys(boolean)}, but with {@code executeUpdate}. */ - @Test - void testPrepare_INSERT_returnGeneratedKeys_executeUpdate() throws Exception { - try (PreparedStatement stmt = con.prepareStatement(TEST_INSERT_QUERY, Statement.RETURN_GENERATED_KEYS)) { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testPrepare_INSERT_returnGeneratedKeys_executeUpdate(boolean withSchema) throws Exception { + try (var stmt = con.prepareStatement(testInsertQuery(withSchema), Statement.RETURN_GENERATED_KEYS)) { assertEquals(FirebirdPreparedStatement.TYPE_EXEC_PROCEDURE, ((FirebirdPreparedStatement) stmt).getStatementType()); stmt.setString(1, TEXT_VALUE); @@ -243,7 +256,7 @@ void testPrepare_INSERT_returnGeneratedKeys_withReturningAll() throws Exception /** * Test for PreparedStatement created through {@link FBConnection#prepareStatement(String, int)} with - * {@link Statement#RETURN_GENERATED_KEYS} with an INSERT for a non existent table. + * {@link Statement#RETURN_GENERATED_KEYS} with an INSERT for a non-existent table. *

* Expected: SQLException Table unknown *

@@ -272,9 +285,10 @@ void testPrepare_INSERT_returnGeneratedKeys_nonExistentTable() { * Expected: TYPE_EXEC_PROCEDURE statement type, single row result set with only the specified column. *

*/ - @Test - void testPrepare_INSERT_columnIndexes() throws Exception { - try (PreparedStatement stmt = con.prepareStatement(TEST_INSERT_QUERY, new int[] { 1 })) { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testPrepare_INSERT_columnIndexes(boolean withSchema) throws Exception { + try (var stmt = con.prepareStatement(testInsertQuery(withSchema), new int[] { 1 })) { assertEquals(FirebirdPreparedStatement.TYPE_EXEC_PROCEDURE, ((FirebirdPreparedStatement) stmt).getStatementType()); stmt.setString(1, TEXT_VALUE); @@ -304,9 +318,10 @@ void testPrepare_INSERT_columnIndexes() throws Exception { * Expected: TYPE_EXEC_PROCEDURE statement type, single row result set with only the specified columns *

*/ - @Test - void testPrepare_INSERT_columnIndexes_quotedColumn() throws Exception { - try (PreparedStatement stmt = con.prepareStatement(TEST_INSERT_QUERY, new int[] { 1, 3 })) { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testPrepare_INSERT_columnIndexes_quotedColumn(boolean withSchema) throws Exception { + try (var stmt = con.prepareStatement(testInsertQuery(withSchema), new int[] { 1, 3 })) { assertEquals(FirebirdPreparedStatement.TYPE_EXEC_PROCEDURE, ((FirebirdPreparedStatement) stmt).getStatementType()); stmt.setString(1, TEXT_VALUE); @@ -332,6 +347,47 @@ void testPrepare_INSERT_columnIndexes_quotedColumn() throws Exception { } } + @SuppressWarnings("SqlSourceToSinkFlow") + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + searchPath, expectedSuffix + , _IN_PUBLIC + 'PUBLIC,OTHER_SCHEMA', _IN_PUBLIC + 'OTHER_SCHEMA,PUBLIC', _IN_OTHER_SCHEMA + """) + void testINSERT_schemalessTable_columnIndexes_schemaSearchPath(String searchPath, String expectedSuffix) + throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + try (var stmt = con.createStatement()) { + if (searchPath != null) { + stmt.execute("set search_path to " + searchPath); + } + } + + try (var stmt = con.prepareStatement("insert into SAME_NAME default values", new int[] { 1, 2 })) { + assertEquals(FirebirdPreparedStatement.TYPE_EXEC_PROCEDURE, ((FirebirdPreparedStatement) stmt).getStatementType()); + var metaData = stmt.getMetaData(); + assertEquals("ID" + expectedSuffix, metaData.getColumnLabel(1), "Unexpected name column 1"); + assertEquals("TEXT" + expectedSuffix, metaData.getColumnLabel(2), "Unexpected name column 2"); + } + } + + @Test + void testINSERT_schemalessTable_columnIndex_tableNotOnSearchPath() throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + try (var stmt = con.createStatement()) { + stmt.execute("set search_path to SYSTEM"); + } + + var exception = assertThrows(SQLNonTransientException.class, + () -> con.prepareStatement("insert into SAME_NAME default values", new int[] { 1, 2 })); + assertThat(exception, fbMessageStartsWith(JaybirdErrorCodes.jb_generatedKeysNoColumnsFound, + "\"SAME_NAME\"", "schemaless table not on the search path")); + } + + // The prepareStatement(String, int[]) variants are the only ones that have special handling for schemaless tables + // We consider testing through prepareStatement without executing sufficient to cover it + // Other combination for execute(String, int[]) already covered in TestGeneratedKeysQuery /** @@ -340,9 +396,10 @@ void testPrepare_INSERT_columnIndexes_quotedColumn() throws Exception { * Expected: single row result set with only the specified column. *

*/ - @Test - void testPrepare_INSERT_columnNames() throws Exception { - try (PreparedStatement stmt = con.prepareStatement(TEST_INSERT_QUERY, new String[] { "ID" })) { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testPrepare_INSERT_columnNames(boolean withSchema) throws Exception { + try (var stmt = con.prepareStatement(testInsertQuery(withSchema), new String[] { "ID" })) { assertEquals(FirebirdPreparedStatement.TYPE_EXEC_PROCEDURE, ((FirebirdPreparedStatement) stmt).getStatementType()); stmt.setString(1, TEXT_VALUE); @@ -372,10 +429,11 @@ void testPrepare_INSERT_columnNames() throws Exception { * Expected: SQLException for Column unknown. *

*/ - @Test - void testPrepare_INSERT_columnNames_nonExistentColumn() { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testPrepare_INSERT_columnNames_nonExistentColumn(boolean withSchema) { SQLException exception = assertThrows(SQLException.class, - () -> con.prepareStatement(TEST_INSERT_QUERY, new String[] { "ID", "NON_EXISTENT" })); + () -> con.prepareStatement(testInsertQuery(withSchema), new String[] { "ID", "NON_EXISTENT" })); assertThat(exception, allOf( errorCode(equalTo(ISCConstants.isc_dsql_field_err)), sqlState(equalTo("42S22")), diff --git a/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java index 086a17ac0..caa29a666 100644 --- a/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java @@ -4,11 +4,16 @@ import org.firebirdsql.gds.ISCConstants; import org.firebirdsql.gds.JaybirdErrorCodes; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; import java.sql.*; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.matchers.SQLExceptionMatchers.*; import static org.hamcrest.CoreMatchers.*; @@ -30,6 +35,8 @@ class FBStatementGeneratedKeysTest extends FBTestGeneratedKeysBase { private static final String TEXT_VALUE = "Some text to insert"; private static final String TEST_INSERT_QUERY = "INSERT INTO TABLE_WITH_TRIGGER(TEXT) VALUES ('" + TEXT_VALUE + "')"; + private static final String TEST_INSERT_QUERY_WITH_SCHEMA = + "INSERT INTO PUBLIC.TABLE_WITH_TRIGGER(TEXT) VALUES ('" + TEXT_VALUE + "')"; private static final String TEST_UPDATE_OR_INSERT = "UPDATE OR INSERT INTO TABLE_WITH_TRIGGER(ID, TEXT) VALUES (1, '" + TEXT_VALUE + "') MATCHING (ID)"; @@ -39,14 +46,15 @@ class FBStatementGeneratedKeysTest extends FBTestGeneratedKeysBase { * Expected: empty generatedKeys result set. *

*/ - @Test - void testExecute_INSERT_noGeneratedKeys() throws Exception { - try (Statement stmt = con.createStatement()) { - boolean producedResultSet = stmt.execute(TEST_INSERT_QUERY, Statement.NO_GENERATED_KEYS); + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecute_INSERT_noGeneratedKeys(boolean withSchema) throws Exception { + try (var stmt = con.createStatement()) { + boolean producedResultSet = stmt.execute(testInsertQuery(withSchema), Statement.NO_GENERATED_KEYS); assertFalse(producedResultSet, "Expected execute to report false (no result set) for INSERT without generated keys returned"); - try (ResultSet rs = stmt.getGeneratedKeys()) { + try (var rs = stmt.getGeneratedKeys()) { assertNotNull(rs, "Expected a non-null result set from getGeneratedKeys"); assertEquals(1, stmt.getUpdateCount(), "Update count should be directly available"); @@ -60,16 +68,21 @@ void testExecute_INSERT_noGeneratedKeys() throws Exception { } } + private static String testInsertQuery(boolean withSchema) { + return withSchema ? TEST_INSERT_QUERY_WITH_SCHEMA : TEST_INSERT_QUERY; + } + /** * Test {@link FBStatement#executeUpdate(String, int)} with {@link Statement#NO_GENERATED_KEYS}. *

* Expected: empty generatedKeys result set. *

*/ - @Test - void testExecuteUpdate_INSERT_noGeneratedKeys() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecuteUpdate_INSERT_noGeneratedKeys(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { - int updateCount = stmt.executeUpdate(TEST_INSERT_QUERY, Statement.NO_GENERATED_KEYS); + int updateCount = stmt.executeUpdate(testInsertQuery(withSchema), Statement.NO_GENERATED_KEYS); assertEquals(1, updateCount, "Expected update count of 1"); try (ResultSet rs = stmt.getGeneratedKeys()) { @@ -89,10 +102,11 @@ void testExecuteUpdate_INSERT_noGeneratedKeys() throws Exception { * Expected: all columns of table returned, single row result set *

*/ - @Test - void testExecute_INSERT_returnGeneratedKeys() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecute_INSERT_returnGeneratedKeys(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { - boolean producedResultSet = stmt.execute(TEST_INSERT_QUERY, Statement.RETURN_GENERATED_KEYS); + boolean producedResultSet = stmt.execute(testInsertQuery(withSchema), Statement.RETURN_GENERATED_KEYS); assertFalse(producedResultSet, "Expected execute to report false (has no result set) for INSERT with generated keys returned"); @@ -163,10 +177,11 @@ void testExecute_UPDATE_with_WHERE_returnGeneratedKeys() throws Exception { * Expected: all columns of table returned, single row result set *

*/ - @Test - void testExecuteUpdate_INSERT_returnGeneratedKeys() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecuteUpdate_INSERT_returnGeneratedKeys(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { - int updateCount = stmt.executeUpdate(TEST_INSERT_QUERY, Statement.RETURN_GENERATED_KEYS); + int updateCount = stmt.executeUpdate(testInsertQuery(withSchema), Statement.RETURN_GENERATED_KEYS); assertEquals(1, updateCount, "Expected update count"); try (ResultSet rs = stmt.getGeneratedKeys()) { @@ -282,7 +297,7 @@ void testExecuteUpdate_INSERT_returnGeneratedKeys_withReturning() throws Excepti /** * Test for {@link FBStatement#execute(String, int)} with {@link Statement#RETURN_GENERATED_KEYS} with an INSERT for - * a non existent table. + * a non-existent table. *

* Expected: SQLException Table unknown *

@@ -314,10 +329,11 @@ void testExecute_INSERT_returnGeneratedKeys_nonExistentTable() throws Exception * Expected: single row result set with only the specified column. *

*/ - @Test - void testExecute_INSERT_columnIndexes() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecute_INSERT_columnIndexes(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { - boolean producedResultSet = stmt.execute(TEST_INSERT_QUERY, new int[] { 1 }); + boolean producedResultSet = stmt.execute(testInsertQuery(withSchema), new int[] { 1 }); assertFalse(producedResultSet, "Expected execute to report false (has no result set) for INSERT with generated keys returned"); @@ -344,10 +360,11 @@ void testExecute_INSERT_columnIndexes() throws Exception { * Expected: single row result set with only the specified column. *

*/ - @Test - void testExecuteUpdate_INSERT_columnIndexes() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecuteUpdate_INSERT_columnIndexes(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { - int updateCount = stmt.executeUpdate(TEST_INSERT_QUERY, new int[] { 1 }); + int updateCount = stmt.executeUpdate(testInsertQuery(withSchema), new int[] { 1 }); assertEquals(1, updateCount, "Expected update count"); try (ResultSet rs = stmt.getGeneratedKeys()) { @@ -371,10 +388,11 @@ void testExecuteUpdate_INSERT_columnIndexes() throws Exception { * Expected: single row result set with only the specified columns. *

*/ - @Test - void testExecute_INSERT_columnIndexes_quotedColumn() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecute_INSERT_columnIndexes_quotedColumn(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { - boolean producedResultSet = stmt.execute(TEST_INSERT_QUERY, new int[] { 1, 3 }); + boolean producedResultSet = stmt.execute(testInsertQuery(withSchema), new int[] { 1, 3 }); assertFalse(producedResultSet, "Expected execute to report false (has no result set) for INSERT with generated keys returned"); @@ -404,10 +422,11 @@ void testExecute_INSERT_columnIndexes_quotedColumn() throws Exception { * Expected: single row result set with only the specified columns. *

*/ - @Test - void testExecuteUpdate_INSERT_columnIndexes_quotedColumn() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecuteUpdate_INSERT_columnIndexes_quotedColumn(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { - int updateCount = stmt.executeUpdate(TEST_INSERT_QUERY, new int[] { 1, 3 }); + int updateCount = stmt.executeUpdate(testInsertQuery(withSchema), new int[] { 1, 3 }); assertEquals(1, updateCount, "Expected update count"); try (ResultSet rs = stmt.getGeneratedKeys()) { @@ -426,6 +445,48 @@ void testExecuteUpdate_INSERT_columnIndexes_quotedColumn() throws Exception { } } + @SuppressWarnings("SqlSourceToSinkFlow") + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + searchPath, expectedSuffix + , _IN_PUBLIC + 'PUBLIC,OTHER_SCHEMA', _IN_PUBLIC + 'OTHER_SCHEMA,PUBLIC', _IN_OTHER_SCHEMA + """) + void testINSERT_schemalessTable_columnIndexes_schemaSearchPath(String searchPath, String expectedSuffix) + throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + try (var stmt = con.createStatement()) { + if (searchPath != null) { + stmt.execute("set search_path to " + searchPath); + } + + stmt.execute("insert into SAME_NAME default values", new int[] { 1, 2 }); + + var rs = stmt.getGeneratedKeys(); + assertNotNull(rs, "Expected a non-null result set from getGeneratedKeys"); + var metaData = rs.getMetaData(); + assertEquals("ID" + expectedSuffix, metaData.getColumnLabel(1), "Unexpected name column 1"); + assertEquals("TEXT" + expectedSuffix, metaData.getColumnLabel(2), "Unexpected name column 2"); + } + } + + @Test + void testINSERT_schemalessTable_columnIndex_tableNotOnSearchPath() throws Exception { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + try (var stmt = con.createStatement()) { + stmt.execute("set search_path to SYSTEM"); + + var exception = assertThrows(SQLNonTransientException.class, + () -> stmt.execute("insert into SAME_NAME default values", new int[] { 1, 2 })); + assertThat(exception, fbMessageStartsWith(JaybirdErrorCodes.jb_generatedKeysNoColumnsFound, + "\"SAME_NAME\"", "schemaless table not on the search path")); + } + } + + // The executeXXX(String, int[]) variants are the only ones that have special handling for schemaless tables + // We consider testing through execute sufficient to cover the other methods as well + // Other combination for execute(String, int[]) already covered in TestGeneratedKeysQuery /** @@ -434,10 +495,11 @@ void testExecuteUpdate_INSERT_columnIndexes_quotedColumn() throws Exception { * Expected: single row result set with only the specified column. *

*/ - @Test - void testExecute_INSERT_columnNames() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecute_INSERT_columnNames(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { - boolean producedResultSet = stmt.execute(TEST_INSERT_QUERY, new String[] { "ID" }); + boolean producedResultSet = stmt.execute(testInsertQuery(withSchema), new String[] { "ID" }); assertFalse(producedResultSet, "Expected execute to report false (has no result set) for INSERT with generated keys returned"); @@ -464,10 +526,11 @@ void testExecute_INSERT_columnNames() throws Exception { * Expected: single row result set with only the specified column. *

*/ - @Test - void testExecuteUpdate_INSERT_columnNames() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecuteUpdate_INSERT_columnNames(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { - int updateCount = stmt.executeUpdate(TEST_INSERT_QUERY, new String[] { "ID" }); + int updateCount = stmt.executeUpdate(testInsertQuery(withSchema), new String[] { "ID" }); assertEquals(1, updateCount, "Expected update count"); try (ResultSet rs = stmt.getGeneratedKeys()) { @@ -491,11 +554,12 @@ void testExecuteUpdate_INSERT_columnNames() throws Exception { * Expected: SQLException for Column unknown. *

*/ - @Test - void testExecute_INSERT_columnNames_nonExistentColumn() throws Exception { + @ParameterizedTest + @MethodSource("withOrWithoutSchema") + void testExecute_INSERT_columnNames_nonExistentColumn(boolean withSchema) throws Exception { try (Statement stmt = con.createStatement()) { SQLException exception = assertThrows(SQLException.class, - () -> stmt.execute(TEST_INSERT_QUERY, new String[] { "ID", "NON_EXISTENT" })); + () -> stmt.execute(testInsertQuery(withSchema), new String[] { "ID", "NON_EXISTENT" })); assertThat(exception, allOf( errorCode(equalTo(ISCConstants.isc_dsql_field_err)), sqlState(equalTo("42S22")), diff --git a/src/test/org/firebirdsql/jdbc/FBTestGeneratedKeysBase.java b/src/test/org/firebirdsql/jdbc/FBTestGeneratedKeysBase.java index e5733c228..5bcb5f730 100644 --- a/src/test/org/firebirdsql/jdbc/FBTestGeneratedKeysBase.java +++ b/src/test/org/firebirdsql/jdbc/FBTestGeneratedKeysBase.java @@ -9,11 +9,15 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.provider.Arguments; import java.sql.Connection; -import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; /** * Test base for tests of retrieval of auto generated keys. @@ -39,6 +43,17 @@ TEXT Varchar(200), "quote_column" INTEGER DEFAULT 2, CONSTRAINT PK_TABLE_WITH_TRIGGER_1 PRIMARY KEY (ID) )"""; + private static final String CREATE_OTHER_SCHEMA = "create schema OTHER_SCHEMA"; + private static final String CREATE_TABLE_SAME_NAME_PUBLIC = """ + create table PUBLIC.SAME_NAME ( + ID_IN_PUBLIC integer generated always as identity constraint PK_SAME_NAME primary key, + TEXT_IN_PUBLIC varchar(200) + )"""; + private static final String CREATE_TABLE_SAME_NAME_OTHER_SCHEMA = """ + create table OTHER_SCHEMA.SAME_NAME ( + ID_IN_OTHER_SCHEMA integer generated always as identity constraint PK_SAME_NAME primary key, + TEXT_IN_OTHER_SCHEMA varchar(200) + )"""; private static final String CREATE_SEQUENCE = "CREATE GENERATOR GEN_TABLE_WITH_TRIGGER_ID"; private static final String INIT_SEQUENCE = "SET GENERATOR GEN_TABLE_WITH_TRIGGER_ID TO 512"; private static final String CREATE_TRIGGER = """ @@ -61,18 +76,36 @@ CONSTRAINT PK_TABLE_WITH_TRIGGER_1 PRIMARY KEY (ID) @RegisterExtension static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( - CREATE_TABLE, - CREATE_SEQUENCE, - CREATE_TRIGGER); + getDbInitStatements()); Connection con; + private static List getDbInitStatements() { + var stmts = new ArrayList<>(List.of( + CREATE_TABLE, + CREATE_SEQUENCE, + CREATE_TRIGGER)); + if (getDefaultSupportInfo().supportsSchemas()) { + stmts.addAll(List.of( + CREATE_OTHER_SCHEMA, + CREATE_TABLE_SAME_NAME_PUBLIC, + CREATE_TABLE_SAME_NAME_OTHER_SCHEMA + )); + } + + return stmts; + } + @BeforeEach void setUp() throws Exception { con = getConnectionViaDriverManager(); - try (Statement stmt = con.createStatement()) { + try (var stmt = con.createStatement()) { stmt.execute("delete from TABLE_WITH_TRIGGER"); stmt.execute(INIT_SEQUENCE); + if (getDefaultSupportInfo().supportsSchemas()) { + // Reset schema search path + stmt.execute("ALTER SESSION RESET"); + } } } @@ -80,4 +113,12 @@ void setUp() throws Exception { void tearDown() throws Exception { con.close(); } + + static Stream withOrWithoutSchema() { + if (getDefaultSupportInfo().supportsSchemas()) { + return Stream.of(Arguments.of(true), Arguments.of(false)); + } + return Stream.of(Arguments.of(false)); + } + } \ No newline at end of file diff --git a/src/test/org/firebirdsql/jdbc/GeneratedKeysQueryTest.java b/src/test/org/firebirdsql/jdbc/GeneratedKeysQueryTest.java index 8d3405b1c..f2d27b313 100644 --- a/src/test/org/firebirdsql/jdbc/GeneratedKeysQueryTest.java +++ b/src/test/org/firebirdsql/jdbc/GeneratedKeysQueryTest.java @@ -12,6 +12,7 @@ import java.sql.SQLException; import java.sql.SQLNonTransientException; import java.sql.Statement; +import java.util.Optional; import static org.firebirdsql.common.matchers.SQLExceptionMatchers.*; import static org.hamcrest.CoreMatchers.*; @@ -284,8 +285,9 @@ void testGeneratedKeys_invalidAutoGeneratedKeys_value() throws SQLException { void testGeneratedKeys_columnIndexes() throws SQLException { initDefaultGeneratedKeysSupport(3, 0); prepareConnectionDialectCheck(3); + when(dbMetadata.findTableSchema("GENERATED_KEYS_TBL")).thenReturn(Optional.of("PUBLIC")); // Metadata for table in query will be retrieved - when(dbMetadata.getColumns(null, null, "GENERATED\\_KEYS\\_TBL", null)).thenReturn(columnRs); + when(dbMetadata.getColumns(null, "PUBLIC", "GENERATED\\_KEYS\\_TBL", null)).thenReturn(columnRs); // We want to return three columns, so for next() three return true, fourth returns false when(columnRs.next()).thenReturn(true, true, true, false); // NOTE: Implementation detail that this calls getString for column 4 (COLUMN_NAME) twice @@ -320,8 +322,9 @@ void testGeneratedKeys_columnIndexes() throws SQLException { void testGeneratedKeys_columnIndexes_dialect1() throws SQLException { initDefaultGeneratedKeysSupport(3, 0); prepareConnectionDialectCheck(1); + when(dbMetadata.findTableSchema("GENERATED_KEYS_TBL")).thenReturn(Optional.of("")); // Metadata for table in query will be retrieved - when(dbMetadata.getColumns(null, null, "GENERATED\\_KEYS\\_TBL", null)).thenReturn(columnRs); + when(dbMetadata.getColumns(null, "", "GENERATED\\_KEYS\\_TBL", null)).thenReturn(columnRs); // We want to return three columns, so for next() three return true, fourth returns false when(columnRs.next()).thenReturn(true, true, true, false); // NOTE: Implementation detail that this calls getString for column 4 (COLUMN_NAME) twice @@ -357,8 +360,9 @@ void testGeneratedKeys_columnIndexes_dialect1() throws SQLException { void testGeneratedKeys_columnIndexes_includingNonExistentIndex() throws SQLException { initDefaultGeneratedKeysSupport(3, 0); prepareConnectionDialectCheck(3); + when(dbMetadata.findTableSchema("GENERATED_KEYS_TBL")).thenReturn(Optional.of("OTHER")); // Metadata for table in query will be retrieved - when(dbMetadata.getColumns(null, null, "GENERATED\\_KEYS\\_TBL", null)).thenReturn(columnRs); + when(dbMetadata.getColumns(null, "OTHER", "GENERATED\\_KEYS\\_TBL", null)).thenReturn(columnRs); // We want to return three columns, so for next() three return true, fourth returns false when(columnRs.next()).thenReturn(true, true, true, false); // NOTE: Implementation detail that this calls getString for column 4 (COLUMN_NAME) twice @@ -370,7 +374,8 @@ void testGeneratedKeys_columnIndexes_includingNonExistentIndex() throws SQLExcep () -> generatedKeysSupport.buildQuery(TEST_INSERT_QUERY, new int[] { 1, 2, 5 })); assertThat(exception, allOf( errorCodeEquals(JaybirdErrorCodes.jb_generatedKeysInvalidColumnPosition), - fbMessageStartsWith(JaybirdErrorCodes.jb_generatedKeysInvalidColumnPosition, "5", "GENERATED_KEYS_TBL"), + fbMessageStartsWith(JaybirdErrorCodes.jb_generatedKeysInvalidColumnPosition, "5", + "\"OTHER\".\"GENERATED_KEYS_TBL\""), sqlStateEquals("22023"))); verify(columnRs).close(); } @@ -390,8 +395,9 @@ void testGeneratedKeys_columnIndexes_includingNonExistentIndex() throws SQLExcep void testGeneratedKeys_columnIndexes_unOrdered() throws SQLException { initDefaultGeneratedKeysSupport(3, 0); prepareConnectionDialectCheck(3); + when(dbMetadata.findTableSchema("GENERATED_KEYS_TBL")).thenReturn(Optional.of("PUBLIC")); // Metadata for table in query will be retrieved - when(dbMetadata.getColumns(null, null, "GENERATED\\_KEYS\\_TBL", null)).thenReturn(columnRs); + when(dbMetadata.getColumns(null, "PUBLIC", "GENERATED\\_KEYS\\_TBL", null)).thenReturn(columnRs); // We want to return three columns, so for next() three return true, fourth returns false when(columnRs.next()).thenReturn(true, true, true, false); // NOTE: Implementation detail that this calls getString for column 4 (COLUMN_NAME) twice From d251d846f63b8ac07185d1659d716b5f0b5db4c9 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 9 Oct 2025 13:35:51 +0200 Subject: [PATCH 45/64] #882 Add schema support for FBCallableStatement --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 18 +- src/docs/asciidoc/release_notes.adoc | 1 - .../org/firebirdsql/gds/AbstractVersion.java | 115 ++++++ .../org/firebirdsql/gds/impl/GDSHelper.java | 19 +- .../gds/impl/GDSServerVersion.java | 75 ++-- .../org/firebirdsql/gds/ng/OdsVersion.java | 66 ++-- .../jaybird/parser/FirebirdReservedWords.java | 14 +- .../parser/ObjectReferenceExtractor.java | 134 +++++++ .../jaybird/parser/OperatorToken.java | 5 +- .../jaybird/parser/SearchPathExtractor.java | 2 +- .../jaybird/parser/SqlTokenizer.java | 3 +- .../parser/UnexpectedEndOfInputException.java | 3 + .../parser/UnexpectedTokenException.java | 36 ++ .../jaybird/util/BasicVersion.java | 122 ++++++ .../jaybird/util/ConditionalHelpers.java | 39 +- .../firebirdsql/jaybird/util/Identifier.java | 65 +++- .../jaybird/util/IdentifierChain.java | 2 +- .../jaybird/util/ObjectReference.java | 51 ++- .../firebirdsql/jdbc/FBCallableStatement.java | 45 +-- .../org/firebirdsql/jdbc/FBConnection.java | 16 +- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 33 +- .../org/firebirdsql/jdbc/FBProcedureCall.java | 212 ++++++++++- .../firebirdsql/jdbc/FBResultSetMetaData.java | 6 +- .../jdbc/FirebirdDatabaseMetaData.java | 59 ++- .../jdbc/StoredProcedureMetaData.java | 49 +-- .../jdbc/StoredProcedureMetaDataFactory.java | 357 +++++++++++++++--- .../jdbc/escape/FBEscapedCallParser.java | 206 +++++++++- .../jdbc/escape/FBEscapedParser.java | 77 ++-- .../jdbc/escape/FBSQLParseException.java | 15 +- .../jdbc/metadata/GetProcedures.java | 21 +- .../firebirdsql/util/FirebirdSupportInfo.java | 3 +- .../firebirdsql/common/FBTestProperties.java | 12 +- .../org/firebirdsql/common/FbAssumptions.java | 10 +- .../gds/impl/GDSServerVersionTest.java | 13 + .../firebirdsql/gds/ng/OdsVersionTest.java | 21 +- .../parser/ObjectReferenceExtractorTest.java | 86 +++++ .../jaybird/util/BasicVersionTest.java | 161 ++++++++ .../jaybird/util/ConditionalHelpersTest.java | 30 +- .../jaybird/util/IdentifierChainTest.java | 11 + .../jaybird/util/IdentifierTest.java | 10 + .../jdbc/ConnectionPropertiesTest.java | 6 +- .../jdbc/FBCallableStatementSchemaTest.java | 166 ++++++++ .../jdbc/FBCallableStatementTest.java | 54 ++- .../jdbc/FBConnectionSchemaTest.java | 12 +- ...DatabaseMetaDataBestRowIdentifierTest.java | 7 +- ...BDatabaseMetaDataColumnPrivilegesTest.java | 5 +- .../jdbc/FBDatabaseMetaDataColumnsTest.java | 4 +- ...FBDatabaseMetaDataFindTableSchemaTest.java | 9 +- ...FBDatabaseMetaDataFunctionColumnsTest.java | 4 +- .../jdbc/FBDatabaseMetaDataFunctionsTest.java | 4 +- .../jdbc/FBDatabaseMetaDataIndexInfoTest.java | 4 +- .../FBDatabaseMetaDataPrimaryKeysTest.java | 5 +- ...BDatabaseMetaDataProcedureColumnsTest.java | 4 +- .../FBDatabaseMetaDataProceduresTest.java | 4 +- .../FBDatabaseMetaDataPseudoColumnsTest.java | 5 +- .../jdbc/FBDatabaseMetaDataSchemasTest.java | 27 +- ...FBDatabaseMetaDataTablePrivilegesTest.java | 7 +- .../jdbc/FBDatabaseMetaDataTablesTest.java | 4 +- .../FBPreparedStatementGeneratedKeysTest.java | 7 +- .../jdbc/FBStatementGeneratedKeysTest.java | 7 +- .../jdbc/escape/FBEscapedCallParserTest.java | 90 ++++- .../escape/FBEscapedFunctionHelperTest.java | 7 +- .../jdbc/escape/FBEscapedParserTest.java | 119 +++--- 63 files changed, 2324 insertions(+), 460 deletions(-) create mode 100644 src/main/org/firebirdsql/gds/AbstractVersion.java create mode 100644 src/main/org/firebirdsql/jaybird/parser/ObjectReferenceExtractor.java create mode 100644 src/main/org/firebirdsql/jaybird/parser/UnexpectedTokenException.java create mode 100644 src/main/org/firebirdsql/jaybird/util/BasicVersion.java create mode 100644 src/test/org/firebirdsql/jaybird/parser/ObjectReferenceExtractorTest.java create mode 100644 src/test/org/firebirdsql/jaybird/util/BasicVersionTest.java create mode 100644 src/test/org/firebirdsql/jdbc/FBCallableStatementSchemaTest.java diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index 9c42211d5..b108cfdbf 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -42,6 +42,11 @@ This is done -- with some exceptions -- at prepare time. * Gbak has additional options to include/exclude (skip) schema data in backup or restore, similar to existing options to include/exclude tables * Gstat has additional options to specify a schema for operations involving tables * For validation, `val_sch_incl` and `val_sch_excl` (I don't think we use the equivalent,`val_tab_incl`/`val_tab_excl` in Jaybird, so might not be relevant) +* Stored procedure resolution: +** Unqualified stored procedures (``) are searched on the search path +** Qualified stored procedures (`.`) are first located by schema and name, and if not found, searched on the search path by package and name. +** Scope-specified stored procedures are either only located by schema (`%SCHEMA.`) or only searched on the search path by package and name (`%PACKAGE.`), not both. +** Fully-qualified packaged stored procedures (`..`) are located by schema, package and name JDBC defines various methods, parameters, and return values or result set columns that are related to schemas. @@ -72,14 +77,14 @@ On Firebird 5.0 and older, this will be silently ignored. * In internal queries in Jaybird, and fully qualified object names, we'll use the regular -- unquoted -- identifier `SYSTEM`, even though `SYSTEM` is a SQL:2023 reserved word, to preserve dialect 1 compatibility. * `Connection.getSchema()` will return the result of `select CURRENT_SCHEMA from SYSTEM.RDB$DATABASE`; the connection will not store this value -* `Connection.setSchema(String)` will query the current search path, if not previously called, it will prepend the schema name to the search path, otherwise it will _replace_ the previously prepended schema name. +* `Connection.setSchema(String)` will query the current search path, and if not previously called, it will prepend the schema name to the search path, otherwise it will _replace_ the previously prepended schema name. The schema name is stored _only_ for this replacement operation (i.e. it will not be returned by `getSchema`!) + ** The name must match exactly as is stored in the metadata (it is always case-sensitive!) ** Jaybird will take care of quoting, and will always quote on dialect 3 ** Existence of the schema is **not** checked, so it is possible the current schema does not change with this operation, as `CURRENT_SCHEMA` reports the first _valid_ schema ** JDBC specifies that "`__Calling ``setSchema`` has no effect on previously created or prepared Statement objects.__`"; -Jaybird cannot honour this requirement for plain `Statement`, as schema resolution is on prepare time (which for plain `Statement` is on execute), and not always for `CallableStatement` (as the implementation may delay actual prepare until execution). +Jaybird cannot honour this requirement for plain `Statement`, as schema resolution is on prepare time (which for plain `Statement` is on execute), and not always for `CallableStatement` (as the implementation may delay actual prepare until execution, though we do try to identify the procedure when the callable statement is created and use that to fully-qualify the procedure). * Request `isc_info_sql_relation_schema` after preparing a query, record it in `FieldDescriptor`, and return it were relevant for JDBC (e.g. `ResultSetMetaData.getSchemaName(int)`) ** For Firebird 5.0 and older, we need to ensure that JDBC methods continue to report the correct value (i.e. `""` for schema-less objects) * A Firebird 6.0 variant of the `DatabaseMetaData` and other internal metadata queries needs to be written to address at least the following things: @@ -91,8 +96,15 @@ Jaybird cannot honour this requirement for plain `Statement`, as schema resoluti * `FirebirdConnection` ** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported ** Added method `List getSearchPatList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported +* `FBCallableStatement` +** On creating the instance, the stored procedure is parsed and identified in the database metadata, including selectability, unless `ignoreProcedureType` is `true` +*** Parsing of callable statements is changed to be able to identify schema, package and procedure, including scope specifiers +** Jaybird emulates the lookup rules as used by Firebird, and -- if found -- records the identified procedure so subsequent internal prepares refer to the same procedure, even if the search path changes; +this fulfills the JDBC requirements that a `CallableStatement` is not sensitive to current schema changes *if* Jaybird is able to identify the procedure, behaviour is undefined if the procedure was not found. +** The API of `StoredProcedureMetaData` (internal API) is changed to not report selectability, but to update the `FBProcedureCall` instance with selectability and other information, like identified schema and/or package. +** For qualified *and* unambiguous procedure reference, the selectability is cached *per connection*, for unqualified or ambiguous procedure reference, the lookup is performed on each `Connection.prepareCall`, to account for search path changes +** Support for packages was missing in the handling of callable statements, and is added, also for older versions * TODO: Define effects for management API -* TODO: Redesign retrieval of selectable procedure information (`StoredProcedureMetaDataFactory`) to be able to find stored procedures by schema * TODO: Add information to Jaybird manual Note to self: use `// TODO Add schema support` in places that you identify need to get/improve schema support, while working on schema support elsewhere diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 773f78b4f..d5f01c2d6 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -685,7 +685,6 @@ If you are confronted with such a change, let us know on {firebird-java}[firebir * `FbWireOperations` ** The `ProcessAttachCallback` parameter of `authReceiveResponse` was removed, as all implementations did nothing, and since protocol 13, it wasn't only called for the attach response ** Interface `ProcessAttachCallback` was removed -* Interface `StoredProcedureMetaData` was made package-private [#breaking-changes-unlikely] === Unlikely breaking changes diff --git a/src/main/org/firebirdsql/gds/AbstractVersion.java b/src/main/org/firebirdsql/gds/AbstractVersion.java new file mode 100644 index 000000000..b39375614 --- /dev/null +++ b/src/main/org/firebirdsql/gds/AbstractVersion.java @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.gds; + +import org.firebirdsql.jaybird.util.BasicVersion; +import org.jspecify.annotations.NonNull; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +/** + * Abstract version for {@code major.minor} version information. + * + * @since 7 + */ +public abstract class AbstractVersion implements Comparable, Serializable { + + @Serial + private static final long serialVersionUID = 909074721396393952L; + + private final int major; + private final int minor; + + protected AbstractVersion(int major, int minor) { + this.major = major; + this.minor = minor; + } + + /** + * @return major version + */ + public final int major() { + return major; + } + + /** + * @return minor version + */ + public final int minor() { + return minor; + } + + /** + * Convenience method to check if the major of this version is equal to or larger than the specified + * required version. + * + * @param requiredMajorVersion + * required major version + * @return {@code true} when current major is equal to or larger than required + */ + public final boolean isEqualOrAbove(int requiredMajorVersion) { + return major >= requiredMajorVersion; + } + + /** + * Convenience method to check if the major.minor of this version is equal to or larger than the specified + * required version. + * + * @param requiredMajorVersion + * required major version + * @param requiredMinorVersion + * required minor version + * @return {@code true} when current major is larger than required, or major is same and minor is equal to or + * larger than required + */ + public final boolean isEqualOrAbove(int requiredMajorVersion, int requiredMinorVersion) { + return major > requiredMajorVersion + || (major == requiredMajorVersion && minor >= requiredMinorVersion); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + AbstractVersion that = (AbstractVersion) o; + return major == that.major && minor == that.minor; + } + + @Override + public int hashCode() { + return Objects.hash(major, minor); + } + + @Override + public String toString() { + return major + "." + minor; + } + + /** + * @return a - possibly cached - basic version ({@code major.minor} only) from the major and minor of this object + */ + public BasicVersion toBasicVersion() { + return BasicVersion.of(this); + } + + /** + * {@inheritDoc} + *

+ * The default implementation compares major and minor; subclasses with more version fields may compare those + * additional fields for instances of their own type and its subclasses. This can result in an unstable order, but + * we accept that as we expect only to compare two versions of potentially differing types, or collections of + * versions of the same type. + *

+ *

+ * If a stable order is required, use a custom comparator that only compares major and minor. + *

+ */ + @Override + public int compareTo(@NonNull AbstractVersion other) { + int majorDiff = Integer.compare(this.major, other.major); + if (majorDiff != 0) return majorDiff; + return Integer.compare(this.minor, other.minor); + } + +} diff --git a/src/main/org/firebirdsql/gds/impl/GDSHelper.java b/src/main/org/firebirdsql/gds/impl/GDSHelper.java index 8188b235d..ab9f538c3 100644 --- a/src/main/org/firebirdsql/gds/impl/GDSHelper.java +++ b/src/main/org/firebirdsql/gds/impl/GDSHelper.java @@ -142,13 +142,15 @@ public void cancelOperation() throws SQLException { // for DatabaseMetaData. + // TODO Consider removing some of these methods by pushing them down into FBDatabaseMetaData + /** * Get the name of the database product that we're connected to. * * @return The database product name (i.e. Firebird or Interbase) */ public String getDatabaseProductName() { - return database.getServerVersion().getServerName(); + return getServerVersion().getServerName(); } /** @@ -157,7 +159,7 @@ public String getDatabaseProductName() { * @return the database product version */ public String getDatabaseProductVersion() { - return database.getServerVersion().getFullVersion(); + return getServerVersion().getFullVersion(); } /** @@ -166,7 +168,7 @@ public String getDatabaseProductVersion() { * @return The major version number of the database */ public int getDatabaseProductMajorVersion() { - return database.getServerVersion().getMajorVersion(); + return getServerVersion().getMajorVersion(); } /** @@ -175,7 +177,7 @@ public int getDatabaseProductMajorVersion() { * @return The minor version number of the database */ public int getDatabaseProductMinorVersion() { - return database.getServerVersion().getMinorVersion(); + return getServerVersion().getMinorVersion(); } /** @@ -201,6 +203,15 @@ public int compareToVersion(int major, int minor) { return differenceMajor; } + /** + * Get the server version. + * + * @return server version + */ + public GDSServerVersion getServerVersion() { + return database.getServerVersion(); + } + /** * Compares the version of this database to the specified major version. *

diff --git a/src/main/org/firebirdsql/gds/impl/GDSServerVersion.java b/src/main/org/firebirdsql/gds/impl/GDSServerVersion.java index 075b12036..b64596fe5 100644 --- a/src/main/org/firebirdsql/gds/impl/GDSServerVersion.java +++ b/src/main/org/firebirdsql/gds/impl/GDSServerVersion.java @@ -4,18 +4,23 @@ // SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause package org.firebirdsql.gds.impl; +import org.firebirdsql.gds.AbstractVersion; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + import java.io.Serial; -import java.io.Serializable; import java.util.Arrays; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrBlank; + /** * Object representing a Firebird server version. The version string is returned * in response to the {@code isc_info_firebird_version} information call. - * Expected version format is: *

+ * Expected version format is: * {@code -...[-] }, * and additional version string elements if present. *

@@ -25,7 +30,8 @@ * "V" - production version, "T" - beta version, "X" - development version. *

*/ -public final class GDSServerVersion implements Serializable { +@NullMarked +public final class GDSServerVersion extends AbstractVersion { @Serial private static final long serialVersionUID = -3401092369588765195L; @@ -66,21 +72,18 @@ public final class GDSServerVersion implements Serializable { private final String type; private final String fullVersion; - private final int majorVersion; - private final int minorVersion; private final int variant; private final int buildNumber; private final String serverName; - private GDSServerVersion(String[] rawVersions, String platform, String type, String fullVersion, int majorVersion, - int minorVersion, int variant, int buildNumber, String serverName) { + private GDSServerVersion(String[] rawVersions, String platform, String type, String fullVersion, + int majorVersion, int minorVersion, int variant, int buildNumber, String serverName) { + super(majorVersion, minorVersion); this.rawVersions = rawVersions.clone(); this.platform = platform; this.type = type; this.fullVersion = fullVersion; - this.majorVersion = majorVersion; - this.minorVersion = minorVersion; this.variant = variant; this.buildNumber = buildNumber; this.serverName = serverName; @@ -91,11 +94,11 @@ public int getBuildNumber() { } public int getMajorVersion() { - return majorVersion; + return major(); } public int getMinorVersion() { - return minorVersion; + return minor(); } public String getPlatform() { @@ -122,7 +125,7 @@ public List getRawVersions() { return List.of(rawVersions); } - public String getExtendedServerName() { + public @Nullable String getExtendedServerName() { if (rawVersions.length < 2) { return null; } else if (rawVersions.length == 2) { @@ -151,7 +154,7 @@ public String getFullVersion() { public int getProtocolVersion() { // We assume the protocol information is in the second version string, // this assumption may be wrong for multi-hop connections - if (rawVersions.length == 1 || rawVersions[1] == null) return -1; + if (rawVersions.length == 1 || isNullOrBlank(rawVersions[1])) return -1; Matcher connectionMetadataMatcher = CONNECTION_METADATA_PATTERN.matcher(rawVersions[1]); if (!connectionMetadataMatcher.find()) return -1; @@ -174,7 +177,7 @@ public boolean isWireCompressionUsed() { private String getConnectionOptions() { // We assume the protocol information is in the second version string, // this assumption may be wrong for multi-hop connections - if (rawVersions.length == 1 || rawVersions[1] == null) return ""; + if (rawVersions.length == 1 || isNullOrBlank(rawVersions[1])) return ""; Matcher connectionMetadataMatcher = CONNECTION_METADATA_PATTERN.matcher(rawVersions[1]); if (!connectionMetadataMatcher.find()) return ""; @@ -215,7 +218,7 @@ public String toString() { * if versionString does not match expected pattern */ public static GDSServerVersion parseRawVersion(String... versionStrings) throws GDSServerVersionException { - if (versionStrings == null || versionStrings.length == 0 || versionStrings[0] == null) { + if (versionStrings == null || versionStrings.length == 0 || isNullOrBlank(versionStrings[0])) { throw new GDSServerVersionException("No version string information present"); } @@ -237,35 +240,6 @@ public static GDSServerVersion parseRawVersion(String... versionStrings) throws matcher.group(SERVER_NAME_IDX)); } - /** - * Convenience method to check if the major of this version is equal to or larger than the specified - * required version. - * - * @param requiredMajorVersion - * Required major version - * @return {@code true} when current major is equal to or larger than required - * @since 6 - */ - public boolean isEqualOrAbove(int requiredMajorVersion) { - return majorVersion >= requiredMajorVersion; - } - - /** - * Convenience method to check if the major.minor of this version is equal to or larger than the specified - * required version. - * - * @param requiredMajorVersion - * Required major version - * @param requiredMinorVersion - * Required minor version - * @return {@code true} when current major is larger than required, or major is same and minor is equal to or - * larger than required - */ - public boolean isEqualOrAbove(int requiredMajorVersion, int requiredMinorVersion) { - return majorVersion > requiredMajorVersion || - (majorVersion == requiredMajorVersion && minorVersion >= requiredMinorVersion); - } - /** * Convenience method to check if the major.minor.variant of this version is equal to or larger than the specified * required version. @@ -278,8 +252,12 @@ public boolean isEqualOrAbove(int requiredMajorVersion, int requiredMinorVersion * Required variant version * @return {@code true} when current major is larger than required, or major is same and minor is equal to required * and variant equal to or larger than required, or major is same and minor is larger than required + * @see #isEqualOrAbove(int) + * @see #isEqualOrAbove(int, int) */ public boolean isEqualOrAbove(int requiredMajorVersion, int requiredMinorVersion, int requiredVariant) { + int majorVersion = major(); + int minorVersion = minor(); return majorVersion > requiredMajorVersion || (majorVersion == requiredMajorVersion && (minorVersion == requiredMinorVersion && variant >= requiredVariant || @@ -288,4 +266,13 @@ public boolean isEqualOrAbove(int requiredMajorVersion, int requiredMinorVersion ); } + @Override + public int compareTo(AbstractVersion o) { + int majorMinorDiff = super.compareTo(o); + if (majorMinorDiff != 0 || !(o instanceof GDSServerVersion other)) return majorMinorDiff; + int variantDiff = Integer.compare(this.variant, other.variant); + if (variantDiff != 0) return variantDiff; + return Integer.compare(this.buildNumber, other.buildNumber); + } + } diff --git a/src/main/org/firebirdsql/gds/ng/OdsVersion.java b/src/main/org/firebirdsql/gds/ng/OdsVersion.java index 51e10d6f2..cb1d2df76 100644 --- a/src/main/org/firebirdsql/gds/ng/OdsVersion.java +++ b/src/main/org/firebirdsql/gds/ng/OdsVersion.java @@ -1,26 +1,33 @@ -// SPDX-FileCopyrightText: Copyright 2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2024-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause package org.firebirdsql.gds.ng; +import org.firebirdsql.gds.AbstractVersion; +import org.jspecify.annotations.NullMarked; + +import java.io.Serial; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * Value class representing the Firebird On-Disk Structure (ODS) version. + *

+ * Implementation limit: {@code major} and {@code minor} must be between 0 and 0xFFFF (65535). + *

* * @author Mark Rotteveel * @since 6 */ -public final class OdsVersion implements Comparable { +@NullMarked +public final class OdsVersion extends AbstractVersion { - private static final Map ODS_VERSION_CACHE = new ConcurrentHashMap<>(); + @Serial + private static final long serialVersionUID = 4152662579163138758L; - private final int major; - private final int minor; + private static final Map ODS_VERSION_CACHE = new ConcurrentHashMap<>(); private OdsVersion(int major, int minor) { - this.major = major; - this.minor = minor; + super(major, minor); } /** @@ -33,6 +40,9 @@ private OdsVersion(int major, int minor) { * @return ODS version instance */ public static OdsVersion of(int major, int minor) { + if ((major & 0xFFFF) != major || (minor & 0xFFFF) != minor) { + throw new IllegalArgumentException("Implementation limit for major or minor exceeded"); + } return ODS_VERSION_CACHE.computeIfAbsent(key(major, minor), ignored -> new OdsVersion(major, minor)); } @@ -50,20 +60,6 @@ public static OdsVersion none() { return of(0, 0); } - /** - * @return ODS major version - */ - public int major() { - return major; - } - - /** - * @return ODS minor version - */ - public int minor() { - return minor; - } - /** * Returns a - possibly cached - instance with the specified major version and the minor version of this instance. * @@ -72,7 +68,7 @@ public int minor() { * @return instance with value of parameter {@code major} and {@link #minor()} of this instance */ public OdsVersion withMajor(int major) { - return this.major != major ? of(major, minor) : this; + return major() != major ? of(major, minor()) : this; } /** @@ -83,32 +79,18 @@ public OdsVersion withMajor(int major) { * @return instance with {@link #major()} of this instance and value of parameter {@code minor} */ public OdsVersion withMinor(int minor) { - return this.minor != minor ? of(major, minor) : this; - } - - @Override - public boolean equals(Object obj) { - if (obj == this) return true; - return obj instanceof OdsVersion that - && this.major == that.major - && this.minor == that.minor; + return minor() != minor ? of(major(), minor) : this; } @Override public int hashCode() { - return key(major, minor); + return key(major(), minor()); } - @Override - public String toString() { - return major + "." + minor; + @Serial + private Object readResolve() { + // Return cached variant + return of(major(), minor()); } - @Override - public int compareTo(OdsVersion o) { - int majorDiff = Integer.compare(this.major, o.major); - if (majorDiff != 0) return majorDiff; - return Integer.compare(this.minor, o.minor); - } - } diff --git a/src/main/org/firebirdsql/jaybird/parser/FirebirdReservedWords.java b/src/main/org/firebirdsql/jaybird/parser/FirebirdReservedWords.java index a0c28d5b5..b625d0396 100644 --- a/src/main/org/firebirdsql/jaybird/parser/FirebirdReservedWords.java +++ b/src/main/org/firebirdsql/jaybird/parser/FirebirdReservedWords.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2021-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2021-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.parser; @@ -40,12 +40,12 @@ public enum FirebirdReservedWords implements ReservedWords { "RDB$SYSTEM_PRIVILEGE", "REAL", "RECORD_VERSION", "RECREATE", "RECURSIVE", "REFERENCES", "REGR_AVGX", "REGR_AVGY", "REGR_COUNT", "REGR_INTERCEPT", "REGR_R2", "REGR_SLOPE", "REGR_SXX", "REGR_SXY", "REGR_SYY", "RELEASE", "RESETTING", "RETURN", "RETURNING_VALUES", "RETURNS", "REVOKE", "RIGHT", "ROLLBACK", "ROW", - "ROWS", "ROW_COUNT", "SAVEPOINT", "SCROLL", "SECOND", "SELECT", "SENSITIVE", "SET", "SIMILAR", "SMALLINT", - "SOME", "SQLCODE", "SQLSTATE", "START", "STDDEV_POP", "STDDEV_SAMP", "SUM", "TABLE", "THEN", "TIME", - "TIMESTAMP", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "TO", "TRAILING", "TRIGGER", "TRIM", "TRUE", "UNBOUNDED", - "UNION", "UNIQUE", "UNKNOWN", "UPDATE", "UPDATING", "UPPER", "USER", "USING", "VALUE", "VALUES", - "VARBINARY", "VARCHAR", "VARIABLE", "VARYING", "VAR_POP", "VAR_SAMP", "VIEW", "WHEN", "WHERE", "WHILE", - "WINDOW", "WITH", "WITHOUT", "YEAR"), + "ROWS", "ROW_COUNT", "SAVEPOINT", "SCHEMA", "SCROLL", "SECOND", "SELECT", "SENSITIVE", "SET", "SIMILAR", + "SMALLINT", "SOME", "SQLCODE", "SQLSTATE", "START", "STDDEV_POP", "STDDEV_SAMP", "SUM", "TABLE", "THEN", + "TIME", "TIMESTAMP", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "TO", "TRAILING", "TRIGGER", "TRIM", "TRUE", + "UNBOUNDED", "UNION", "UNIQUE", "UNKNOWN", "UPDATE", "UPDATING", "UPPER", "USER", "USING", "VALUE", + "VALUES", "VARBINARY", "VARCHAR", "VARIABLE", "VARYING", "VAR_POP", "VAR_SAMP", "VIEW", "WHEN", "WHERE", + "WHILE", "WINDOW", "WITH", "WITHOUT", "YEAR"), ; private final Set reservedWords; diff --git a/src/main/org/firebirdsql/jaybird/parser/ObjectReferenceExtractor.java b/src/main/org/firebirdsql/jaybird/parser/ObjectReferenceExtractor.java new file mode 100644 index 000000000..c427c2a2b --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/parser/ObjectReferenceExtractor.java @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.parser; + +import org.firebirdsql.jaybird.util.Identifier; +import org.firebirdsql.jaybird.util.ObjectReference; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Token visitor to extract an object reference (identifier chain) from a string. + *

+ * When this token visitor is added to a parser, it is expected that the token stream is immediately before the start of + * the object reference (ignoring whitespace and comments). It will unregister itself as soon as it receives a token + * that is not part of an object reference. + *

+ *

+ * If you want to reuse an instance of this token visitor, you must call {@link #reset()} before adding it to + * the parser again. + *

+ * + * @since 7 + */ +public final class ObjectReferenceExtractor implements TokenVisitor { + + // pre-sizing at 3, as for current usages we expect at most 3 identifiers (i.e. ..) + private final List identifiers = new ArrayList<>(3); + private Token previousToken; + private boolean previousTokenWasScopeSpecifierValue; + private UnexpectedTokenException unexpectedTokenException; + + @Override + public void visitToken(Token token, VisitorRegistrar visitorRegistrar) { + if (token.isWhitespaceOrComment()) return; + final boolean previousTokenWasScopeSpecifierValue = this.previousTokenWasScopeSpecifierValue; + this.previousTokenWasScopeSpecifierValue = false; + try { + if (previousToken == null || previousToken instanceof PeriodToken) { + if (token instanceof GenericToken identifier && identifier.isValidIdentifier()) { + identifiers.add(new Identifier(identifier.text().toUpperCase(Locale.ROOT))); + } else if (token instanceof QuotedIdentifierToken quotedIdentifier) { + identifiers.add(new Identifier(quotedIdentifier.name())); + } else { + throw new UnexpectedTokenException( + "Expected a QuotedIdentifierToken or GenericToken with valid identifier, received " + token, + token); + } + } else if (previousToken instanceof GenericToken || previousToken instanceof QuotedIdentifierToken + || previousTokenWasScopeSpecifierValue) { + if (!(token instanceof PeriodToken || isScopeSpecifier(token))) { + // End of identifier chain, we're no longer interested + visitorRegistrar.removeVisitor(this); + return; + } + } else if (isScopeSpecifier(previousToken)) { + if (isScopeSpecifierValue(token)) { + var identifierScope = Identifier.Scope.valueOf(token.text().toUpperCase(Locale.ROOT)); + // Replace last identifier with one with the specified scope + Identifier oldIdentifier = identifiers.remove(identifiers.size() - 1); + identifiers.add(new Identifier(oldIdentifier.name(), identifierScope)); + this.previousTokenWasScopeSpecifierValue = true; + } else { + // End of identifier chain, we're no longer interested + // This is unexpected, but we're assuming future implementation of % as remainder or modular division + // or some other kind of operator which would end the identifier chain + visitorRegistrar.removeVisitor(this); + return; + } + } else { + throw new UnexpectedTokenException( + "Unexpected token or parser state, likely this is an implementation bug, received " + + token, token); + } + } catch (UnexpectedTokenException e) { + unexpectedTokenException = e; + // Unrecoverable, no longer interested + visitorRegistrar.removeVisitor(this); + return; + } + + previousToken = token; + } + + /** + * Resets the state of the detector so it behaves as if it was just created. + */ + public void reset() { + identifiers.clear(); + previousToken = null; + previousTokenWasScopeSpecifierValue = false; + unexpectedTokenException = null; + } + + private boolean isScopeSpecifier(Token token) { + return token instanceof OperatorToken operatorToken && operatorToken.charAt(0) == '%'; + } + + private boolean isScopeSpecifierValue(Token token) { + // SCHEMA is a reserved word, PACKAGE is not + return token instanceof GenericToken && token.equalsIgnoreCase("PACKAGE") + || token instanceof ReservedToken && token.equalsIgnoreCase("SCHEMA"); + } + + @Override + public void complete(VisitorRegistrar visitorRegistrar) { + if (previousToken instanceof PeriodToken) { + unexpectedTokenException = new UnexpectedTokenException( + "Last token was PeriodToken, missing a QuotedIdentifierToken or GenericToken with valid identifier", + previousToken); + } else if (isScopeSpecifier(previousToken)) { + unexpectedTokenException = new UnexpectedTokenException( + "Last token was scope specifier (%), missing scope or other token", previousToken); + } + } + + /** + * Obtains the object reference. + * + * @return object reference + * @throws IllegalStateException + * if an unexpected token was encountered, or if no identifiers where detected + */ + public ObjectReference toObjectReference() { + if (unexpectedTokenException != null) { + throw new IllegalStateException("Parsing failed", unexpectedTokenException); + } else if (identifiers.isEmpty()) { + throw new IllegalStateException("No identifiers were detected"); + } + return ObjectReference.ofIdentifiers(identifiers); + } + +} diff --git a/src/main/org/firebirdsql/jaybird/parser/OperatorToken.java b/src/main/org/firebirdsql/jaybird/parser/OperatorToken.java index c625ed0f1..38477685c 100644 --- a/src/main/org/firebirdsql/jaybird/parser/OperatorToken.java +++ b/src/main/org/firebirdsql/jaybird/parser/OperatorToken.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2021-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2021-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.parser; @@ -7,7 +7,8 @@ *

* The term operator is taken very broadly, and includes mathematical operators ({@code + - / *}, boolean operators * ({@code and or is not} and comparison operators ({@code = <> > < >= <= != ~= ^= !< ~< ^< !> ~> ^>} and the prefix of - * those operators ({@code ! ~ ^} if they appear individually in the statement (which is a syntax error in Firebird). + * those operators ({@code ! ~ ^} if they appear individually in the statement (which is a syntax error in Firebird), + * and the scope specifier ({@code %}). *

* * @author Mark Rotteveel diff --git a/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java b/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java index 093f44529..a6aa10763 100644 --- a/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java +++ b/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java @@ -36,7 +36,7 @@ private void extractIdentifier(Token token, VisitorRegistrar visitorRegistrar) { if (isPreviousTokenSeparator()) { if (token instanceof QuotedIdentifierToken quotedIdentifier) { identifiers.add(quotedIdentifier.name()); - } else if (token instanceof GenericToken identifier) { + } else if (token instanceof GenericToken identifier && identifier.isValidIdentifier()) { // Firebird returns the search path with quoted identifiers, but this offers extra flexibility if needed identifiers.add(identifier.text().toUpperCase(Locale.ROOT)); } else { diff --git a/src/main/org/firebirdsql/jaybird/parser/SqlTokenizer.java b/src/main/org/firebirdsql/jaybird/parser/SqlTokenizer.java index 6a4744084..16959c6a1 100644 --- a/src/main/org/firebirdsql/jaybird/parser/SqlTokenizer.java +++ b/src/main/org/firebirdsql/jaybird/parser/SqlTokenizer.java @@ -162,6 +162,7 @@ private Token nextToken() { yield new PeriodToken(start); } case '+', + '%', // Firebird 6.0 scope specifier '*', // Can also signify 'all' (as in select * or select alias.*) '=' -> new OperatorToken(start, src, start, pos); case '-' -> { @@ -514,7 +515,7 @@ private boolean detectToken(char[][] expectedChars) { private static boolean isNormalTokenBoundary(int c) { return switch (c) { case EOF, '\t', '\n', '\r', ' ', '(', ')', '{', '}', '[', ']', '\'', '"', ':', ';', '.', ',', '+', '-', '/', - '*', '=', '>', '<', '~', '^', '!', '?' -> true; + '*', '=', '>', '<', '~', '^', '!', '?', '%' -> true; default -> false; }; } diff --git a/src/main/org/firebirdsql/jaybird/parser/UnexpectedEndOfInputException.java b/src/main/org/firebirdsql/jaybird/parser/UnexpectedEndOfInputException.java index 8a7d81878..f86724dfa 100644 --- a/src/main/org/firebirdsql/jaybird/parser/UnexpectedEndOfInputException.java +++ b/src/main/org/firebirdsql/jaybird/parser/UnexpectedEndOfInputException.java @@ -4,6 +4,8 @@ import org.firebirdsql.util.InternalApi; +import java.io.Serial; + /** * Thrown when the tokenizer required a character, but instead the end of input was reached. * @@ -13,6 +15,7 @@ @InternalApi public class UnexpectedEndOfInputException extends RuntimeException { + @Serial private static final long serialVersionUID = 5393338512797009183L; public UnexpectedEndOfInputException(String message) { diff --git a/src/main/org/firebirdsql/jaybird/parser/UnexpectedTokenException.java b/src/main/org/firebirdsql/jaybird/parser/UnexpectedTokenException.java new file mode 100644 index 000000000..694ecd71b --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/parser/UnexpectedTokenException.java @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.parser; + +import java.io.Serial; + +/** + * Used by some {@link TokenVisitor} implementations when it receives a token it did not expect. + *

+ * Be aware that some token visitors may use other means of signalling this kind of parsing error. + *

+ *

+ * Usage note: do not throw this from {@link TokenVisitor#visitToken(Token, VisitorRegistrar)} or + * {@link TokenVisitor#complete(VisitorRegistrar)} as the parser will ignore exceptions thrown from those methods. Throw + * it from methods that a user calls to obtain the result of a token visitor. + *

+ * + * @since 7 + */ +public class UnexpectedTokenException extends IllegalStateException { + + @Serial + private static final long serialVersionUID = -2350496918296695040L; + + private final Token token; + + public UnexpectedTokenException(String message, Token token) { + super(message); + this.token = token; + } + + public Token getToken() { + return token; + } + +} diff --git a/src/main/org/firebirdsql/jaybird/util/BasicVersion.java b/src/main/org/firebirdsql/jaybird/util/BasicVersion.java new file mode 100644 index 000000000..528cdef59 --- /dev/null +++ b/src/main/org/firebirdsql/jaybird/util/BasicVersion.java @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.firebirdsql.gds.AbstractVersion; + +import java.io.Serial; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Value class representing a version with {@code major.minor} version information. + *

+ * Implementation limit: {@code major} and {@code minor} must be between 0 and 0xFFFF (65535). + *

+ * + * @since 7 + */ +public final class BasicVersion extends AbstractVersion { + + @Serial + private static final long serialVersionUID = -2133746651860946034L; + + private static final Map VERSION_CACHE = new ConcurrentHashMap<>(); + + private BasicVersion(int major, int minor) { + super(major, minor); + } + + /** + * @return a - possibly cached - basic version object with {@code major} and {@code minor} + */ + public static BasicVersion of(int major, int minor) { + if ((major & 0xFFFF) != major || (minor & 0xFFFF) != minor) { + throw new IllegalArgumentException("Implementation limit for major or minor exceeded"); + } + return VERSION_CACHE.computeIfAbsent(key(major, minor), ignored -> new BasicVersion(major, minor)); + } + + @Override + public BasicVersion toBasicVersion() { + return this; + } + + /** + * Returns a - possibly cached - instance with the specified major version and the minor version of this instance. + * + * @param major + * major version + * @return instance with value of parameter {@code major} and {@link #minor()} of this instance + */ + public BasicVersion withMajor(int major) { + return major() != major ? of(major, minor()) : this; + } + + /** + * Returns a - possibly cached - instance with the major version of this instance and the specified minor version. + * + * @param minor + * minor version + * @return instance with {@link #major()} of this instance and value of parameter {@code minor} + */ + public BasicVersion withMinor(int minor) { + return minor() != minor ? of(major(), minor) : this; + } + + /** + * @return a - possibly cached - basic version object with major {@code 0} and minor {@code 0} + */ + public static BasicVersion none() { + return of(0, 0); + } + + /** + * @return a - possibly cached - basic version object with {@code major} and minor {@code 0} + */ + public static BasicVersion of(int major) { + return of(major, 0); + } + + private static int key(int major, int minor) { + /* In practice, relatively small values occur. Striping them will ensure that in most cases a value with less + than 7 bits set will be produced, which will allow the cache key to be an Integer from the Integer cache (for + major <= 31 and minor <= 3) */ + return (major & 0x1F) | ((minor & 0xFFFF) << 5) | ((major & 0xFFE0) << 16); + } + + /** + * Identity factory method. + * + * @param version + * version + * @return {@code version} (identity operation) + */ + public static BasicVersion of(BasicVersion version) { + return version; + } + + /** + * Factory method to derive a - possibly cached - basic version from {@code version}. + * + * @param version + * version + * @return a {@code BasicVersion} with the same {@code major.minor}, or {@code version} if it is a + * {@code BasicVersion} (identity operation) + */ + public static BasicVersion of(AbstractVersion version) { + return version instanceof BasicVersion bv ? bv : of(version.major(), version.minor()); + } + + @Override + public int hashCode() { + return key(major(), minor()); + } + + @Serial + private Object readResolve() { + // Return cached variant + return of(major(), minor()); + } + +} diff --git a/src/main/org/firebirdsql/jaybird/util/ConditionalHelpers.java b/src/main/org/firebirdsql/jaybird/util/ConditionalHelpers.java index e44add39a..3b3ba0379 100644 --- a/src/main/org/firebirdsql/jaybird/util/ConditionalHelpers.java +++ b/src/main/org/firebirdsql/jaybird/util/ConditionalHelpers.java @@ -1,7 +1,9 @@ -// SPDX-FileCopyrightText: Copyright 2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2023-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.util; +import org.jspecify.annotations.Nullable; + /** * Helpers for conditional behaviour and conditional expressions. * @@ -46,4 +48,39 @@ public static int firstNonZero(int firstValue, int secondValue, int thirdValue) return firstNonZero(secondValue, thirdValue); } + /** + * Returns {@code firstValue} if it is non-null, otherwise {@code secondValue}. + * + * @param firstValue + * first value + * @param secondValue + * second value + * @return {@code firstValue} if it is non-null, otherwise {@code secondValue} (which may be {@code null}) + * @see java.util.Objects#requireNonNullElse(Object, Object) + * @since 7 + */ + public static T firstNonNull(T firstValue, T secondValue) { + if (firstValue != null) return firstValue; + return secondValue; + } + + /** + * Returns {@code firstValue} if it is non-null, otherwise {@code secondValue} if it is non-null, otherwise + * {@code thirdValue}. + * + * @param firstValue + * first value + * @param secondValue + * second value + * @param thirdValue + * third value + * @return {@code firstValue} if it is non-null, otherwise {@code secondValue} if it is non-null, otherwise + * {@code thirdValue} (which may be {@code null}) + * @since 7 + */ + public static T firstNonNull(T firstValue, T secondValue, T thirdValue) { + if (firstValue != null) return firstValue; + return firstNonNull(secondValue, thirdValue); + } + } diff --git a/src/main/org/firebirdsql/jaybird/util/Identifier.java b/src/main/org/firebirdsql/jaybird/util/Identifier.java index 16b120635..e136c436a 100644 --- a/src/main/org/firebirdsql/jaybird/util/Identifier.java +++ b/src/main/org/firebirdsql/jaybird/util/Identifier.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.stream.Stream; +import static java.util.Objects.requireNonNull; import static org.firebirdsql.jaybird.util.StringUtils.trimToNull; /** @@ -19,19 +20,29 @@ public final class Identifier extends ObjectReference { private final String name; + private final Scope scope; public Identifier(String name) { + this(name, Scope.UNKNOWN); + } + + public Identifier(String name, Scope scope) { name = trimToNull(name); if (name == null) { throw new IllegalArgumentException("name cannot be null, empty, or blank"); } this.name = name; + this.scope = requireNonNull(scope, "scope"); } public String name() { return name; } + public Scope scope() { + return scope; + } + @Override public int size() { return 1; @@ -63,7 +74,8 @@ public Identifier last() { * @return name, possibly quoted */ public String toString(QuoteStrategy quoteStrategy) { - return quoteStrategy.quoteObjectName(name); + // 12 is 2 quotes, 1 scope specifier, 7 scope PACKAGE + 2 slack + return append(new StringBuilder(name.length() + 12), quoteStrategy).toString(); } /** @@ -76,7 +88,11 @@ public String toString(QuoteStrategy quoteStrategy) { * @return {@code sb} for chaining */ public StringBuilder append(StringBuilder sb, QuoteStrategy quoteStrategy) { - return quoteStrategy.appendQuoted(name, sb); + quoteStrategy.appendQuoted(name, sb); + if (scope != Scope.UNKNOWN) { + sb.append('%').append(scope.name()); + } + return sb; } @Override @@ -89,6 +105,12 @@ public List toList() { return List.of(this); } + /** + * {@inheritDoc} + *

+ * The {@code scope} is not considered for equality. + *

+ */ @Override public boolean equals(@Nullable Object obj) { if (obj instanceof Identifier other) { @@ -100,11 +122,46 @@ public boolean equals(@Nullable Object obj) { return false; } + /** + * {@inheritDoc} + *

+ * The {@code scope} is not considered for hash code. + *

+ */ @Override public int hashCode() { - // This needs to be consistent with IdentifierChain.hashCode (as if this is a chain with a single item) - // We're clearing the sign bit, because that is what IdentifierChain does to avoid negative values + // This needs to be consistent with IdentifierChain.hashCode (as if this is a chain with a single item). + // We're clearing the sign bit, because that is what IdentifierChain does to avoid negative values. return (31 + name.hashCode()) & 0x7FFF_FFFF; } + /** + * Scope (type) of the identifier. + *

+ * During equality checks, the scope is not considered significant. + *

+ */ + public enum Scope { + /** + * Scope of the identifier is not specified or unknown (the default). + */ + UNKNOWN, + /** + * Scope of the identifier is a schema. + *

+ * This will generally only be used when this was explicitly detected using the scope specifier or other + * context-specific information. + *

+ */ + SCHEMA, + /** + * Scope of the identifier is a package. + *

+ * This will generally only be used when this was explicitly detected using the scope specifier or other + * context-specific information. + *

+ */ + PACKAGE, + } + } diff --git a/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java b/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java index f4f0f5bc8..f80fdebcd 100644 --- a/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java +++ b/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java @@ -81,7 +81,7 @@ private int hashCode0() { for (Identifier identifier : identifiers) { hashCode = 31 * hashCode + identifier.name().hashCode(); } - // Clear sign bit to avoid -1 (and any other negative value) + // Clear sign bit to avoid -1 (and any other negative value) as it's used as a "not cached" marker return this.hashCode = hashCode & 0x7FFF_FFFF; } diff --git a/src/main/org/firebirdsql/jaybird/util/ObjectReference.java b/src/main/org/firebirdsql/jaybird/util/ObjectReference.java index eb2e1d868..e9a637432 100644 --- a/src/main/org/firebirdsql/jaybird/util/ObjectReference.java +++ b/src/main/org/firebirdsql/jaybird/util/ObjectReference.java @@ -68,10 +68,47 @@ public static ObjectReference of(@Nullable String... names) { public static ObjectReference of(List<@Nullable String> names) { //noinspection DataFlowIssue : Identifier(String) is @NonNull, and produce an IllegalArgumentException for null List nameList = names.stream().dropWhile(StringUtils::isNullOrEmpty).map(Identifier::new).toList(); - if (nameList.size() == 1) { - return nameList.get(0); - } - return new IdentifierChain(nameList); + return ofIdentifiers(nameList); + } + + /** + * Creates an object reference from a list of identifiers. + *

+ * If a single identifier is passed, it will return that identifier. + *

+ *

+ * Be careful when combining identifiers with a scope other than {@link Identifier.Scope#UNKNOWN}; this method does + * not check if the combination is syntactically allowed by Firebird. + *

+ * + * @param identifiers + * list of identifiers + * @return an object reference + * @throws IllegalArgumentException + * if {@code identifiers} is empty + */ + public static ObjectReference ofIdentifiers(Identifier... identifiers) { + return ofIdentifiers(List.of(identifiers)); + } + + /** + * Creates an object reference from a list of identifiers. + *

+ * If {@code identifiers} is a singleton list, it will return the first entry of the list. + *

+ *

+ * Be careful when combining identifiers with a scope other than {@link Identifier.Scope#UNKNOWN}; this method does + * not check if the combination is syntactically allowed by Firebird. + *

+ * + * @param identifiers + * list of identifiers + * @return an object reference + * @throws IllegalArgumentException + * if {@code identifiers} is empty + */ + public static ObjectReference ofIdentifiers(List identifiers) { + return identifiers.size() == 1 ? identifiers.get(0) : new IdentifierChain(identifiers); } /** @@ -110,10 +147,16 @@ public static Optional ofTable(FieldDescriptor fieldDescriptor) */ public abstract Identifier at(int index); + /** + * @return first identifier in this object reference + */ public Identifier first() { return at(0); } + /** + * @return last identifier in this object reference + */ public Identifier last() { return at(size() - 1); } diff --git a/src/main/org/firebirdsql/jdbc/FBCallableStatement.java b/src/main/org/firebirdsql/jdbc/FBCallableStatement.java index 1c5abc98d..be89c3d25 100644 --- a/src/main/org/firebirdsql/jdbc/FBCallableStatement.java +++ b/src/main/org/firebirdsql/jdbc/FBCallableStatement.java @@ -6,7 +6,7 @@ SPDX-FileCopyrightText: Copyright 2002-2003 Blas Rodriguez Somoza SPDX-FileCopyrightText: Copyright 2005-2006 Steven Jardine SPDX-FileCopyrightText: Copyright 2007 Gabriel Reid - SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc; @@ -50,27 +50,21 @@ public class FBCallableStatement extends FBPreparedStatement implements Callable static final String SET_BY_STRING_NOT_SUPPORTED = "Setting parameters by name is not supported"; private @Nullable FBResultSet singletonRs; - - protected boolean selectableProcedure; - protected FBProcedureCall procedureCall; protected FBCallableStatement(FBConnection connection, String sql, ResultSetBehavior rsBehavior, StoredProcedureMetaData storedProcMetaData, FBObjectListener.StatementListener statementListener, FBObjectListener.BlobListener blobListener) throws SQLException { super(connection, rsBehavior, statementListener, blobListener); - var parser = new FBEscapedCallParser(); + var parser = new FBEscapedCallParser(connection.getEscapedParser()); // here statement is parsed twice, once in c.nativeSQL(...) // and second time in parser.parseCall(...)... not nice, maybe // in the future should be fixed by calling FBEscapedParser for // each parameter in FBEscapedCallParser class - // TODO Might be unnecessary now FBEscapedParser processes nested escapes + // TODO nativeSQL call might be unnecessary now FBEscapedParser processes nested escapes procedureCall = parser.parseCall(nativeSQL(sql)); - - if (storedProcMetaData.canGetSelectableInformation()) { - setSelectabilityAutomatically(storedProcMetaData); - } + storedProcMetaData.updateSelectability(procedureCall); } @Override @@ -87,7 +81,7 @@ public FirebirdParameterMetaData getFirebirdParameterMetaData() throws SQLExcept checkValidity(); // TODO See http://tracker.firebirdsql.org/browse/JDBC-352 notifyStatementStarted(false); - prepareFixedStatement(procedureCall.getSQL(isSelectableProcedure())); + prepareProcedureCall(); return new FBParameterMetaData(fbStatement.getParameterDescriptor(), connection); } } @@ -119,7 +113,7 @@ protected List executeBatchInternal() throws SQLException { List results = new ArrayList<>(batchList.size()); notifyStatementStarted(); try { - prepareFixedStatement(procedureCall.getSQL(isSelectableProcedure())); + prepareProcedureCall(); for (FBProcedureCall fbProcedureCall : batchList) { procedureCall = fbProcedureCall; results.add(executeSingleForBatch()); @@ -149,12 +143,12 @@ private long executeSingleForBatch() throws SQLException { @Override public void setSelectableProcedure(boolean selectableProcedure) { - this.selectableProcedure = selectableProcedure; + procedureCall.setSelectable(selectableProcedure); } @Override public boolean isSelectableProcedure() { - return selectableProcedure; + return procedureCall.isSelectable(); } /** @@ -189,6 +183,11 @@ protected void prepareFixedStatement(String sql) throws SQLException { super.prepareFixedStatement(sql); } + protected void prepareProcedureCall() throws SQLException { + prepareFixedStatement( + procedureCall.getSQL(connection.getQuoteStrategy())); + } + /** * {@inheritDoc} *

@@ -203,7 +202,7 @@ protected void prepareFixedStatement(String sql) throws SQLException { try (LockCloseable ignored = withLock()) { // TODO See http://tracker.firebirdsql.org/browse/JDBC-352 notifyStatementStarted(false); - prepareFixedStatement(procedureCall.getSQL(isSelectableProcedure())); + prepareProcedureCall(); } return super.getMetaData(); @@ -216,7 +215,7 @@ public boolean execute() throws SQLException { procedureCall.checkParameters(); notifyStatementStarted(); try { - prepareFixedStatement(procedureCall.getSQL(isSelectableProcedure())); + prepareProcedureCall(); boolean hasResultSet = internalExecute(!isSelectableProcedure()); if (hasResultSet) { setRequiredTypes(); @@ -239,7 +238,7 @@ public ResultSet executeQuery() throws SQLException { procedureCall.checkParameters(); notifyStatementStarted(); try { - prepareFixedStatement(procedureCall.getSQL(isSelectableProcedure())); + prepareProcedureCall(); if (!internalExecute(!isSelectableProcedure())) { throw queryProducedNoResultSet(); } @@ -262,7 +261,7 @@ public int executeUpdate() throws SQLException { procedureCall.checkParameters(); notifyStatementStarted(); try { - prepareFixedStatement(procedureCall.getSQL(isSelectableProcedure())); + prepareProcedureCall(); /* R.Rokytskyy: JDBC CTS suite uses executeUpdate() together with output parameters, therefore we * cannot throw exception if we want to pass the test suite. @@ -1272,16 +1271,6 @@ public void setTimestamp(int parameterIndex, @Nullable Timestamp x) throws SQLEx setInputParam(parameterIndex, x); } - /** - * Set the selectability of this stored procedure from RDB$PROCEDURE_TYPE. - * - * @throws SQLException If no selectability information is available - */ - private void setSelectabilityAutomatically(StoredProcedureMetaData storedProcMetaData) - throws SQLException { - selectableProcedure = storedProcMetaData.isSelectable(procedureCall.getName()); - } - /** * {@inheritDoc} *

diff --git a/src/main/org/firebirdsql/jdbc/FBConnection.java b/src/main/org/firebirdsql/jdbc/FBConnection.java index 6853e49d2..aeb483a56 100644 --- a/src/main/org/firebirdsql/jdbc/FBConnection.java +++ b/src/main/org/firebirdsql/jdbc/FBConnection.java @@ -336,10 +336,22 @@ public Array createArrayOf(String typeName, Object[] elements) throws SQLExcepti public String nativeSQL(String sql) throws SQLException { try (LockCloseable ignored = withLock()) { checkValidity(); - return FBEscapedParser.toNativeSql(sql); + return getEscapedParser().toNative(sql); } } + /** + * Get an {@link FBEscapedParser} for this connection. + * + * @return an {@link FBEscapedParser} configured for the version and dialect of this connection + * @throws SQLException if the connection is closed + * @since 7 + */ + FBEscapedParser getEscapedParser() throws SQLException { + // We're not caching it, as it has no real state and is cheap to create + return FBEscapedParser.of(getGDSHelper().getServerVersion(), getQuoteStrategy()); + } + @Override public void setAutoCommit(boolean autoCommit) throws SQLException { try (LockCloseable ignored = withLock()) { @@ -872,6 +884,8 @@ public CallableStatement prepareCall(String sql, int resultSetType, int resultSe rsBehavior = rsBehavior.withReadOnly(); } + // TODO Maybe we should move creation and retention of the StoredProcedureMetaData to FBDatabaseMetaData, + // then we can also clear the cached selectability info if FBDatabaseMetaData.close() is called. if (storedProcedureMetaData == null) { storedProcedureMetaData = StoredProcedureMetaDataFactory.getInstance(this); } diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 4f1c8ca48..b6d42bc4d 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -16,6 +16,7 @@ import org.firebirdsql.gds.JaybirdErrorCodes; import org.firebirdsql.gds.impl.GDSFactory; import org.firebirdsql.gds.impl.GDSHelper; +import org.firebirdsql.gds.impl.GDSServerVersion; import org.firebirdsql.gds.impl.GDSType; import org.firebirdsql.gds.ng.FbExceptionBuilder; import org.firebirdsql.gds.ng.LockCloseable; @@ -1185,6 +1186,18 @@ public boolean dataDefinitionIgnoredInTransactions() throws SQLException { * {@inheritDoc} * *

+ * Jaybird defines these additional columns: + *

    + *
  1. JB_PROCEDURE_TYPE Short => type of procedure ({@code RDB$PROCEDURES.RDB$PROCEDURE_TYPE}): + *
      + *
    • {@link #jbProcedureTypeUnknown} ({@code 0}) — unknown
    • + *
    • {@link #jbProcedureTypeSelectable} ({@code 1}) — selectable
    • + *
    • {@link #jbProcedureTypeExecutable} ({@code 2}) — executable
    • + *
    + *
  2. + *
+ *

+ *

* By default, this method does not return procedures defined in packages. To also return procedures in packages, * set connection property {@code useCatalogAsPackage} to {@code true}. When enabled, this method has the following * differences in behaviour: @@ -1371,7 +1384,7 @@ public ResultSet getColumns(String catalog, String schemaPattern, String tableNa * Contrary to specified in the JDBC API, the result set is ordered by {@code TABLE_SCHEM}, {@code COLUMN_NAME}, * {@code PRIVILEGE}, and {@code GRANTEE} (JDBC specifies ordering by {@code COLUMN_NAME} and {@code PRIVILEGE}). * This only makes a difference when specifying {@code null} for {@code schema} (search all schemas) and there are - * multiples tables with the same {@code name}. + * multiples tables with the same name {@code table}. *

*/ @Override @@ -1394,7 +1407,7 @@ public ResultSet getColumnPrivileges(String catalog, String schema, String table *

*

* NOTE: This implementation returns all privileges, not just applicable to the current user. It is - * unclear if this complies with the JDBC requirements. This may change in the future to only return only privileges + * unclear if this complies with the JDBC requirements. This may change in the future to only return privileges * applicable to the current user, user {@code PUBLIC} and — maybe — active roles. *

*/ @@ -1684,23 +1697,23 @@ public int getResultSetHoldability() throws SQLException { } @Override - public int getDatabaseMajorVersion() throws SQLException { - return gdsHelper.getDatabaseProductMajorVersion(); + public GDSServerVersion getServerVersion() throws SQLException { + return gdsHelper.getServerVersion(); } @Override - public int getDatabaseMinorVersion() throws SQLException { - return gdsHelper.getDatabaseProductMinorVersion(); + public int getDatabaseMajorVersion() throws SQLException { + return getServerVersion().major(); } @Override - public int getOdsMajorVersion() throws SQLException { - return gdsHelper.getCurrentDatabase().getOdsMajor(); + public int getDatabaseMinorVersion() throws SQLException { + return getServerVersion().minor(); } @Override - public int getOdsMinorVersion() throws SQLException { - return gdsHelper.getCurrentDatabase().getOdsMinor(); + public OdsVersion getOdsVersion() throws SQLException { + return gdsHelper.getCurrentDatabase().getOdsVersion(); } @Override diff --git a/src/main/org/firebirdsql/jdbc/FBProcedureCall.java b/src/main/org/firebirdsql/jdbc/FBProcedureCall.java index 4eaac611c..5490adc7f 100644 --- a/src/main/org/firebirdsql/jdbc/FBProcedureCall.java +++ b/src/main/org/firebirdsql/jdbc/FBProcedureCall.java @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: Copyright 2003-2005 Roman Rokytskyy // SPDX-FileCopyrightText: Copyright 2005-2006 Steven Jardine -// SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; import org.firebirdsql.jaybird.util.CollectionUtils; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.util.InternalApi; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -14,11 +15,14 @@ import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.Optional; +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrBlank; +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; import static org.firebirdsql.jdbc.SQLStateConstants.*; /** - * Represents procedure call. + * Represents a procedure call. *

* This class is internal API of Jaybird. Future versions may radically change, move, or make inaccessible this type. *

@@ -30,15 +34,30 @@ public class FBProcedureCall { private static final String NATIVE_CALL_COMMAND = "EXECUTE PROCEDURE "; private static final String NATIVE_SELECT_COMMAND = "SELECT * FROM "; + public static final String NO_SCHEMA = ""; + public static final String NO_PACKAGE = ""; + + private @Nullable String schema; + private @Nullable String pkg; private @Nullable String name; - private List<@Nullable FBProcedureParam> inputParams = new ArrayList<>(); - private List<@Nullable FBProcedureParam> outputParams = new ArrayList<>(); + private boolean ambiguousScope; + private boolean selectable; + private @Nullable ObjectReference objectReference; + private final List<@Nullable FBProcedureParam> inputParams; + private final List<@Nullable FBProcedureParam> outputParams; public FBProcedureCall() { + inputParams = new ArrayList<>(); + outputParams = new ArrayList<>(); } private FBProcedureCall(FBProcedureCall source) { + schema = source.schema; + pkg = source.pkg; name = source.name; + ambiguousScope = source.ambiguousScope; + selectable = source.selectable; + objectReference = source.objectReference; inputParams = cloneParameters(source.inputParams); outputParams = cloneParameters(source.outputParams); } @@ -48,31 +67,164 @@ public static FBProcedureCall copyOf(FBProcedureCall source) { } private static List<@Nullable FBProcedureParam> cloneParameters(final List<@Nullable FBProcedureParam> parameters) { - final List<@Nullable FBProcedureParam> clonedParameters = new ArrayList<>(parameters.size()); + final var clonedParameters = new ArrayList<@Nullable FBProcedureParam>(parameters.size()); for (FBProcedureParam param : parameters) { clonedParameters.add(param != null ? (FBProcedureParam) param.clone() : null); } return clonedParameters; } + /** + * Get the schema of the procedure. + * + * @return name of the schema, {@code ""} (empty string) if no schema, or {@code null} if schema is not known + * @see #isAmbiguousScope() + * @since 7 + */ + public @Nullable String getSchema() { + return schema; + } + + /** + * Set schema of the procedure, case must match as stored in metadata tables. + * + * @param schema + * name of the schema, {@code ""} (empty string) if no schema, or {@code null} if schema is not known + * @see #isAmbiguousScope() + * @since 7 + */ + public void setSchema(@Nullable String schema) { + this.schema = schema; + } + + /** + * Get the package of the procedure. + * + * @return name of the package, {@code ""} (empty string) if no package, or {@code null} if package is not known + * @see #isAmbiguousScope() + * @since 7 + */ + public @Nullable String getPackage() { + return pkg; + } + + /** + * Set package of the procedure, case must match as stored in metadata tables. + * + * @param pkg + * name of the package, {@code ""} (empty string) if no package, or {@code null} if package is not known + * @see #isAmbiguousScope() + * @since 7 + */ + public void setPackage(@Nullable String pkg) { + this.pkg = pkg; + } + /** * Get the name of the procedure to be called. * - * @return The procedure name + * @return procedure name */ public @Nullable String getName() { return name; } /** - * Set the name of the procedure to be called. + * Set the name of the procedure to be called, case must match as stored in metadata tables. * - * @param name The name of the procedure + * @param name + * name of the procedure */ public void setName(String name) { this.name = name; } + /** + * @return {@code true} if there might be a scope ambiguity (value of {@code schema} might be the package, with + * unknown schema) + */ + public boolean isAmbiguousScope() { + return ambiguousScope; + } + + /** + * Marks this procedure call to have ambiguous scope (value of {@code schema} might be the package, with unknown + * schema). + * + * @param ambiguousScope + * {@code true} if ambiguous scope, {@code false} if not + */ + public void setAmbiguousScope(boolean ambiguousScope) { + this.ambiguousScope = ambiguousScope; + } + + /** + * @return {@code true} if selectable, {@code false} if executable, or if selectability hasn't been resolved yet + */ + public boolean isSelectable() { + return selectable; + } + + /** + * Sets selectability of the procedure + * + * @param selectable + * {@code true} marks as selectable, {@code false} as executable + */ + public void setSelectable(boolean selectable) { + this.selectable = selectable; + } + + /** + * Get the object reference that is explicitly stored on this procedure call. + * + * @return object reference, or empty if no object reference was explicitly set + * @see #deriveObjectReference() + * @since 7 + */ + public Optional getObjectReference() { + return Optional.ofNullable(objectReference); + } + + /** + * Set the object reference of the procedure. + * + * @param objectReference + * object reference (or {@code null} to clear) + */ + public void setObjectReference(@Nullable ObjectReference objectReference) { + this.objectReference = objectReference; + } + + /** + * Derive the object reference for this procedure call. + *

+ * This method will either return the explicitly stored object reference (see {@link #getObjectReference()}), or + * otherwise derive it from the current values of {@code schema}, {@code pkg} and {@code name}. + *

+ *

+ * The derived object reference is not stored in this instance. If that is needed, it must be done + * explicitly by the caller. + *

+ * + * @return derived object reference + * @throws IllegalStateException + * if {@code name} is {@code null} or blank + * @see #getObjectReference() + * @since 7 + */ + public ObjectReference deriveObjectReference() { + return getObjectReference().orElseGet(() -> { + if (isNullOrBlank(name)) { + throw new IllegalStateException("Property name is null, cannot derive object reference"); + } else if (isNullOrEmpty(pkg)) { + return ObjectReference.of(schema, name); + } else { + return ObjectReference.of(schema, pkg, name); + } + }); + } + /** * Get input parameter by the specified index. * @@ -263,14 +415,9 @@ public void registerOutParam(int index, int type) throws SQLException { param.setType(type); } - /** - * Get native SQL for the specified procedure call. - * - * @return native SQL that can be executed by the database server. - */ - public String getSQL(boolean select) throws SQLException { - StringBuilder sb = new StringBuilder(select ? NATIVE_SELECT_COMMAND : NATIVE_CALL_COMMAND); - sb.append(name); + public String getSQL(QuoteStrategy quoteStrategy) { + var sb = new StringBuilder(selectable ? NATIVE_SELECT_COMMAND : NATIVE_CALL_COMMAND); + deriveObjectReference().append(sb, quoteStrategy); boolean firstParam = true; sb.append('('); @@ -315,21 +462,46 @@ public void checkParameters() throws SQLException { } /** - * Check if obj is equal to this instance. + * Check if {@code obj} is equal to this instance. + *

+ * The fields {@code objectReference} and {@code ambiguousScope} are not considered for equality. + *

* - * @return true iff obj is instance of this class - * representing the same procedure with the same parameters. + * @return {@code true} if {@code obj}is instance of this class representing the same procedure with the same + * parameters */ public boolean equals(Object obj) { if (obj == this) return true; return obj instanceof FBProcedureCall other && Objects.equals(name, other.name) + && Objects.equals(schema, other.schema) + && Objects.equals(pkg, other.pkg) && inputParams.equals(other.inputParams) && outputParams.equals(other.outputParams); } + /** + * {@inheritDoc} + *

+ * The fields {@code objectReference} and {@code ambiguousScope} are not considered for the hashcode. + *

+ */ public int hashCode() { - return Objects.hash(name, inputParams, outputParams); + return Objects.hash(schema, pkg, name, inputParams, outputParams); + } + + @Override + public String toString() { + return "FBProcedureCall{" + + "schema='" + schema + '\'' + + ", pkg='" + pkg + '\'' + + ", name='" + name + '\'' + + ", ambiguousScope=" + ambiguousScope + + ", selectable=" + selectable + + ", objectReference=" + objectReference + + ", inputParams=" + inputParams + + ", outputParams=" + outputParams + + '}'; } /** diff --git a/src/main/org/firebirdsql/jdbc/FBResultSetMetaData.java b/src/main/org/firebirdsql/jdbc/FBResultSetMetaData.java index 426bf5c0c..b47ee7716 100644 --- a/src/main/org/firebirdsql/jdbc/FBResultSetMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBResultSetMetaData.java @@ -161,8 +161,8 @@ public String getColumnName(int column) throws SQLException { /** * {@inheritDoc} * - * @return Schema of table, empty string ({@code ""}) if schemaless, e.g. always on Firebird 5.0 and older, or if - * the column has no backing table + * @return schema of table, empty string ({@code ""}) if schemaless (i.e. always on Firebird 5.0 and older), or if + * the column has no known backing table */ @Override public String getSchemaName(int column) throws SQLException { @@ -173,7 +173,7 @@ public String getSchemaName(int column) throws SQLException { * {@inheritDoc} *

* NOTE For {@code NUMERIC} and {@code DECIMAL} we attempt to retrieve the exact precision from the metadata, - * if this is not possible (eg the column is dynamically defined in the query), the reported precision is + * if this is not possible (e.g. the column is dynamically defined in the query), the reported precision is * the maximum precision allowed by the underlying storage data type. *

*/ diff --git a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java index a231a640a..f249571d4 100644 --- a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java @@ -5,19 +5,42 @@ // SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause package org.firebirdsql.jdbc; +import org.firebirdsql.gds.impl.GDSServerVersion; +import org.firebirdsql.gds.ng.OdsVersion; +import org.firebirdsql.util.InternalApi; + import java.sql.DatabaseMetaData; import java.sql.SQLException; import java.util.Optional; /** - * Extension of {@link DatabaseMetaData} interface providing access to Firebird - * specific features. + * Extension of {@link DatabaseMetaData} interface providing access to Firebird specific features. * * @author Michael Romankiewicz + * @author Mark Rotteveel */ @SuppressWarnings("unused") public interface FirebirdDatabaseMetaData extends DatabaseMetaData { + // TODO Add schema support: remove/deprecate getProcedureSourceCode(String) and remove new alternative with schema, + // and instead add a column to getProcedures(..) + + /** + * Firebird procedure type is unknown (value of column {@code JB_PROCEDURE_TYPE} of + * {@link #getProcedures(String, String, String)}) + */ + int jbProcedureTypeUnknown = 0; + /** + * Firebird procedure type is selectable (value of column {@code JB_PROCEDURE_TYPE} of + * {@link #getProcedures(String, String, String)}) + */ + int jbProcedureTypeSelectable = 1; + /** + * Firebird procedure type is executable (value of column {@code JB_PROCEDURE_TYPE} of + * {@link #getProcedures(String, String, String)}) + */ + int jbProcedureTypeExecutable = 2; + /** * Get the source of a stored procedure. *

@@ -106,6 +129,15 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { */ String getViewSourceCode(String schema, String viewName) throws SQLException; + /** + * Get the Firebird server version. + * + * @return server version object + * @throws SQLException + * if a database access error occurs + */ + GDSServerVersion getServerVersion() throws SQLException; + /** * Get the major version of the ODS (On-Disk Structure) of the database. * @@ -113,7 +145,9 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * @throws SQLException * if a database access error occurs */ - int getOdsMajorVersion() throws SQLException; + default int getOdsMajorVersion() throws SQLException { + return getOdsVersion().major(); + } /** * Get the minor version of the ODS (On-Disk Structure) of the database. @@ -122,7 +156,24 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * @throws SQLException * if a database access error occurs */ - int getOdsMinorVersion() throws SQLException; + default int getOdsMinorVersion() throws SQLException { + return getOdsVersion().major(); + } + + /** + * Get the ODS (On-Disk Structure) version of the database. + *

+ * This method is marked internal API as {@link OdsVersion} is internal API. We don't expect this method to be + * removed, nor the API of {@code OdsVersion} to radically change in future versions. + *

+ * + * @return ODS version object + * @throws SQLException + * if a database access error occurs + * @since 7 + */ + @InternalApi + OdsVersion getOdsVersion() throws SQLException; /** * Get the dialect of the database. diff --git a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaData.java b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaData.java index 7c4e8599f..b9bc96590 100644 --- a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaData.java +++ b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaData.java @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2007 Gabriel Reid -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause package org.firebirdsql.jdbc; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.util.InternalApi; import org.jspecify.annotations.NullMarked; @@ -17,31 +18,35 @@ */ @InternalApi @NullMarked -sealed interface StoredProcedureMetaData - permits DefaultCallableStatementMetaData, DummyCallableStatementMetaData { +public sealed interface StoredProcedureMetaData + permits DummyStoredProcedureMetaData, PackageAwareStoredProcedureMetaData, SchemaAwareStoredProcedureMetaData { /** - * Determine if the "selectability" of procedures is available. - * This functionality is only available starting from Firebird 2.1, - * and only with databases created by that version or later. - * - * @return {@code true} if selectability information is available, {@code false} otherwise - */ - boolean canGetSelectableInformation(); - - /** - * Retrieve whether a given stored procedure is selectable. + * Determines the selectability of a stored procedure and records it on {@code procedureCall}. + *

+ * On Firebird 6.0 and higher, if the procedure call has ambiguous scope or an unknown schema, the resolved stored + * procedure will be recorded and the ambiguity removed. We try to replicate the Firebird rules for resolving + * ambiguity, but it is possible that we diverge. By recording the procedure we resolved, we ensure the executed + * stored procedure is at least consistent with our decision, and ensure that changes to the search path do not + * change which procedure is executed. Our implementation of callable statement may internally prepare multiple + * times over the lifetime of the statement object, while JDBC requires stable schema resolution after + * {@code prepareCall}. + *

+ *

+ * On Firebird versions that do not have selectability information, this will not perform any attempt to resolve + * selectability. If the procedure cannot be found, it will also be returned as-is, but will then likely fail at + * execute (or other operations which perform an internal prepare). + *

*

- * A selectable procedure is one that can return multiple rows of results (i.e. it uses a {@code SUSPEND} - * statement). + * Implementations may call {@link FBProcedureCall#setObjectReference(ObjectReference)} if not already set, but + * are not required to do so. *

- * - * @param procedureName - * The name of the procedure for which selectability information is to be retrieved - * @return - * {@code true} if the procedure is selectable, {@code false} otherwise - * @throws SQLException If no selectability information is available + * + * @param procedureCall + * procedure call information to update + * @throws SQLException + * for failures to query database metadata */ - boolean isSelectable(String procedureName) throws SQLException; + void updateSelectability(FBProcedureCall procedureCall) throws SQLException; } diff --git a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java index eab78c4c8..044d2c3a0 100644 --- a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java +++ b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java @@ -3,19 +3,31 @@ // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc; +import org.firebirdsql.gds.impl.GDSType; +import org.firebirdsql.gds.ng.OdsVersion; +import org.firebirdsql.jaybird.util.ObjectReference; +import org.firebirdsql.jaybird.util.UncheckedSQLException; +import org.firebirdsql.jdbc.DbMetadataMediator.MetadataQuery; +import org.firebirdsql.jdbc.metadata.GetProcedures; +import org.firebirdsql.jdbc.metadata.MetadataPattern; +import org.firebirdsql.util.FirebirdSupportInfo; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLNonTransientException; -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; -import static org.firebirdsql.jdbc.SQLStateConstants.SQL_STATE_GENERAL_ERROR; -import static org.firebirdsql.util.FirebirdSupportInfo.supportInfoFor; +import static java.lang.System.Logger.Level.TRACE; +import static java.util.Objects.requireNonNull; +import static org.firebirdsql.jaybird.util.ConditionalHelpers.firstNonNull; /** - * Factory to retrieve meta-data on stored procedures in a Firebird database. + * Factory to retrieve metadata on stored procedures in a Firebird database. */ @NullMarked final class StoredProcedureMetaDataFactory { @@ -25,95 +37,322 @@ private StoredProcedureMetaDataFactory() { } /** - * Retrieve a {@link StoredProcedureMetaData} object for a Connection. + * Retrieve a {@link StoredProcedureMetaData} object for a connection. * * @param connection - * The connection for which data is to be retrieved + * connection for which data is to be retrieved * @return {@link StoredProcedureMetaData} for the current connection * @throws SQLException - * if an exception occurs while retrieving meta-data + * if an exception occurs while retrieving metadata */ - public static StoredProcedureMetaData getInstance(FBConnection connection) throws SQLException { - if (connectionHasProcedureMetadata(connection)) { - return new DefaultCallableStatementMetaData(connection); - } else { - return new DummyCallableStatementMetaData(); - } - } - - private static boolean connectionHasProcedureMetadata(FBConnection connection) throws SQLException { + static StoredProcedureMetaData getInstance(FBConnection connection) throws SQLException { if (connection.isIgnoreProcedureType()) { - return false; + return new DummyStoredProcedureMetaData(); } FirebirdDatabaseMetaData metaData = connection.getMetaData(); - - return versionEqualOrAboveFB21(metaData.getDatabaseMajorVersion(), metaData.getDatabaseMinorVersion()) - && versionEqualOrAboveFB21(metaData.getOdsMajorVersion(), metaData.getOdsMinorVersion()); + OdsVersion odsVersion = metaData.getOdsVersion(); + if (odsVersion.isEqualOrAbove(14)) { + // Schema support (Firebird 6 or higher) + return new SchemaAwareStoredProcedureMetaData(connection); + } if (!odsVersion.isEqualOrAbove(11, 1)) { + // No selectability info (Firebird 2.0 or earlier database) + return new DummyStoredProcedureMetaData(); + } + // Fallback to package-aware support (Firebird 2.1 or higher databases) + return new PackageAwareStoredProcedureMetaData(connection); } - private static boolean versionEqualOrAboveFB21(int majorVersion, int minorVersion) { - return majorVersion > 2 || - (majorVersion == 2 && minorVersion >= 1); - } } /** - * A fully-functional implementation of {@link StoredProcedureMetaData}. + * Implementation of {@link StoredProcedureMetaData} that is schema-aware and package-aware. It can resolve scope + * ambiguity and find procedures on the search path. */ @NullMarked -final class DefaultCallableStatementMetaData implements StoredProcedureMetaData { +final class SchemaAwareStoredProcedureMetaData implements StoredProcedureMetaData { - // TODO Add schema support: solution needs to be reworked to support schemas, which will cascade into - // callable statement parsing. This needs further investigation. In addition, the current solution doesn't handle - // case-sensitivity + /** + * Resolve ambiguous procedure references, by first looking for schema+name, and then searching on the search path + * for package+name. + *

+ * This emulates the resolution rules that Firebird itself also uses if no scope-specifier is used. + *

+ */ + private static final String FIND_AMBIGUOUS_PROCEDURE = """ + with SEARCH_PATH as ( + select row_number() over() as PRIO, NAME as SCHEMA_NAME + from SYSTEM.RDB$SQL.PARSE_UNQUALIFIED_NAMES(rdb$get_context('SYSTEM', 'SEARCH_PATH')) + ) + select + 0 as PRIO, + sp.RDB$SCHEMA_NAME as SCHEMA_NAME, + sp.RDB$PACKAGE_NAME as PACKAGE_NAME, + sp.RDB$PROCEDURE_NAME as PROCEDURE_NAME, + sp.RDB$PROCEDURE_TYPE as PROCEDURE_TYPE + from RDB$PROCEDURES sp + where sp.RDB$SCHEMA_NAME = ? and sp.RDB$PACKAGE_NAME is null and sp.RDB$PROCEDURE_NAME = ? + union all + select + s.PRIO, + sp.RDB$SCHEMA_NAME, + sp.RDB$PACKAGE_NAME, + sp.RDB$PROCEDURE_NAME, + sp.RDB$PROCEDURE_TYPE + from SEARCH_PATH s + inner join RDB$PROCEDURES sp + on sp.RDB$SCHEMA_NAME = s.SCHEMA_NAME + where sp.RDB$PACKAGE_NAME = ? and sp.RDB$PROCEDURE_NAME = ? + order by 1 + fetch first row only + """; - final Set selectableProcedureNames = new HashSet<>(); + /** + * Find a procedure on the search path, by package (optional) and name. + */ + private static final String FIND_SCHEMA_PROCEDURE = """ + with SEARCH_PATH as ( + select row_number() over() as PRIO, NAME as SCHEMA_NAME + from SYSTEM.RDB$SQL.PARSE_UNQUALIFIED_NAMES(rdb$get_context('SYSTEM', 'SEARCH_PATH')) + ) + select + s.PRIO, + sp.RDB$SCHEMA_NAME as SCHEMA_NAME, + sp.RDB$PACKAGE_NAME as PACKAGE_NAME, + sp.RDB$PROCEDURE_NAME as PROCEDURE_NAME, + sp.RDB$PROCEDURE_TYPE as PROCEDURE_TYPE + from SEARCH_PATH s + inner join RDB$PROCEDURES sp + on sp.RDB$SCHEMA_NAME = s.SCHEMA_NAME + where sp.RDB$PACKAGE_NAME is not distinct from nullif(?, '') and sp.RDB$PROCEDURE_NAME = ? + order by 1 + fetch first row only + """; + + private static final System.Logger LOG = System.getLogger(SchemaAwareStoredProcedureMetaData.class.getName()); + + private final PackageAwareStoredProcedureMetaData packageAwareStoredProcedureMetaData; + private final DbMetadataMediator metadataMediator; - DefaultCallableStatementMetaData(FBConnection connection) - throws SQLException { - loadSelectableProcedureNames(connection); + SchemaAwareStoredProcedureMetaData(FBConnection connection) throws SQLException { + packageAwareStoredProcedureMetaData = new PackageAwareStoredProcedureMetaData(connection); + FBDatabaseMetaData dbmd = connection.getMetaData().unwrap(FBDatabaseMetaData.class); + metadataMediator = dbmd.getDbMetadataMediator(); } - private void loadSelectableProcedureNames(FBConnection connection) throws SQLException { - try (var stmt = connection.createStatement()) { - // TODO Replace with looking for specific procedure - String sql = supportInfoFor(connection).supportsSchemas() - ? "SELECT RDB$PROCEDURE_NAME FROM SYSTEM.RDB$PROCEDURES WHERE RDB$PROCEDURE_TYPE = 1" - : "SELECT RDB$PROCEDURE_NAME FROM RDB$PROCEDURES WHERE RDB$PROCEDURE_TYPE = 1"; - try (var resultSet = stmt.executeQuery(sql)) { - while (resultSet.next()) { - selectableProcedureNames.add(resultSet.getString(1).trim().toUpperCase(Locale.ROOT)); - } - } + @Override + public void updateSelectability(FBProcedureCall procedureCall) throws SQLException { + // We can't cache for not fully qualified procedures, as their resolution depends on the search path. + if (procedureCall.isAmbiguousScope()) { + findAmbiguousProcedure(procedureCall); + } else if (procedureCall.getSchema() == null) { + findProcedureOnSearchPath(procedureCall); + } else { + // Already fully qualified, delegate to package-aware implementation + packageAwareStoredProcedureMetaData.updateSelectability(procedureCall); } } - @Override - public boolean canGetSelectableInformation() { - return true; + private void findAmbiguousProcedure(FBProcedureCall procedureCall) throws SQLException { + // Resolve ambiguity; the query emulates Firebird's resolution rules by first trying to resolve based on + // schema, and then on packages on the search path + String schemaOrPackage = requireNonNull(procedureCall.getSchema(), "schema"); + String procedureName = requireNonNull(procedureCall.getName(), "name"); + var parameters = List.of(schemaOrPackage, procedureName, schemaOrPackage, procedureName); + try (ResultSet rs = metadataMediator.performMetaDataQuery( + new MetadataQuery(FIND_AMBIGUOUS_PROCEDURE, parameters))) { + if (rs.next()) { + procedureCall.setSchema(rs.getString("SCHEMA_NAME")); + procedureCall.setPackage(firstNonNull(rs.getString("PACKAGE_NAME"), FBProcedureCall.NO_PACKAGE)); + procedureCall.setAmbiguousScope(false); + procedureCall.setSelectable( + rs.getInt("PROCEDURE_TYPE") == FirebirdDatabaseMetaData.jbProcedureTypeSelectable); + } else { + LOG.log(TRACE, "Could not find procedure for schema or package ''{0}'' and procedure name ''{1}''", + schemaOrPackage, procedureName); + } + } } - @Override - public boolean isSelectable(String procedureName) { - return selectableProcedureNames.contains(procedureName.toUpperCase(Locale.ROOT)); + private void findProcedureOnSearchPath(FBProcedureCall procedureCall) throws SQLException { + // Find the (not-packaged or packaged) procedure on the search path + String pkg = requireNonNull(procedureCall.getPackage(), "package"); + String procedureName = requireNonNull(procedureCall.getName(), "name"); + var parameters = List.of(pkg, procedureName); + try (ResultSet rs = metadataMediator.performMetaDataQuery( + new MetadataQuery(FIND_SCHEMA_PROCEDURE, parameters))) { + if (rs.next()) { + procedureCall.setSchema(rs.getString("SCHEMA_NAME")); + procedureCall.setSelectable( + rs.getInt("PROCEDURE_TYPE") == FirebirdDatabaseMetaData.jbProcedureTypeSelectable); + } else { + LOG.log(TRACE, + "Could not find procedure on search path: package ''{0}'' and procedure name ''{1}''", + pkg, procedureName); + } + } } + } /** - * A non-functional implementation of {@link StoredProcedureMetaData} for databases that don't have this capability. + * Implementation of {@link StoredProcedureMetaData} that can resolve selectability, and is package-aware. + *

+ * This implementation works for Firebird 2.1 — Firebird 5.0, and also works for Firebird 6.0+ if the stored + * procedure itself is already fully qualified. + *

+ * + * @since 7 */ @NullMarked -final class DummyCallableStatementMetaData implements StoredProcedureMetaData { +final class PackageAwareStoredProcedureMetaData implements StoredProcedureMetaData { + + // Thread-safety assumption: access is always under lock on the connection by the caller. + // We assume that selectability of a procedure is stable for the lifetime of the connection; i.e. it won't be + // recreated to change from selectable to executable or vice versa. + private final Map procedureSelectability = new HashMap<>(); + private final GetProcedures getProcedures; + + PackageAwareStoredProcedureMetaData(FBConnection connection) throws SQLException { + FBDatabaseMetaData dbmd = connection.getMetaData().unwrap(FBDatabaseMetaData.class); + DbMetadataMediator mediator = new PackageAwareDbMetadataMediator(dbmd.getDbMetadataMediator()); + getProcedures = GetProcedures.create(mediator); + } @Override - public boolean canGetSelectableInformation() { - return false; + public void updateSelectability(FBProcedureCall procedureCall) throws SQLException { + requireFullyQualified(procedureCall); + try { + ObjectReference procedureReference = procedureCall.deriveObjectReference(); + Boolean selectable = procedureSelectability.computeIfAbsent(procedureReference, + ignored -> isSelectable(procedureCall)); + if (selectable == null) { + // Procedure does not exist. Remove from map, so if it's created later, we can detect selectability. + procedureSelectability.remove(procedureReference); + } else { + // Store or overwrite object reference to make it permanent + procedureCall.setObjectReference(procedureReference); + procedureCall.setSelectable(selectable); + } + } catch (UncheckedSQLException e) { + throw e.getCause(); + } + } + + private static void requireFullyQualified(FBProcedureCall procedureCall) throws SQLException { + if (procedureCall.isAmbiguousScope() || procedureCall.getSchema() == null || procedureCall.getPackage() == null + || procedureCall.getName() == null) { + throw new SQLNonTransientException("The procedure was not full qualified; this likely indicates a bug in " + + "Jaybird. As a workaround, fully qualify the procedure, and/or use the scope specifier (%SCHEMA " + + "or %PACKAGE)."); + } + } + + /** + * Determines selectability of {@code procedureCall}. + * + * @param procedureCall + * procedure call + * @return {@code true} if selectable, {@code false} if executable or selectability is unknown, {@code null} if the + * procedure was not found + * @throws UncheckedSQLException + * with errors for querying database metadata + */ + private @Nullable Boolean isSelectable(FBProcedureCall procedureCall) { + try (ResultSet procedures = getProcedures.getProcedures( + /* not escaped: not a pattern */ + procedureCall.getPackage(), + MetadataPattern.escapeWildcards(procedureCall.getSchema()), + MetadataPattern.escapeWildcards(procedureCall.getName()))) { + if (!procedures.next()) return null; + return procedures.getInt("JB_PROCEDURE_TYPE") == FirebirdDatabaseMetaData.jbProcedureTypeSelectable; + } catch (SQLException e) { + throw new UncheckedSQLException(e); + } } + /** + * Implementation of {@link DbMetadataMediator} that ensures {@link DbMetadataMediator#isUseCatalogAsPackage()} is + * reported as {@code true}. + *

+ * This implementation does not support calls to {@link DbMetadataMediator#getMetaData()}. All other methods are + * delegated to a wrapped {@link DbMetadataMediator} instance. + *

+ */ + private static final class PackageAwareDbMetadataMediator extends DbMetadataMediator { + + private final DbMetadataMediator mediator; + + PackageAwareDbMetadataMediator(DbMetadataMediator mediator) { + if (mediator instanceof PackageAwareDbMetadataMediator) { + throw new IllegalArgumentException("provided mediator instance is an instance of this class"); + } + this.mediator = mediator; + } + + @Override + public FirebirdSupportInfo getFirebirdSupportInfo() { + return mediator.getFirebirdSupportInfo(); + } + + @Override + public ResultSet performMetaDataQuery(MetadataQuery metadataQuery) throws SQLException { + return mediator.performMetaDataQuery(metadataQuery); + } + + /** + * {@inheritDoc} + * + * @throws UnsupportedOperationException + * always, as this implementation should not be used in this manner + */ + @Override + public FBDatabaseMetaData getMetaData() { + // Instead of returning a (possibly) not-package-aware instance, or having to subclass FBDatabaseMetaData, + // we disallow calling this method. + throw new UnsupportedOperationException("getMetaData() should not be called on this mediator instance"); + } + + @Override + public GDSType getGDSType() { + return mediator.getGDSType(); + } + + /** + * {@inheritDoc} + * + * @return always {@code true} + */ + @Override + public boolean isUseCatalogAsPackage() { + return true; + } + + @Override + public Collection getClientInfoPropertyNames() { + return mediator.getClientInfoPropertyNames(); + } + + @Override + public OdsVersion getOdsVersion() { + return mediator.getOdsVersion(); + } + + } + +} + +/** + * Implementation of {@link StoredProcedureMetaData} for Firebird ODS versions that don't have selectability information + * (i.e. ODS 11.0 or older). + * + * @since 7 + */ +@NullMarked +final class DummyStoredProcedureMetaData implements StoredProcedureMetaData { + @Override - public boolean isSelectable(String procedureName) throws SQLException { - throw new SQLNonTransientException("A DummyCallableStatementMetaData can't retrieve selectable settings", - SQL_STATE_GENERAL_ERROR); + public void updateSelectability(FBProcedureCall procedureCall) { + // No selectability information, nothing to do } } diff --git a/src/main/org/firebirdsql/jdbc/escape/FBEscapedCallParser.java b/src/main/org/firebirdsql/jdbc/escape/FBEscapedCallParser.java index 99978a29c..fb08b2a27 100644 --- a/src/main/org/firebirdsql/jdbc/escape/FBEscapedCallParser.java +++ b/src/main/org/firebirdsql/jdbc/escape/FBEscapedCallParser.java @@ -1,17 +1,26 @@ /* SPDX-FileCopyrightText: Copyright 2003-2005 Roman Rokytskyy SPDX-FileCopyrightText: Copyright 2005 Gabriel Reid - SPDX-FileCopyrightText: Copyright 2012-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc.escape; +import org.firebirdsql.jaybird.parser.FirebirdReservedWords; +import org.firebirdsql.jaybird.parser.ObjectReferenceExtractor; +import org.firebirdsql.jaybird.parser.ReservedWords; +import org.firebirdsql.jaybird.parser.SqlParser; +import org.firebirdsql.gds.AbstractVersion; +import org.firebirdsql.jaybird.util.BasicVersion; +import org.firebirdsql.jaybird.util.Identifier; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.jdbc.FBProcedureCall; import org.firebirdsql.jdbc.FBProcedureParam; import org.firebirdsql.util.InternalApi; import java.sql.SQLException; +import static org.firebirdsql.jdbc.SQLStateConstants.SQL_STATE_SYNTAX_ERROR; import static org.firebirdsql.jdbc.escape.FBEscapedCallParser.ParserState.*; /** @@ -25,6 +34,9 @@ public final class FBEscapedCallParser { private static final int INITIAL_CAPACITY = 32; + private final FBEscapedParser escapedParser; + private final SyntaxVersion syntaxVersion; + private ParserState state = NORMAL_STATE; private boolean isNameProcessed; @@ -36,6 +48,18 @@ public final class FBEscapedCallParser { private FBProcedureCall procedureCall = new FBProcedureCall(); + /** + * Creates an escaped call parser using {@code escapedParser}. + * + * @param escapedParser + * escaped parser + * @since 7 + */ + public FBEscapedCallParser(FBEscapedParser escapedParser) { + this.escapedParser = escapedParser; + syntaxVersion = SyntaxVersion.of(escapedParser.firebirdVersion()); + } + /** * Test the character to be the state switching character and switches the state if necessary. * @@ -212,7 +236,7 @@ public FBProcedureCall parseCall(String sql) throws SQLException { throw new FBSQLParseException("Procedure name is empty."); } - procedureCall.setName(buffer.toString().trim()); + processName(buffer.toString().trim()); isNameProcessed = true; buffer.setLength(0); } else { @@ -285,7 +309,7 @@ public FBProcedureCall parseCall(String sql) throws SQLException { // if there's something in the buffer, treat it as last param if (null == procedureCall.getName() && !isNameProcessed) { - procedureCall.setName(value); + processName(value); } else { FBProcedureParam callParam = procedureCall.addParam(paramPosition, value); @@ -298,6 +322,10 @@ public FBProcedureCall parseCall(String sql) throws SQLException { return procedureCall; } + private void processName(String nameToken) throws FBSQLParseException { + syntaxVersion.parseName(nameToken, procedureCall); + } + /** * Process token. This method detects procedure call keywords and sets appropriate flags. Also, it detects * procedure name and sets appropriate field in the procedure call object. @@ -306,7 +334,7 @@ public FBProcedureCall parseCall(String sql) throws SQLException { * token to process. * @return {@code true} if token was understood and processed. */ - boolean processToken(String token) { + boolean processToken(String token) throws FBSQLParseException { if ("EXECUTE".equalsIgnoreCase(token) && !isExecuteWordProcessed && !isProcedureWordProcessed && !isNameProcessed) { isExecuteWordProcessed = true; @@ -325,7 +353,7 @@ boolean processToken(String token) { } if ((isCallWordProcessed || (isExecuteWordProcessed && isProcedureWordProcessed)) && !isNameProcessed) { - procedureCall.setName(token); + processName(token); isNameProcessed = true; return true; } @@ -343,7 +371,7 @@ boolean processToken(String token) { * if parameter cannot be correctly parsed */ String processParam(String param) throws SQLException { - return FBEscapedParser.toNativeSql(param); + return escapedParser.toNative(param); } /** @@ -360,4 +388,170 @@ enum ParserState { COMMA_STATE, } + /** + * Stored procedure syntax to process. + *

+ * In contexts where the version is unknown, it is usually best to use the latest. + *

+ * + * @since 7 + */ + public enum SyntaxVersion { + FIREBIRD_1_0(1, 0) { + @Override + void processObjectReference(ObjectReference objectReference, FBProcedureCall procedureCall) { + procedureCall.setSchema(FBProcedureCall.NO_SCHEMA); + procedureCall.setPackage(FBProcedureCall.NO_PACKAGE); + if (objectReference.size() == 1) { + procedureCall.setName(objectReference.last().name()); + } + // else we'll use the default initialization to the original name + } + }, + FIREBIRD_3_0(3, 0) { + @Override + void processObjectReference(ObjectReference objectReference, FBProcedureCall procedureCall) { + procedureCall.setSchema(FBProcedureCall.NO_SCHEMA); + switch (objectReference.size()) { + case 1 -> { + // no package + procedureCall.setPackage(FBProcedureCall.NO_PACKAGE); + procedureCall.setName(objectReference.last().name()); + } + case 2 -> { + procedureCall.setPackage(objectReference.first().name()); + procedureCall.setName(objectReference.last().name()); + } + // Give up and use name as-is (this will likely fail later) + default -> { + // nothing to do (use default initialization) + } + } + } + }, + FIREBIRD_6_0(6, 0) { + @Override + void processObjectReference(ObjectReference objectReference, FBProcedureCall procedureCall) { + switch (objectReference.size()) { + case 1 -> { + // no package + procedureCall.setPackage(FBProcedureCall.NO_PACKAGE); + procedureCall.setName(objectReference.last().name()); + } + case 2 -> { + Identifier first = objectReference.first(); + procedureCall.setName(objectReference.last().name()); + switch (first.scope()) { + case UNKNOWN -> { + // assume schema, no package + procedureCall.setSchema(first.name()); + procedureCall.setPackage(FBProcedureCall.NO_PACKAGE); + // signal that we might be wrong + procedureCall.setAmbiguousScope(true); + } + case SCHEMA -> { + procedureCall.setSchema(first.name()); + procedureCall.setPackage(FBProcedureCall.NO_PACKAGE); + } + case PACKAGE -> procedureCall.setPackage(first.name()); + } + } + case 3 -> { + procedureCall.setSchema(objectReference.first().name()); + procedureCall.setPackage(objectReference.at(1).name()); + procedureCall.setName(objectReference.last().name()); + } + // Give up and use name as-is (this will likely fail later) + default -> { + // nothing to do (use default initialization) + } + } + } + }, + ; + + private final BasicVersion firebirdVersion; + + SyntaxVersion(int major, int minor) { + firebirdVersion = BasicVersion.of(major, minor); + } + + /** + * @return the earliest firebird version corresponding to this syntax version + */ + @SuppressWarnings("unused") + public BasicVersion firebirdVersion() { + return firebirdVersion; + } + + public static SyntaxVersion of(AbstractVersion version) { + return of(version.major()); + } + + public static SyntaxVersion of(int majorVersion) { + if (majorVersion >= 6) { + return FIREBIRD_6_0; + } else if (majorVersion >= 3) { + return FIREBIRD_3_0; + } else { + return FIREBIRD_1_0; + } + } + + public static SyntaxVersion latest() { + return FIREBIRD_6_0; + } + + /** + * Parses name to {@code procedureCall}. + *

+ * Depending on the version, it might consider name to be simply the procedure name (after removing quotes), for + * newer versions, it will parse out the package and/or schema name. + *

+ * + * @param name + * name to parse + * @param procedureCall + * procedure call to populate + */ + public void parseName(String name, FBProcedureCall procedureCall) throws FBSQLParseException { + defaultInit(procedureCall, name); + ObjectReference objectReference = parse(name); + procedureCall.setObjectReference(objectReference); + processObjectReference(objectReference, procedureCall); + } + + private void defaultInit(FBProcedureCall procedureCall, String name) { + // schema unknown + procedureCall.setSchema(null); + // we don't know if it should have a package + procedureCall.setPackage(null); + // fallback to unparsed name + procedureCall.setName(name); + procedureCall.setObjectReference(null); + procedureCall.setAmbiguousScope(false); + } + + abstract void processObjectReference(ObjectReference objectReference, FBProcedureCall procedureCall); + + ReservedWords reservedWords() { + return FirebirdReservedWords.latest(); + } + + ObjectReference parse(String name) throws FBSQLParseException { + try { + var detector = new ObjectReferenceExtractor(); + SqlParser.withReservedWords(reservedWords()) + .withVisitor(detector) + .of(name) + .parse(); + return detector.toObjectReference(); + } catch (IllegalStateException e) { + throw new FBSQLParseException("Unable to extract object reference from '" + name + '\'', + SQL_STATE_SYNTAX_ERROR, e); + } + } + + } + } diff --git a/src/main/org/firebirdsql/jdbc/escape/FBEscapedParser.java b/src/main/org/firebirdsql/jdbc/escape/FBEscapedParser.java index 0c6f0b42e..cf01da82f 100644 --- a/src/main/org/firebirdsql/jdbc/escape/FBEscapedParser.java +++ b/src/main/org/firebirdsql/jdbc/escape/FBEscapedParser.java @@ -2,12 +2,15 @@ SPDX-FileCopyrightText: Copyright 2001-2002 David Jencks SPDX-FileCopyrightText: Copyright 2002-2005 Roman Rokytskyy SPDX-FileCopyrightText: Copyright 2003 Blas Rodriguez Somoza - SPDX-FileCopyrightText: Copyright 2012-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc.escape; +import org.firebirdsql.gds.ng.OdsVersion; +import org.firebirdsql.gds.AbstractVersion; import org.firebirdsql.jdbc.FBProcedureCall; +import org.firebirdsql.jdbc.QuoteStrategy; import org.firebirdsql.util.InternalApi; import java.sql.SQLException; @@ -18,10 +21,14 @@ /** * The class {@code FBEscapedParser} parses the SQL and converts escaped syntax to native form. + *

+ * The parser itself is stateless, the only state this class has is the version and quote strategy to apply. + *

* * @author Roman Rokytskyy * @author Mark Rotteveel */ +@SuppressWarnings("ClassCanBeRecord") @InternalApi public final class FBEscapedParser { @@ -47,20 +54,50 @@ public final class FBEscapedParser { * the escape introducers. */ private static final Pattern CHECK_ESCAPE_PATTERN = Pattern.compile( - "\\{(?:(?:\\?\\s*=\\s*)?call|d|ts?|escape|fn|oj|limit)\\s", - Pattern.CASE_INSENSITIVE); + "\\{(?:(?:\\?\\s*=\\s*)?call|d|ts?|escape|fn|oj|limit)\\s", Pattern.CASE_INSENSITIVE); private static final String LIMIT_OFFSET_CLAUSE = " offset "; - private FBEscapedParser() { - // No instances + private final AbstractVersion firebirdVersion; + private final QuoteStrategy quoteStrategy; + + private FBEscapedParser(AbstractVersion firebirdVersion, QuoteStrategy quoteStrategy) { + this.firebirdVersion = firebirdVersion; + this.quoteStrategy = quoteStrategy; + } + + /** + * Get an instance of the escape parser for the specified Firebird version and quote strategy. + *

+ * The implementation may substitute the passed {@code firebirdVersion} with + * a {@link org.firebirdsql.jaybird.util.BasicVersion} with the same {@code major.minor}. + *

+ * + * @param firebirdVersion + * firebird version + * @param quoteStrategy + * quote strategy + * @since 7 + */ + public static FBEscapedParser of(AbstractVersion firebirdVersion, QuoteStrategy quoteStrategy) { + assert !(firebirdVersion instanceof OdsVersion) : "Do not pass OdsVersion to FBEscapedParser.of(...)"; + return new FBEscapedParser(firebirdVersion, quoteStrategy); + } + + AbstractVersion firebirdVersion() { + return firebirdVersion; + } + + @SuppressWarnings("unused") + QuoteStrategy quoteStrategy() { + return quoteStrategy; } /** * Check if the target SQL contains at least one of the escaped syntax commands. This method performs a simple regex * match, so it may report that SQL contains escaped syntax when the "{" is followed by * the escaped syntax command in regular string constants that are passed as parameters. In this case - * {@link #parse(String)} will perform complete SQL parsing. + * {@link #toNative(String)} will perform complete SQL parsing. * * @param sql * to test @@ -75,25 +112,16 @@ private static boolean checkForEscapes(String sql) { * * @param sql * to parse - * @return native form of the {@code sql}. - */ - public static String toNativeSql(String sql) throws SQLException { - return parse(sql); - } - - /** - * Converts escaped parts in {@code sql} to native representation. - * - * @param sql - * to parse - * @return native form of the {@code sql}. + * @return native form of {@code sql} + * @since 7 */ @SuppressWarnings("java:S127") - public static String parse(final String sql) throws SQLException { + public String toNative(String sql) throws SQLException { if (!checkForEscapes(sql)) return sql; ParserState state = ParserState.INITIAL_STATE; - // Note initialising to 8 as that is the minimum size in Oracle Java, and we (usually) need less than the default of 16 + // Note initialising to 8 as that was the minimum size in Oracle Java at some point, and we (usually) need less + // than the default of 16 final var bufferStack = new ArrayDeque(8); final int sqlLength = sql.length(); var buffer = new StringBuilder(sqlLength); @@ -192,7 +220,7 @@ private static void processEscaped(final String escaped, final StringBuilder key * @param escaped * the part of escaped SQL between the '{' and '}'. */ - private static void escapeToNative(final StringBuilder target, final String escaped) throws SQLException { + private void escapeToNative(final StringBuilder target, final String escaped) throws SQLException { final StringBuilder keyword = new StringBuilder(); final StringBuilder payload = new StringBuilder(Math.max(16, escaped.length())); @@ -268,11 +296,11 @@ private static void toTimestampString(final StringBuilder target, final CharSequ * part of {call proc_name(...)} without curly braces and "call" * word. */ - private static void convertProcedureCall(final StringBuilder target, final String procedureCall) throws SQLException { - FBEscapedCallParser tempParser = new FBEscapedCallParser(); + private void convertProcedureCall(final StringBuilder target, final String procedureCall) throws SQLException { + var tempParser = new FBEscapedCallParser(this); FBProcedureCall call = tempParser.parseCall(procedureCall); call.checkParameters(); - target.append(call.getSQL(false)); + target.append(call.getSQL(quoteStrategy)); } /** @@ -315,6 +343,7 @@ private static void convertEscapeString(final StringBuilder target, final CharSe */ private static void convertLimitString(final StringBuilder target, final CharSequence limitClause) throws FBSQLParseException { + // TODO Now the parser has version information, consider transforming to OFFSET ... FETCH ... syntax if supported final String limitEscape = limitClause.toString().toLowerCase(Locale.ROOT); final int offsetStart = limitEscape.indexOf(LIMIT_OFFSET_CLAUSE); if (offsetStart == -1) { diff --git a/src/main/org/firebirdsql/jdbc/escape/FBSQLParseException.java b/src/main/org/firebirdsql/jdbc/escape/FBSQLParseException.java index 3543f7300..f8d1d948f 100644 --- a/src/main/org/firebirdsql/jdbc/escape/FBSQLParseException.java +++ b/src/main/org/firebirdsql/jdbc/escape/FBSQLParseException.java @@ -1,7 +1,7 @@ /* SPDX-FileCopyrightText: Copyright 2001-2002 David Jencks SPDX-FileCopyrightText: Copyright 2002-2003 Roman Rokytskyy - SPDX-FileCopyrightText: Copyright 2012-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc.escape; @@ -12,8 +12,8 @@ import java.sql.SQLSyntaxErrorException; /** - * This exception is thrown by FBEscapedParser when it cannot parse the - * escaped syntax. + * This exception is thrown by {@link FBEscapedParser} and {@link FBEscapedCallParser} when they cannot parse + * the escaped syntax. * * @author Roman Rokytskyy */ @@ -25,4 +25,13 @@ public class FBSQLParseException extends SQLSyntaxErrorException { public FBSQLParseException(String msg) { super(msg, SQLStateConstants.SQL_STATE_INVALID_ESCAPE_SEQ); } + + public FBSQLParseException(String msg, String SQLState) { + super(msg, SQLState); + } + + public FBSQLParseException(String msg, String SQLState, Throwable cause) { + super(msg, SQLState, cause); + } + } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java index 65a089d1a..ddd5504f9 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java @@ -36,7 +36,7 @@ public abstract sealed class GetProcedures extends AbstractMetadataMethod { private static final String COLUMN_SCHEMA_NAME = "RDB$SCHEMA_NAME"; private static final String COLUMN_PACKAGE_NAME = "RDB$PACKAGE_NAME"; - private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(9) + private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(10) .at(0).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "PROCEDURE_CAT", PROCEDURES).addField() .at(1).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "PROCEDURE_SCHEM", PROCEDURES).addField() .at(2).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "PROCEDURE_NAME", PROCEDURES).addField() @@ -48,6 +48,7 @@ public abstract sealed class GetProcedures extends AbstractMetadataMethod { .at(7).simple(SQL_SHORT, 0, "PROCEDURE_TYPE", PROCEDURES).addField() // space for quoted package name, ".", quoted procedure name (assuming no double quotes in name) .at(8).simple(SQL_VARYING, 2 * OBJECT_NAME_LENGTH + 5, "SPECIFIC_NAME", PROCEDURES).addField() + .at(9).simple(SQL_SHORT, 0, "JB_PROCEDURE_TYPE", PROCEDURES).addField() .toRowDescriptor(); private GetProcedures(DbMetadataMediator mediator) { @@ -79,6 +80,7 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .at(6).setString(rs.getString("REMARKS")) .at(7).setShort(rs.getShort("PROCEDURE_TYPE") == 0 ? procedureNoResult : procedureReturnsResult) .at(8).setString(toSpecificName(catalog, schema, procedureName)) + .at(9).setShort(rs.getShort("JB_PROCEDURE_TYPE")) .toRowValue(true); } @@ -92,7 +94,7 @@ public static GetProcedures create(DbMetadataMediator mediator) { return FB6CatalogAsPackage.createInstance(mediator); } return FB6.createInstance(mediator); - }else if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { + } else if (firebirdSupportInfo.isVersionEqualOrAbove(3)) { if (mediator.isUseCatalogAsPackage()) { return FB3CatalogAsPackage.createInstance(mediator); } @@ -111,7 +113,8 @@ private static final class FB2_5 extends GetProcedures { cast(null as char(1)) as PROCEDURE_SCHEM, RDB$PROCEDURE_NAME as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, - RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE + RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE from RDB$PROCEDURES"""; private static final String GET_PROCEDURES_ORDER_BY_2_5 = "\norder by RDB$PROCEDURE_NAME"; @@ -142,7 +145,8 @@ private static final class FB3 extends GetProcedures { null as PROCEDURE_SCHEM, trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, - RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE + RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE from RDB$PROCEDURES where RDB$PACKAGE_NAME is null"""; @@ -175,7 +179,8 @@ private static final class FB3CatalogAsPackage extends GetProcedures { null as PROCEDURE_SCHEM, trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, - RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE + RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE from RDB$PROCEDURES"""; private static final String GET_PROCEDURES_ORDER_BY_3_W_PKG = @@ -222,7 +227,8 @@ private static final class FB6 extends GetProcedures { trim(trailing from RDB$SCHEMA_NAME) as PROCEDURE_SCHEM, trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, - RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE + RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE from SYSTEM.RDB$PROCEDURES where RDB$PACKAGE_NAME is null"""; @@ -261,7 +267,8 @@ private static final class FB6CatalogAsPackage extends GetProcedures { trim(trailing from RDB$SCHEMA_NAME) as PROCEDURE_SCHEM, trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, - RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE + RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE from SYSTEM.RDB$PROCEDURES"""; private static final String GET_PROCEDURES_ORDER_BY_6_W_PKG = diff --git a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java index 446428e09..602fd3161 100644 --- a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java +++ b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java @@ -13,6 +13,7 @@ import org.firebirdsql.gds.ng.wire.auth.srp.SrpAuthenticationPluginSpi; import org.firebirdsql.jdbc.FirebirdConnection; import org.firebirdsql.management.PageSizeConstants; +import org.jspecify.annotations.Nullable; import java.sql.SQLException; @@ -804,7 +805,7 @@ public boolean supportsSchemas() { * value to return when schema support is not available * @return {@code forSchema} if schema support is available, otherwise {@code withoutSchema} */ - public T ifSchemaElse(T forSchema, T withoutSchema) { + public T ifSchemaElse(T forSchema, T withoutSchema) { return supportsSchemas() ? forSchema : withoutSchema; } diff --git a/src/test/org/firebirdsql/common/FBTestProperties.java b/src/test/org/firebirdsql/common/FBTestProperties.java index 68a396c47..eb9159571 100644 --- a/src/test/org/firebirdsql/common/FBTestProperties.java +++ b/src/test/org/firebirdsql/common/FBTestProperties.java @@ -16,6 +16,7 @@ import org.firebirdsql.jaybird.props.AttachmentProperties; import org.firebirdsql.jaybird.props.DatabaseConnectionProperties; import org.firebirdsql.jaybird.props.ServiceConnectionProperties; +import org.firebirdsql.jaybird.util.BasicVersion; import org.firebirdsql.jaybird.xca.FBManagedConnectionFactory; import org.firebirdsql.jdbc.FBDriver; import org.firebirdsql.jdbc.FirebirdConnection; @@ -23,6 +24,7 @@ import org.firebirdsql.management.FBServiceManager; import org.firebirdsql.management.ServiceManager; import org.firebirdsql.util.FirebirdSupportInfo; +import org.jspecify.annotations.Nullable; import java.nio.file.Path; import java.sql.DriverManager; @@ -378,7 +380,7 @@ public static void defaultDatabaseTearDown(FBManager fbManager) throws Exception * @return {@code forSchema} if schema support is available, otherwise {@code withoutSchema} * @see FirebirdSupportInfo#ifSchemaElse(Object, Object) */ - public static T ifSchemaElse(T forSchema, T withoutSchema) { + public static T ifSchemaElse(T forSchema, T withoutSchema) { return getDefaultSupportInfo().ifSchemaElse(forSchema, withoutSchema); } @@ -398,6 +400,14 @@ public static String resolveSchema(String schemaName) { return schemaName; } + public static BasicVersion minimumVersionSupported() { + return BasicVersion.of(3); + } + + public static BasicVersion maximumVersionSupported() { + return BasicVersion.of(6); + } + private FBTestProperties() { // No instantiation } diff --git a/src/test/org/firebirdsql/common/FbAssumptions.java b/src/test/org/firebirdsql/common/FbAssumptions.java index 42e03300d..43459fdd9 100644 --- a/src/test/org/firebirdsql/common/FbAssumptions.java +++ b/src/test/org/firebirdsql/common/FbAssumptions.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.common; @@ -34,6 +34,14 @@ public static void assumeServerBatchSupport() { FBTestProperties.GDS_TYPE, isPureJavaType()); } + public static void assumeSchemaSupport() { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test expects schema support"); + } + + public static void assumeNoSchemaSupport() { + assumeFeatureMissing(FirebirdSupportInfo::supportsSchemas, "Test expects no schema support"); + } + /** * Assume {@code featureTest} returns {@code true}, and fail otherwise. * diff --git a/src/test/org/firebirdsql/gds/impl/GDSServerVersionTest.java b/src/test/org/firebirdsql/gds/impl/GDSServerVersionTest.java index 4eb4d34fe..e93ca2b78 100644 --- a/src/test/org/firebirdsql/gds/impl/GDSServerVersionTest.java +++ b/src/test/org/firebirdsql/gds/impl/GDSServerVersionTest.java @@ -2,7 +2,10 @@ // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.gds.impl; +import org.firebirdsql.jaybird.util.BasicVersion; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -219,4 +222,14 @@ void testGetRawVersions() throws Exception { assertEquals(List.of(TEST_VERSION_30), version.getRawVersions(), "rawVersions"); } + @ParameterizedTest + @ValueSource(strings = { "WI-V1.5.2.4731 Firebird 1.5", "WI-V2.1.3.18185 Firebird 2.1", "WI-V2.5.8.1 Firebird 2.5" }) + void testToBasicVersion(String versionString) throws Exception { + var serverVersion = GDSServerVersion.parseRawVersion(versionString); + BasicVersion basicVersion = serverVersion.toBasicVersion(); + + assertEquals(serverVersion.getMajorVersion(), basicVersion.major(), "major"); + assertEquals(serverVersion.getMinorVersion(), basicVersion.minor(), "minor"); + } + } diff --git a/src/test/org/firebirdsql/gds/ng/OdsVersionTest.java b/src/test/org/firebirdsql/gds/ng/OdsVersionTest.java index b68be3b4f..40582ffcf 100644 --- a/src/test/org/firebirdsql/gds/ng/OdsVersionTest.java +++ b/src/test/org/firebirdsql/gds/ng/OdsVersionTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2024-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.gds.ng; @@ -16,6 +16,7 @@ import static org.firebirdsql.common.matchers.ComparableMatcherFactory.compares; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; /** * Tests for {@link OdsVersion}. @@ -33,6 +34,10 @@ class OdsVersionTest { 13, 0 13, 1 13, 2 + # minimum allowed + 0, 0 + # maximum allowed + 65535, 65535 """) void odsVersionReturnedByOf(int major, int minor) { var odsVersion = OdsVersion.of(major, minor); @@ -93,6 +98,20 @@ void compareTo(int op1Major, int op1Minor, String expectedComparison, int op2Maj assertThat(OdsVersion.of(op1Major, op1Minor), compares(expectedComparison, OdsVersion.of(op2Major, op2Minor))); } + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + major, minor + -1, 0 + 0, -1 + -1, -1 + 65536, 0 + 0, 65536 + 65536, 65536 + """) + void of_outOfRangeMajorMinor(int major, int minor) { + assertThrows(IllegalArgumentException.class, () -> OdsVersion.of(major, minor)); + } + private static String keyAsString(int key) { return Long.toString(key & 0x0FFFFFFFFL, 2); } diff --git a/src/test/org/firebirdsql/jaybird/parser/ObjectReferenceExtractorTest.java b/src/test/org/firebirdsql/jaybird/parser/ObjectReferenceExtractorTest.java new file mode 100644 index 000000000..a9856ab2d --- /dev/null +++ b/src/test/org/firebirdsql/jaybird/parser/ObjectReferenceExtractorTest.java @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.parser; + +import org.firebirdsql.jaybird.util.Identifier; +import org.firebirdsql.jaybird.util.ObjectReference; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ObjectReferenceExtractorTest { + + @ParameterizedTest + @MethodSource + void extractObjectReference(String text, boolean verifyScope, ObjectReference expectedObjectReference) { + ObjectReference objectReference = parse(text); + + assertEquals(expectedObjectReference, objectReference); + if (verifyScope) { + for (int idx = 0; idx < expectedObjectReference.size(); idx++) { + assertEquals(expectedObjectReference.at(idx).scope(), objectReference.at(idx).scope(), + "unexpected scope for identifier at index " + idx); + } + } + } + + static Stream extractObjectReference() { + return Stream.of( + testCase("table_name", "TABLE_NAME"), + testCase("/* comment */ \"table_name\" -- comment", "table_name"), + testCase("identifier1.identifier2", "IDENTIFIER1", "IDENTIFIER2"), + testCase("identifier1.identifier2.identifier3", "IDENTIFIER1", "IDENTIFIER2", "IDENTIFIER3"), + testCase("identifier1%schema.identifier2", + new Identifier("IDENTIFIER1", Identifier.Scope.SCHEMA), new Identifier("IDENTIFIER2")), + testCase("\"identifier1\"%package.identifier2", + new Identifier("identifier1", Identifier.Scope.PACKAGE), new Identifier("IDENTIFIER2")), + testCase("identifier1.identifier2 where x = y", "IDENTIFIER1", "IDENTIFIER2"), + testCase("identifier1.identifier2.identifier3 as some_alias", + "IDENTIFIER1", "IDENTIFIER2", "IDENTIFIER3"), + // Not actually valid in Firebird, but interpreted as the end of the identifier chain + testCase("identifier1%5", "IDENTIFIER1") + ); + } + + @ParameterizedTest + @ValueSource(strings = { + "INCOMPLETE.", + "INCOMPLETE. /* comment */", + "INCOMPLETE%", + "INCOMPLETE% /* comment */", + ".PERIOD_START", + "%SCOPE_SPECIFIER_START", + "SECOND.not an identifier" + }) + void expectedParserFailures(String text) { + assertThrows(IllegalStateException.class, () -> parse(text)); + } + + private static Arguments testCase(String text, String... expectedIdentifierNames) { + return testCase(text, false, ObjectReference.of(expectedIdentifierNames)); + } + + private static Arguments testCase(String text, Identifier... expectedIdentifiers) { + return testCase(text, true, ObjectReference.ofIdentifiers(expectedIdentifiers)); + } + + private static Arguments testCase(String text, boolean verifyScope, ObjectReference expectedObjectReference) { + return Arguments.of(text, verifyScope, expectedObjectReference); + } + + private static ObjectReference parse(String text) { + var detector = new ObjectReferenceExtractor(); + SqlParser.withReservedWords(FirebirdReservedWords.latest()) + .withVisitor(detector) + .of(text) + .parse(); + return detector.toObjectReference(); + } + +} \ No newline at end of file diff --git a/src/test/org/firebirdsql/jaybird/util/BasicVersionTest.java b/src/test/org/firebirdsql/jaybird/util/BasicVersionTest.java new file mode 100644 index 000000000..827dedb4e --- /dev/null +++ b/src/test/org/firebirdsql/jaybird/util/BasicVersionTest.java @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jaybird.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.stream.Stream; + +import static org.firebirdsql.common.matchers.ComparableMatcherFactory.compares; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link BasicVersion} and parts of {@link org.firebirdsql.gds.AbstractVersion}. + * + * @author Mark Rotteveel + */ +class BasicVersionTest { + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + major, minor + 1, 0 + 1, 5 + 2, 1 + 2, 5 + 3, 0 + 4, 0 + 5, 0 + # minimum allowed + 0, 0 + # maximum allowed + 65535, 65535 + """) + void versionReturnedByOf(int major, int minor) { + var version = BasicVersion.of(major, minor); + assertEquals(major, version.major(), "major"); + assertEquals(minor, version.minor(), "minor"); + } + + @Test + void withMajor() { + assertEquals(BasicVersion.of(2, 5), BasicVersion.of(1, 5).withMajor(2)); + } + + @Test + void withMinor() { + assertEquals(BasicVersion.of(2, 1), BasicVersion.of(2, 5).withMinor(1)); + } + + @ParameterizedTest + @MethodSource + void keyStriping(int major, int minor, int expectedKey) throws Throwable { + var lookup = MethodHandles.privateLookupIn(BasicVersion.class, MethodHandles.lookup()); + MethodHandle keyMethod = + lookup.findStatic(BasicVersion.class, "key", MethodType.methodType(int.class, int.class, int.class)); + int key = (int) keyMethod.invoke(major, minor); + assertEquals(expectedKey, key, + "expected %s, received: %s".formatted(keyAsString(expectedKey), keyAsString(key))); + } + + static Stream keyStriping() { + //@formatting:off + return Stream.of( + Arguments.of(0b1000_0000_0000_0001, 0b1100_0000_0000_0011, 0b1000_0000_0001_1000_0000_0000_0110_0001), + Arguments.of(0xFFFF, 0, 0b1111_1111_1110_0000_0000_0000_0001_1111), + Arguments.of(0, 0xFFFF, 0b0000_0000_0001_1111_1111_1111_1110_0000), + Arguments.of(1, 0, 1), + Arguments.of(1, 5, 0b0000_0000_0000_0000_0000_0000_1010_0001), + Arguments.of(2, 0, 2), + Arguments.of(2, 1, 0b0000_0000_0000_0000_0000_0000_0010_0010), + Arguments.of(2, 5, 0b0000_0000_0000_0000_0000_0000_1010_0010), + Arguments.of(3, 0, 3), + Arguments.of(4, 0, 4), + Arguments.of(5, 0, 5), + Arguments.of(31, 3, 0b0000_0000_0000_0000_0000_0000_0111_1111)); + //@formatting:one + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + op1Major, op1Minor, expectedComparison, op2Major, op2Minor + 1, 0, ==, 1, 0 + 1, 0, <, 2, 0 + 2, 0, >, 1, 0 + 2, 0, <, 2, 1 + 2, 1, >, 2, 0 + 2, 5, <, 3, 1 + 3, 1, >, 2, 5 + """) + void compareTo(int op1Major, int op1Minor, String expectedComparison, int op2Major, int op2Minor) { + assertThat(BasicVersion.of(op1Major, op1Minor), + compares(expectedComparison, BasicVersion.of(op2Major, op2Minor))); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + major, minor + -1, 0 + 0, -1 + -1, -1 + 65536, 0 + 0, 65536 + 65536, 65536 + """) + void of_outOfRangeMajorMinor(int major, int minor) { + assertThrows(IllegalArgumentException.class, () -> BasicVersion.of(major, minor)); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + major, minor, checkMajor, expectedResult + 1, 0, 0, true + 0, 0, 1, false + 1, 0, 1, true + 1, 1, 1, true + 2, 0, 1, true + 2, 1, 1, true + 1, 0, 2, false + """) + void isEqualOrAbove_major(int major, int minor, int checkMajor, boolean expectedResult) { + var version = BasicVersion.of(major, minor); + + assertEquals(expectedResult, version.isEqualOrAbove(checkMajor), + "result of (" + version + ").isEqualOrAbove(" + checkMajor + ")"); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + major, minor, checkMajor, checkMinor, expectedResult + 1, 0, 0, 0, true + 0, 0, 1, 0, false + 1, 0, 1, 0, true + 1, 0, 1, 1, false + 1, 1, 1, 0, true + 1, 1, 1, 2, false + 1, 1, 2, 0, false + 2, 0, 1, 1, true + 2, 1, 1, 1, true + 1, 0, 2, 0, false + """) + void isEqualOrAbove_major_minor(int major, int minor, int checkMajor, int checkMinor, boolean expectedResult) { + var version = BasicVersion.of(major, minor); + + assertEquals(expectedResult, version.isEqualOrAbove(checkMajor, checkMinor), + "result of (" + version + ").isEqualOrAbove(" + checkMajor + ',' + checkMinor + ")"); + } + + private static String keyAsString(int key) { + return Long.toString(key & 0x0FFFFFFFFL, 2); + } + +} \ No newline at end of file diff --git a/src/test/org/firebirdsql/jaybird/util/ConditionalHelpersTest.java b/src/test/org/firebirdsql/jaybird/util/ConditionalHelpersTest.java index 2749cb779..6981cc0f2 100644 --- a/src/test/org/firebirdsql/jaybird/util/ConditionalHelpersTest.java +++ b/src/test/org/firebirdsql/jaybird/util/ConditionalHelpersTest.java @@ -1,7 +1,8 @@ -// SPDX-FileCopyrightText: Copyright 2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2023-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.util; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -44,4 +45,31 @@ void firstNonZero_3arg(int firstValue, int secondValue, int thirdValue, int expe assertEquals(expectedResult, ConditionalHelpers.firstNonZero(firstValue, secondValue, thirdValue)); } + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + firstValue, secondValue, expectedResult + a, b, a + a, , a + , b, b + , , + """) + void firstNonNull_2arg(@Nullable String firstValue, @Nullable String secondValue, @Nullable String expectedResult) { + assertEquals(expectedResult, ConditionalHelpers.firstNonNull(firstValue, secondValue)); + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + firstValue, secondValue, thirdValue, expectedResult + a, b, c, a + a, , c, a + , b, c, b + , b, , b + , , c, c + , , , + """) + void firstNonNull_3arg(@Nullable String firstValue, @Nullable String secondValue, @Nullable String thirdValue, + @Nullable String expectedResult) { + assertEquals(expectedResult, ConditionalHelpers.firstNonNull(firstValue, secondValue, thirdValue)); + } + } \ No newline at end of file diff --git a/src/test/org/firebirdsql/jaybird/util/IdentifierChainTest.java b/src/test/org/firebirdsql/jaybird/util/IdentifierChainTest.java index b248f1e8a..55ee08fdc 100644 --- a/src/test/org/firebirdsql/jaybird/util/IdentifierChainTest.java +++ b/src/test/org/firebirdsql/jaybird/util/IdentifierChainTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import java.util.List; @@ -163,6 +164,16 @@ void resolve_chainAndIdentifier() { assertEquals(new IdentifierChain(allIdentifiers), chain.resolve(identifier3), "resolve"); } + @ParameterizedTest + @EnumSource(Identifier.Scope.class) + void toString_includesScopeIdentifierSCHEMAorPACKAGE(Identifier.Scope scope) { + String expected = "\"ONE\"" + (scope != Identifier.Scope.UNKNOWN ? "%" + scope : "") + ".\"TWO\""; + + var chain = new IdentifierChain(List.of(new Identifier("ONE", scope), new Identifier("TWO"))); + + assertEquals(expected, chain.toString()); + } + private static List toIdentifiers(String nameList) { return Stream.of(nameList.split("\\.")).map(Identifier::new).toList(); } diff --git a/src/test/org/firebirdsql/jaybird/util/IdentifierTest.java b/src/test/org/firebirdsql/jaybird/util/IdentifierTest.java index 7e023a3f7..45049a2ee 100644 --- a/src/test/org/firebirdsql/jaybird/util/IdentifierTest.java +++ b/src/test/org/firebirdsql/jaybird/util/IdentifierTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; @@ -147,4 +148,13 @@ void resolve_identifierAndChain() { "resolve"); } + @ParameterizedTest + @EnumSource(Identifier.Scope.class) + void toString_includesScopeIdentifierSCHEMAorPACKAGE(Identifier.Scope scope) { + String expected = "\"EXAMPLE_1\"" + (scope != Identifier.Scope.UNKNOWN ? "%" + scope : ""); + var identifier = new Identifier("EXAMPLE_1", scope); + + assertEquals(expected, identifier.toString()); + } + } \ No newline at end of file diff --git a/src/test/org/firebirdsql/jdbc/ConnectionPropertiesTest.java b/src/test/org/firebirdsql/jdbc/ConnectionPropertiesTest.java index 0df91a59d..d45f563b2 100644 --- a/src/test/org/firebirdsql/jdbc/ConnectionPropertiesTest.java +++ b/src/test/org/firebirdsql/jdbc/ConnectionPropertiesTest.java @@ -20,7 +20,7 @@ import java.util.stream.Stream; import static org.firebirdsql.common.FBTestProperties.*; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -85,7 +85,7 @@ void testProperty_isolation_onDriverManager() throws Exception { @MethodSource("searchPathTestCases") void testProperty_searchPath_onDriverManager(String searchPath, String expectedSearchPath, String expectedSchema, String expectedColumn) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); try (Connection connection = getConnectionViaDriverManager(PropertyNames.searchPath, searchPath)) { verifySearchPath(connection, expectedSearchPath, expectedSchema, expectedColumn); } @@ -95,7 +95,7 @@ void testProperty_searchPath_onDriverManager(String searchPath, String expectedS @MethodSource("searchPathTestCases") void testProperty_searchPath_onataSource(String searchPath, String expectedSearchPath, String expectedSchema, String expectedColumn) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); FBSimpleDataSource ds = createDataSource(); ds.setSearchPath(searchPath); diff --git a/src/test/org/firebirdsql/jdbc/FBCallableStatementSchemaTest.java b/src/test/org/firebirdsql/jdbc/FBCallableStatementSchemaTest.java new file mode 100644 index 000000000..afffdd7ad --- /dev/null +++ b/src/test/org/firebirdsql/jdbc/FBCallableStatementSchemaTest.java @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright 2025 Mark Rotteveel +// SPDX-License-Identifier: LGPL-2.1-or-later +package org.firebirdsql.jdbc; + +import org.firebirdsql.common.extension.UsesDatabaseExtension; +import org.firebirdsql.jaybird.props.PropertyNames; +import org.firebirdsql.jaybird.util.ObjectReference; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.stream.Stream; + +import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; +import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for {@link FBCallableStatement} for its schema support. + *

+ * Some schema-related testing occurs in {@link FBCallableStatementTest}. + *

+ */ +class FBCallableStatementSchemaTest { + + // NOTE Names of schemas and packages overlap, that is intentional + @RegisterExtension + static final UsesDatabaseExtension.UsesDatabaseForAll usesDatabase = UsesDatabaseExtension.usesDatabaseForAll( + dbInitStatements()); + + @BeforeAll + static void requiresSchemaSupport() { + assumeSchemaSupport(); + } + + private static List dbInitStatements() { + return Stream.of( + Stream.of("T1", "T2", "T3").map(FBCallableStatementSchemaTest::createSchema), + Stream.of( + createProcedure("PUBLIC", Proc.selectable("PROC1")), + createProcedure("PUBLIC", Proc.executable("PROC2")), + createProcedure("T1", Proc.executable("PROC1")), + createProcedure("T2", Proc.selectable("PROC1")), + createProcedure("T2", Proc.selectable("PROC2")), + createProcedure("T3", Proc.executable("PROC2"))), + createPackage("PUBLIC", "T2", Proc.executable("PROC2")), + createPackage("T1", "T3", Proc.executable("PROC1"), Proc.selectable("PROC2")), + createPackage("T1", "T2", Proc.selectable("PROC2")), + createPackage("T2", "T1", Proc.selectable("PROC1")), + createPackage("T2", "T2", Proc.executable("PROC1"), Proc.executable("PROC2")), + createPackage("T3", "T2", Proc.executable("PROC1"), Proc.selectable("PROC2")) + ) + .reduce(Stream.of(), Stream::concat) + .toList(); + } + + @SuppressWarnings("SqlSourceToSinkFlow") + @ParameterizedTest + @MethodSource + void testProcedureResolution(String searchPath, String statement, ObjectReference expectedProcedure, + boolean selectable) throws Exception { + try (var connection = createConnection(searchPath); + var cstmt = connection.prepareCall(statement).unwrap(FirebirdCallableStatement.class)) { + assertEquals(selectable, cstmt.isSelectableProcedure(), "selectableProcedure"); + try (var rs = cstmt.executeQuery()) { + assertNextRow(rs); + assertEquals(expectedProcedure.toString(), rs.getString(1)); + } + } + } + + static Stream testProcedureResolution() { + return Stream.of( + testCase("PUBLIC", "{call PROC1}", ObjectReference.of("PUBLIC", "PROC1"), true), + testCase("T1", "{call PUBLIC.PROC1}", ObjectReference.of("PUBLIC", "PROC1"), true), + testCase("PUBLIC", "{call PROC2}", ObjectReference.of("PUBLIC", "PROC2"), false), + testCase("PUBLIC", "{call T1.PROC1}", ObjectReference.of("T1", "PROC1"), false), + testCase("PUBLIC", "{call T2.PROC2}", ObjectReference.of("T2", "PROC2"), true), + testCase("PUBLIC", "{call T2.T2.PROC2}", ObjectReference.of("T2", "T2", "PROC2"), false), + testCase("PUBLIC", "{call T2%PACKAGE.PROC2}", ObjectReference.of("PUBLIC", "T2", "PROC2"), false), + testCase("T2,PUBLIC", "{call T2%PACKAGE.PROC2}", ObjectReference.of("T2", "T2", "PROC2"), false), + testCase("T1,T2,T3", "{call PROC1}", ObjectReference.of("T1", "PROC1"), false), + testCase("T2,T3,T1", "{call PROC1}", ObjectReference.of("T2", "PROC1"), true), + testCase("T3,T1,T2", "{call PROC1}", ObjectReference.of("T1", "PROC1"), false), + testCase("T1,T2,T3", "{call T3.PROC1}", ObjectReference.of("T1", "T3", "PROC1"), false), + testCase("T1,T2,T3", "{call T3.PROC2}", ObjectReference.of("T3", "PROC2"), false), + testCase("T1,T2,T3", "{call T3%SCHEMA.PROC2}", ObjectReference.of("T3", "PROC2"), false), + testCase("T1,T2,T3", "{call T3%PACKAGE.PROC2}", ObjectReference.of("T1", "T3", "PROC2"), true)); + } + + private static Arguments testCase(String searchPath, String statement, ObjectReference expectedProcedure, + boolean selectable) { + return Arguments.of(searchPath, statement, expectedProcedure, selectable); + } + + private static Connection createConnection(String searchPath) throws SQLException { + return getConnectionViaDriverManager(PropertyNames.searchPath, searchPath); + } + + + + private static String createSchema(String schema) { + return "create schema " + ObjectReference.of(schema); + } + + private static String createProcedure(String schema, Proc proc) { + String procedureName = ObjectReference.of(schema, proc.name()).toString(); + return """ + create procedure %s returns (RESULT varchar(50)) + as + begin + RESULT = '%s';%s + end""".formatted(procedureName, procedureName.replace("'", "''"), proc.selectable() ? "\n suspend;" : ""); + } + + private static Stream createPackage(String schema, String pkg, Proc... procs) { + return Stream.of(createPackageHeader(schema, pkg, procs), createPackageBody(schema, pkg, procs)); + } + + private static String createPackageHeader(String schema, String pkg, Proc... procs) { + var sb = new StringBuilder("create package "); + ObjectReference.of(schema, pkg).append(sb, QuoteStrategy.DIALECT_3).append("\nas\nbegin\n"); + for (Proc proc : procs) { + sb.append(" procedure "); + QuoteStrategy.DIALECT_3.appendQuoted(proc.name(), sb).append(" returns (RESULT varchar(50));\n"); + } + sb.append("end"); + return sb.toString(); + } + + private static String createPackageBody(String schema, String pkg, Proc... procs) { + var sb = new StringBuilder("create package body "); + ObjectReference.of(schema, pkg).append(sb, QuoteStrategy.DIALECT_3).append("\nas\nbegin\n"); + for (Proc proc : procs) { + sb.append(" procedure "); + QuoteStrategy.DIALECT_3.appendQuoted(proc.name(), sb) + .append(" returns (RESULT varchar(50))\n as\n begin\n result = '"); + ObjectReference.of(schema, pkg, proc.name()).append(sb, QuoteStrategy.DIALECT_3).append("';\n"); + if (proc.selectable()) { + sb.append(" suspend;\n"); + } + sb.append(" end\n"); + } + sb.append("end"); + return sb.toString(); + } + + record Proc(String name, boolean selectable) { + + static Proc selectable(String name) { + return new Proc(name, true); + } + + static Proc executable(String name) { + return new Proc(name, false); + } + + } + +} diff --git a/src/test/org/firebirdsql/jdbc/FBCallableStatementTest.java b/src/test/org/firebirdsql/jdbc/FBCallableStatementTest.java index 7f97adb9d..6df0af069 100644 --- a/src/test/org/firebirdsql/jdbc/FBCallableStatementTest.java +++ b/src/test/org/firebirdsql/jdbc/FBCallableStatementTest.java @@ -5,7 +5,7 @@ SPDX-FileCopyrightText: Copyright 2003 Ryan Baldwin SPDX-FileCopyrightText: Copyright 2005-2007 Gabriel Reid SPDX-FileCopyrightText: Copyright 2005 Steven Jardine - SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc; @@ -13,12 +13,14 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.jaybird.props.PropertyConstants; import org.firebirdsql.jaybird.props.PropertyNames; +import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import java.io.ByteArrayInputStream; import java.io.StringReader; @@ -30,6 +32,7 @@ import static org.firebirdsql.common.DdlHelper.executeCreateTable; import static org.firebirdsql.common.DdlHelper.executeDDL; import static org.firebirdsql.common.FBTestProperties.*; +import static org.firebirdsql.common.FbAssumptions.assumeFeature; import static org.firebirdsql.common.assertions.SQLExceptionAssertions.assertThrowsFbStatementClosed; import static org.firebirdsql.common.assertions.SQLExceptionAssertions.assertThrowsFbStatementOnlyMethod; import static org.firebirdsql.common.matchers.GdsTypeMatchers.isOtherNativeType; @@ -1223,6 +1226,55 @@ void setObject_InputStream_scaleOrLength() throws Exception { } } + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void determineSelectability_normalProc(boolean selectable) throws Exception { + executeDDL(con, """ + create procedure test_selectability(in_param integer) returns (out_param integer) + as + begin + out_param = in_param; + %s + end + """.formatted(selectable ? "suspend;" : "")); + + try (var cstmt = con.prepareCall("{call test_selectability(?)}").unwrap(FirebirdCallableStatement.class)) { + assertEquals(selectable, cstmt.isSelectableProcedure(), "selectable"); + } + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void determineSelectability_packagedProc(boolean selectable) throws Exception { + assumeFeature(FirebirdSupportInfo::supportsPackages, "Test requires package support"); + con.setAutoCommit(false); + executeDDL(con, """ + create package test_pkg + as + begin + procedure test_selectability(in_param integer) returns (out_param integer); + end + """); + executeDDL(con, """ + create package body test_pkg + as + begin + procedure test_selectability(in_param integer) returns (out_param integer) + as + begin + out_param = in_param; + %s + end + end + """.formatted(selectable ? "suspend;" : "")); + con.setAutoCommit(true); + + try (var cstmt = con.prepareCall("{call test_pkg.test_selectability(?)}") + .unwrap(FirebirdCallableStatement.class)) { + assertEquals(selectable, cstmt.isSelectableProcedure(), "selectable"); + } + } + static Stream scrollableCursorPropertyValues() { // We are unconditionally emitting SERVER, to check if the value behaves appropriately on versions that do // not support server-side scrollable cursors diff --git a/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java b/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java index 2354f7181..36bf8b6c0 100644 --- a/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java +++ b/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java @@ -6,7 +6,6 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension.UsesDatabaseForAll; import org.firebirdsql.jaybird.props.PropertyNames; import org.firebirdsql.jaybird.util.SearchPathHelper; -import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; @@ -22,8 +21,8 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; -import static org.firebirdsql.common.FbAssumptions.assumeFeatureMissing; +import static org.firebirdsql.common.FbAssumptions.assumeNoSchemaSupport; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; @@ -213,11 +212,4 @@ private static void checkSchemaResolution(Connection connection, String expected } } - private static void assumeSchemaSupport() { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test expects schema support"); - } - - private static void assumeNoSchemaSupport() { - assumeFeatureMissing(FirebirdSupportInfo::supportsSchemas, "Test expects no schema support"); - } } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataBestRowIdentifierTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataBestRowIdentifierTest.java index f55ce737c..4799fc5b6 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataBestRowIdentifierTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataBestRowIdentifierTest.java @@ -3,7 +3,6 @@ package org.firebirdsql.jdbc; import org.firebirdsql.common.extension.UsesDatabaseExtension; -import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -23,7 +22,7 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNoNextRow; @@ -119,7 +118,7 @@ void testGetBestRowIdentifier() throws Exception { @Test void testGetBestRowIdentifier_otherSchema() throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); for (int scope : new int[] { DatabaseMetaData.bestRowTemporary, DatabaseMetaData.bestRowTransaction, DatabaseMetaData.bestRowSession }) { try (ResultSet rs = dbmd.getBestRowIdentifier("", "OTHER_SCHEMA", "BEST_ROW_PK", scope, true)) { @@ -130,7 +129,7 @@ void testGetBestRowIdentifier_otherSchema() throws Exception { @Test void testGetBestRowIdentifier_allSchemas() throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); /* JDBC specifies that "null means that the schema name should not be used to narrow the search". This seems like useless behaviour to me for this method (you don't know to which table the columns are actually referring), but let's verify it (our implementation returns the columns of all tables with the same name, ordered by schema diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java index b5039e1e0..752d3312e 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java @@ -4,7 +4,6 @@ import org.firebirdsql.common.FBTestProperties; import org.firebirdsql.common.extension.UsesDatabaseExtension; -import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -27,7 +26,7 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -221,7 +220,7 @@ void testColumnPrivileges_tbl2_all(boolean schemaNull) throws Exception { true, """) void testColumnPrivileges_other_schema_tbl2_all(boolean schemaNull, String columnNameAllPattern) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); var rules = Arrays.asList( createRule("OTHER_SCHEMA", "TBL3", "COL1", SYSDBA, true, "DELETE"), createRule("OTHER_SCHEMA", "TBL3", "COL1", SYSDBA, true, "INSERT"), diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java index 9117372f0..a0bdd605b 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java @@ -18,7 +18,7 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.jaybird.util.StringUtils.trimToNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -766,7 +766,7 @@ void testInt128Column() throws Exception { @Test void testOtherSchemaTable() throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); List> validationRules = new ArrayList<>(); Map idRules = getDefaultValueValidationRules(); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFindTableSchemaTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFindTableSchemaTest.java index f89eff1aa..9a5804616 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFindTableSchemaTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFindTableSchemaTest.java @@ -3,7 +3,6 @@ package org.firebirdsql.jdbc; import org.firebirdsql.common.extension.UsesDatabaseExtension; -import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -22,8 +21,8 @@ import static com.spotify.hamcrest.optional.OptionalMatchers.optionalWithValue; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; -import static org.firebirdsql.common.FbAssumptions.assumeFeatureMissing; +import static org.firebirdsql.common.FbAssumptions.assumeNoSchemaSupport; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; @@ -86,7 +85,7 @@ static void tearDownAll() throws Exception { @ParameterizedTest @ValueSource(strings = { "TABLE_ONE", "table_two", "RDB$RELATIONS", "DOES_NOT_EXIST" }) void findSchema_noTableSchemaSupport(String tableName) throws Exception { - assumeFeatureMissing(FirebirdSupportInfo::supportsSchemas, "Test requires no schema support"); + assumeNoSchemaSupport(); Optional schemaOpt = dbmd.findTableSchema(tableName); assertThat("expected schema empty string (no schema support)", schemaOpt, is(optionalWithValue(""))); @@ -113,7 +112,7 @@ void findSchema_noTableSchemaSupport(String tableName) throws Exception { DOES_NOT_EXIST, 'OTHER_SCHEMA,PUBLIC', #NOT_FOUND# """) void findSchema_Table_schemaSupport(String tableName, String searchPath, String expectedSchema) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); if (searchPath != null) { try (var stmt = connection.createStatement()) { stmt.execute("set search_path to " + searchPath); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java index ad54514f5..48e0c6d19 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java @@ -25,7 +25,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.jdbc.FBDatabaseMetaDataFunctionsTest.isIgnoredFunction; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.*; @@ -263,7 +263,7 @@ void testFunctionColumnMetaData_defaultSchema_functionNamePatternAll(String func void testFunctionColumnMetaData_otherSchema_functionNamePatternAll(String functionNamePattern) throws Exception { // For 4.0 and older, we ignore the schemaPattern, so we need to skip this test, as the query would return // functions (the "default schema" functions) instead of no functions - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); validateExpectedFunctionColumns("", "OTHER_SCHEMA", functionNamePattern, "%", getOtherSchemaAllNonPackagedFunctionColumns()); } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java index c4818399f..a5c197d91 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java @@ -30,7 +30,7 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -193,7 +193,7 @@ void testFunctionMetadata_everything_functionNamePattern(String schemaPattern, S """) void testFunctionMetadata_everything_ofOtherSchema(String schemaPattern, String functionNamePattern) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); var expectedFunctions = List.of(getOtherSchemaPsqlExample(), getOtherSchemaPsqlExample2()); validateExpectedFunctions(null, schemaPattern, functionNamePattern, expectedFunctions); } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java index 0b7b79497..c24278b76 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java @@ -30,7 +30,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalTo; @@ -283,7 +283,7 @@ void indexInfoOfdOds13_0DbWithFirebirdSupportingPartialIndex(@TempDir Path tempD @ParameterizedTest @ValueSource(booleans = { true, false }) void testIndexInfo_table3_all(boolean limitToSchema) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); String schema = "OTHER_SCHEMA"; String tableName = "INDEX_TEST_TABLE_3"; var expectedIndexInfo = List.of( diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java index 836b443ec..81399bf63 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java @@ -3,7 +3,6 @@ package org.firebirdsql.jdbc; import org.firebirdsql.common.extension.UsesDatabaseExtension; -import org.firebirdsql.util.FirebirdSupportInfo; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -26,7 +25,7 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNoNextRow; @@ -164,7 +163,7 @@ void namedPkNamedIndex() throws Exception { @ParameterizedTest @ValueSource(booleans = { true, false }) void schemaNamedSingleColumnPk(boolean limitToSchema) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); validateExpectedPrimaryKeys(limitToSchema ? "OTHER_SCHEMA" : null, "SCHEMA_NAMED_SINGLE_COLUMN_PK", List.of(createPrimaryKeysRow("OTHER_SCHEMA", "SCHEMA_NAMED_SINGLE_COLUMN_PK", "ID", 1, "PK_NAMED_7", "PK_NAMED_7"))); } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java index afb3ac5f7..3d8274491 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java @@ -27,7 +27,7 @@ import static org.firebirdsql.common.FBTestProperties.getUrl; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.FBTestProperties.resolveSchema; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.firebirdsql.jdbc.FBDatabaseMetaDataProceduresTest.isIgnoredProcedure; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.*; @@ -419,7 +419,7 @@ private static List> getInPackage_allColumn """) void testProcedureColumns_otherSchemaProcWithReturn_all(String schemaPattern, String procedureNamePattern, String columnNamePattern) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); var expectedColumns = getOtherSchemaProcWithReturn_allColumns(); ResultSet procedureColumns = dbmd diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java index 10c519bbf..0d88008c3 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java @@ -30,7 +30,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.getUrl; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -299,7 +299,7 @@ void testProcedureMetaData_useCatalogAsPackage_nonPackagedOnly() throws Exceptio OTHER\\_SCHEMA, PROC\\_% """) void testProcedureMetaData_otherSchema_all(String schemaPattern, String procedureNamePattern) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); var expectedProcedures = List.of(ProcedureTestData.OTHER_SCHEMA_PROC_NO_RETURN); try (var procedures = dbmd.getProcedures(null, schemaPattern, procedureNamePattern)) { diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java index 679dbdb8e..9f919137f 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java @@ -3,7 +3,6 @@ package org.firebirdsql.jdbc; import org.firebirdsql.common.extension.UsesDatabaseExtension; -import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -19,7 +18,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.FBTestProperties.resolveSchema; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -302,7 +301,7 @@ void testPattern_nullTable() throws Exception { @NullSource @ValueSource(strings = { "%", "OTHER_SCHEMA", "OTHER\\_SCHEMA", "OTHER%" }) void testOtherSchemaNormalTable2_allPseudoColumns(String schemaPattern) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); List> validationRules = createStandardValidationRules("OTHER_SCHEMA", NORMAL_TABLE3_NAME, "NO"); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataSchemasTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataSchemasTest.java index faf3b0cca..976b199c5 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataSchemasTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataSchemasTest.java @@ -3,7 +3,6 @@ package org.firebirdsql.jdbc; import org.firebirdsql.common.extension.UsesDatabaseExtension; -import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -27,8 +26,8 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; -import static org.firebirdsql.common.FbAssumptions.assumeFeatureMissing; +import static org.firebirdsql.common.FbAssumptions.assumeNoSchemaSupport; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNoNextRow; @@ -98,7 +97,7 @@ void testSchemaMetaDataColumns() throws Exception { @Test void getSchemas_noSchemaSupport_noRows() throws Exception { - requireNoSchemaSupport(); + assumeNoSchemaSupport(); ResultSet schemas = dbmd.getSchemas(); assertNoNextRow(schemas); } @@ -107,7 +106,7 @@ void getSchemas_noSchemaSupport_noRows() throws Exception { @NullSource @ValueSource(strings = "%") void getSchemas_string_string_noSchemaSupport_noRows(String schemaPattern) throws Exception { - requireNoSchemaSupport(); + assumeNoSchemaSupport(); validateSchemaMetaDataNoRow(null, schemaPattern); } @@ -119,7 +118,7 @@ void getSchemas_string_string_emptySchemaPattern_noRows() throws Exception { @Test void getSchemas_schemaSupport_defaults() throws Exception { - requireSchemaSupport(); + assumeSchemaSupport(); try (ResultSet schemas = dbmd.getSchemas()) { // calling getSchemas() is equivalent to calling getSchemas(null, null) validateSchemaMetaData(null, schemas, DEFAULT_SCHEMAS); @@ -130,7 +129,7 @@ void getSchemas_schemaSupport_defaults() throws Exception { @NullSource @ValueSource(strings = "%") void getSchemas_string_string_schemaSupport_defaults(String schemaPattern) throws Exception { - requireSchemaSupport(); + assumeSchemaSupport(); validateSchemaMetaData(null, schemaPattern, List.of("PUBLIC", "SYSTEM")); } @@ -146,14 +145,14 @@ void getSchemas_string_string_schemaSupport_defaults(String schemaPattern) throw """) void getSchemas_string_string_schemaSupport_singleSchemaExpected(String schemaPattern, String expectedSchemaName) throws Exception { - requireSchemaSupport(); + assumeSchemaSupport(); validateSchemaMetaData(null, schemaPattern, List.of(expectedSchemaName)); } @ParameterizedTest @NullAndEmptySource void getSchemas_string_string_schemaSupport_catalogNullOrEmpty_defaults(String catalog) throws Exception { - requireSchemaSupport(); + assumeSchemaSupport(); validateSchemaMetaData(catalog, "%", DEFAULT_SCHEMAS); } @@ -165,7 +164,7 @@ void getSchemas_string_string_catalogNonEmpty_noRows() throws Exception { @Test void getSchema_string_string_schemaSupport_returnsUserDefinedSchemas() throws Exception { - requireSchemaSupport(); + assumeSchemaSupport(); try (var stmt = con.createStatement()) { con.setAutoCommit(false); for (String schema : List.of("ABC", "QRS", "TUV")) { @@ -177,14 +176,6 @@ void getSchema_string_string_schemaSupport_returnsUserDefinedSchemas() throws Ex validateSchemaMetaData("", "%", List.of("ABC", "PUBLIC", "QRS", "SYSTEM", "TUV")); } - private static void requireNoSchemaSupport() { - assumeFeatureMissing(FirebirdSupportInfo::supportsSchemas, "Test requires no schema support"); - } - - private static void requireSchemaSupport() { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); - } - /** * Helper method for test methods that retrieve metadata expecting no results. * diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java index ec4762d7e..b153dca40 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java @@ -4,7 +4,6 @@ import org.firebirdsql.common.FBTestProperties; import org.firebirdsql.common.extension.UsesDatabaseExtension; -import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -26,7 +25,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.FBTestProperties.resolveSchema; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -170,7 +169,7 @@ private List> getTbl2_all() { OTHER_SCHEMA, TBL_ """) void testColumnPrivileges_otherSchemaTBL3_all(String schemaPattern, String tableNamePattern) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); List> rules = getTBL3_all(); validateExpectedColumnPrivileges(schemaPattern, tableNamePattern, rules); @@ -232,7 +231,7 @@ void testColumnPrivileges_defaultSchema_all(String schemaPattern, String tableNa OTHER%, """) void testColumnPrivileges_otherSchema_all(String schemaPattern, String tableNamePattern) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); List> rules = getTBL3_all(); validateExpectedColumnPrivileges(schemaPattern, tableNamePattern, rules); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java index fa2d0766b..8a3c1d9e4 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java @@ -24,7 +24,7 @@ import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.FBTestProperties.resolveSchema; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.hamcrest.CoreMatchers.anyOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -416,7 +416,7 @@ void testTableMetaData_NormalQuotedTable_AllTypes() throws Exception { @NullSource @ValueSource(strings = { "%", "OTHER_SCHEMA", "OTHER\\_SCHEMA", "OTHER%" }) void testTableMetaData_OtherSchemaNormalTable_typesTABLE(String schemaPattern) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); Map validationRules = getDefaultValueValidationRules(); validationRules.put(TableMetaData.TABLE_TYPE, TABLE); validationRules.put(TableMetaData.TABLE_SCHEM, "OTHER_SCHEMA"); diff --git a/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java index f018ba5e1..c85baa55b 100644 --- a/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java @@ -6,7 +6,6 @@ import org.firebirdsql.gds.ISCConstants; import org.firebirdsql.gds.JaybirdErrorCodes; import org.firebirdsql.jaybird.props.PropertyNames; -import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -17,7 +16,7 @@ import java.util.Properties; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.FbAssumptions.assumeServerBatchSupport; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.matchers.SQLExceptionMatchers.*; @@ -357,7 +356,7 @@ void testPrepare_INSERT_columnIndexes_quotedColumn(boolean withSchema) throws Ex """) void testINSERT_schemalessTable_columnIndexes_schemaSearchPath(String searchPath, String expectedSuffix) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); try (var stmt = con.createStatement()) { if (searchPath != null) { stmt.execute("set search_path to " + searchPath); @@ -374,7 +373,7 @@ void testINSERT_schemalessTable_columnIndexes_schemaSearchPath(String searchPath @Test void testINSERT_schemalessTable_columnIndex_tableNotOnSearchPath() throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); try (var stmt = con.createStatement()) { stmt.execute("set search_path to SYSTEM"); } diff --git a/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java index caa29a666..719e389af 100644 --- a/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java @@ -4,7 +4,6 @@ import org.firebirdsql.gds.ISCConstants; import org.firebirdsql.gds.JaybirdErrorCodes; -import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -13,7 +12,7 @@ import java.sql.*; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; -import static org.firebirdsql.common.FbAssumptions.assumeFeature; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.firebirdsql.common.matchers.SQLExceptionMatchers.*; import static org.hamcrest.CoreMatchers.*; @@ -455,7 +454,7 @@ void testExecuteUpdate_INSERT_columnIndexes_quotedColumn(boolean withSchema) thr """) void testINSERT_schemalessTable_columnIndexes_schemaSearchPath(String searchPath, String expectedSuffix) throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); try (var stmt = con.createStatement()) { if (searchPath != null) { stmt.execute("set search_path to " + searchPath); @@ -473,7 +472,7 @@ void testINSERT_schemalessTable_columnIndexes_schemaSearchPath(String searchPath @Test void testINSERT_schemalessTable_columnIndex_tableNotOnSearchPath() throws Exception { - assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + assumeSchemaSupport(); try (var stmt = con.createStatement()) { stmt.execute("set search_path to SYSTEM"); diff --git a/src/test/org/firebirdsql/jdbc/escape/FBEscapedCallParserTest.java b/src/test/org/firebirdsql/jdbc/escape/FBEscapedCallParserTest.java index 96397cbc1..9b3310647 100644 --- a/src/test/org/firebirdsql/jdbc/escape/FBEscapedCallParserTest.java +++ b/src/test/org/firebirdsql/jdbc/escape/FBEscapedCallParserTest.java @@ -3,18 +3,26 @@ SPDX-FileCopyrightText: Copyright 2002 David Jencks SPDX-FileCopyrightText: Copyright 2003 Blas Rodriguez Somoza SPDX-FileCopyrightText: Copyright 2005 Gabriel Reid - SPDX-FileCopyrightText: Copyright 2011-2023 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later */ package org.firebirdsql.jdbc.escape; +import org.firebirdsql.common.FBTestProperties; +import org.firebirdsql.jaybird.util.BasicVersion; import org.firebirdsql.jdbc.FBProcedureCall; import org.firebirdsql.jdbc.FBProcedureParam; +import org.firebirdsql.jdbc.QuoteStrategy; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.sql.SQLException; import java.sql.Types; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -42,7 +50,8 @@ class FBEscapedCallParserTest { private static final String CALL_TEST_8 = "EXECUTE PROCEDURE my_proc (UPPER(?), '11-dec-2001')"; private static final String CALL_TEST_9 = " \t EXECUTE\nPROCEDURE my_proc ( UPPER(?), '11-dec-2001') \t"; - private final FBEscapedCallParser parser = new FBEscapedCallParser(); + private final FBEscapedCallParser parser = new FBEscapedCallParser( + FBEscapedParser.of(FBTestProperties.maximumVersionSupported(), QuoteStrategy.DIALECT_3)); private FBProcedureCall testProcedureCall; private final FBProcedureParam param1 = new FBProcedureParam(0, "?"); private final FBProcedureParam param2 = new FBProcedureParam(1, "UPPER(?)"); @@ -52,7 +61,10 @@ class FBEscapedCallParserTest { @BeforeEach public void setUp() throws SQLException { testProcedureCall = new FBProcedureCall(); - testProcedureCall.setName("my_proc"); + // We're testing with the maximum supported version, and this procedure is not schema qualified in the statement + testProcedureCall.setSchema(null); + testProcedureCall.setPackage(FBProcedureCall.NO_PACKAGE); + testProcedureCall.setName("MY_PROC"); testProcedureCall.addOutputParam(param1); testProcedureCall.addInputParam(param2); testProcedureCall.addInputParam(param3); @@ -84,12 +96,14 @@ void testProcessEscapedCall() throws Exception { procedureCall2.getInputParam(2).setValue("test value"); assertThrows(SQLException.class, () -> procedureCall2.registerOutParam(3, Types.CHAR), "Should not allow registering param 3 as output, since it does not exist"); - assertEquals(testProcedureCall, procedureCall2, "Should correctly parse call " + procedureCall2.getSQL(false)); + assertEquals(testProcedureCall, procedureCall2, + "Should correctly parse call: " + procedureCall2.getSQL(QuoteStrategy.DIALECT_3)); FBProcedureCall procedureCall3 = parser.parseCall(CALL_TEST_6); procedureCall3.registerOutParam(1, Types.INTEGER); procedureCall3.getInputParam(2).setValue("test value"); - assertEquals(testProcedureCall, procedureCall3, "Should correctly parse call. " + procedureCall3.getSQL(false)); + assertEquals(testProcedureCall, procedureCall3, + "Should correctly parse call: " + procedureCall3.getSQL(QuoteStrategy.DIALECT_3)); FBProcedureCall procedureCall4 = parser.parseCall(CALL_TEST_7); verifyParseSql(procedureCall4); @@ -115,10 +129,70 @@ void testOutParameterMapping() throws Exception { assertEquals(3, procedureCall.mapOutParamIndexToPosition(3), "Should return unmapped parameter"); } + @ParameterizedTest + @MethodSource + void parseCalls(BasicVersion version, String in, boolean ambiguousScope, String schema, String pkg, String name, + String sql) throws Exception { + var parser = new FBEscapedCallParser(FBEscapedParser.of(version, QuoteStrategy.DIALECT_3)); + FBProcedureCall procedureCall = parser.parseCall(in); + assertEquals(ambiguousScope, procedureCall.isAmbiguousScope(), "ambiguousScope"); + assertEquals(schema, procedureCall.getSchema(), "schema"); + assertEquals(pkg, procedureCall.getPackage(), "package"); + assertEquals(name, procedureCall.getName(), "name"); + assertEquals(sql, procedureCall.getSQL(QuoteStrategy.DIALECT_3), "converted SQL"); + } + + static Stream parseCalls() { + return Stream.of( + parseCallTestCase(2, "{call SOME_PROCEDURE(?)}", false, "", "", "SOME_PROCEDURE", + "EXECUTE PROCEDURE \"SOME_PROCEDURE\"(?)"), + parseCallTestCase(2, "EXECUTE PROCEDURE SOME_PROCEDURE(?)", false, "", "", "SOME_PROCEDURE", + "EXECUTE PROCEDURE \"SOME_PROCEDURE\"(?)"), + parseCallTestCase(3, "{call SOME_PROCEDURE(?)}", false, "", "", "SOME_PROCEDURE", + "EXECUTE PROCEDURE \"SOME_PROCEDURE\"(?)"), + parseCallTestCase(3, "EXECUTE PROCEDURE SOME_PROCEDURE(?)", false, "", "", "SOME_PROCEDURE", + "EXECUTE PROCEDURE \"SOME_PROCEDURE\"(?)"), + parseCallTestCase(3, "{call SOME_PACKAGE.SOME_PROCEDURE(?)}", false, "", "SOME_PACKAGE", + "SOME_PROCEDURE", "EXECUTE PROCEDURE \"SOME_PACKAGE\".\"SOME_PROCEDURE\"(?)"), + parseCallTestCase(6, "{call SOME_PROCEDURE(?)}", false, null, "", "SOME_PROCEDURE", + "EXECUTE PROCEDURE \"SOME_PROCEDURE\"(?)"), + parseCallTestCase(6, "EXECUTE PROCEDURE SOME_PROCEDURE(?)", false, null, "", "SOME_PROCEDURE", + "EXECUTE PROCEDURE \"SOME_PROCEDURE\"(?)"), + parseCallTestCase(6, "{call SOME_PACKAGE.SOME_PROCEDURE(?)}", true, "SOME_PACKAGE", "", + "SOME_PROCEDURE", "EXECUTE PROCEDURE \"SOME_PACKAGE\".\"SOME_PROCEDURE\"(?)"), + parseCallTestCase(6, "{call SOME_PACKAGE%PACKAGE.SOME_PROCEDURE(?)}", false, null, "SOME_PACKAGE", + "SOME_PROCEDURE", "EXECUTE PROCEDURE \"SOME_PACKAGE\"%PACKAGE.\"SOME_PROCEDURE\"(?)"), + parseCallTestCase(6, "{call SOME_SCHEMA.SOME_PROCEDURE(?)}", true, "SOME_SCHEMA", "", + "SOME_PROCEDURE", "EXECUTE PROCEDURE \"SOME_SCHEMA\".\"SOME_PROCEDURE\"(?)"), + parseCallTestCase(6, "{call SOME_SCHEMA%SCHEMA.SOME_PROCEDURE(?)}", false, "SOME_SCHEMA", "", + "SOME_PROCEDURE", "EXECUTE PROCEDURE \"SOME_SCHEMA\"%SCHEMA.\"SOME_PROCEDURE\"(?)"), + parseCallTestCase(6, "{call SOME_SCHEMA.SOME_PACKAGE.SOME_PROCEDURE(?)}", false, "SOME_SCHEMA", + "SOME_PACKAGE", "SOME_PROCEDURE", + "EXECUTE PROCEDURE \"SOME_SCHEMA\".\"SOME_PACKAGE\".\"SOME_PROCEDURE\"(?)"), + parseCallTestCase(6, "EXECUTE PROCEDURE SOME_SCHEMA.SOME_PACKAGE.SOME_PROCEDURE(?)", false, + "SOME_SCHEMA", "SOME_PACKAGE", "SOME_PROCEDURE", + "EXECUTE PROCEDURE \"SOME_SCHEMA\".\"SOME_PACKAGE\".\"SOME_PROCEDURE\"(?)"), + + // NOTE These are cases of invalid syntax for the version, and although consistently parsed, is + // undefined behaviour and will fail one execute + parseCallTestCase(2, "{call SOME_PACKAGE.SOME_PROCEDURE(?)}", false, "", "", + "SOME_PACKAGE.SOME_PROCEDURE", "EXECUTE PROCEDURE \"SOME_PACKAGE\".\"SOME_PROCEDURE\"(?)"), + parseCallTestCase(3, "{call SOME_SCHEMA.SOME_PACKAGE.SOME_PROCEDURE(?)}", false, "", null, + "SOME_SCHEMA.SOME_PACKAGE.SOME_PROCEDURE", + "EXECUTE PROCEDURE \"SOME_SCHEMA\".\"SOME_PACKAGE\".\"SOME_PROCEDURE\"(?)") + ); + } + + private static Arguments parseCallTestCase(int major, String in, boolean ambiguous, @Nullable String schema, + @Nullable String pkg, String name, String sql) { + return Arguments.of(BasicVersion.of(major),in, ambiguous, schema, pkg, name, sql); + } + private void verifyParseSql(FBProcedureCall procedureCall) throws SQLException { - assertEquals(testProcedureCall.getSQL(false), procedureCall.getSQL(false), - String.format("Should correctly parse call.\n[%s] \n[%s]", - procedureCall.getSQL(false), testProcedureCall.getSQL(false))); + String testProcedureCallSQL = testProcedureCall.getSQL(QuoteStrategy.DIALECT_3); + String procedureCallSQL = procedureCall.getSQL(QuoteStrategy.DIALECT_3); + assertEquals(testProcedureCallSQL, procedureCallSQL, + String.format("Should correctly parse call.\n[%s] \n[%s]", procedureCallSQL, testProcedureCallSQL)); } } diff --git a/src/test/org/firebirdsql/jdbc/escape/FBEscapedFunctionHelperTest.java b/src/test/org/firebirdsql/jdbc/escape/FBEscapedFunctionHelperTest.java index 4b330b74c..5ed0f0f56 100644 --- a/src/test/org/firebirdsql/jdbc/escape/FBEscapedFunctionHelperTest.java +++ b/src/test/org/firebirdsql/jdbc/escape/FBEscapedFunctionHelperTest.java @@ -1,8 +1,10 @@ // SPDX-FileCopyrightText: Copyright 2003-2004 Roman Rokytskyy -// SPDX-FileCopyrightText: Copyright 2012-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.escape; +import org.firebirdsql.common.FBTestProperties; +import org.firebirdsql.jdbc.QuoteStrategy; import org.junit.jupiter.api.Test; import java.sql.SQLException; @@ -41,7 +43,8 @@ void testParseName() throws SQLException { @Test void testEscapedFunctionCall() throws SQLException { - String ucaseTest = FBEscapedParser.toNativeSql(UCASE_FUNCTION_CALL); + String ucaseTest = FBEscapedParser.of(FBTestProperties.minimumVersionSupported(), QuoteStrategy.DIALECT_3) + .toNative(UCASE_FUNCTION_CALL); assertEquals(UCASE_FUNCTION_TEST, ucaseTest, "ucase function parsing should be correct"); } diff --git a/src/test/org/firebirdsql/jdbc/escape/FBEscapedParserTest.java b/src/test/org/firebirdsql/jdbc/escape/FBEscapedParserTest.java index 89edac74d..6c8c1a902 100644 --- a/src/test/org/firebirdsql/jdbc/escape/FBEscapedParserTest.java +++ b/src/test/org/firebirdsql/jdbc/escape/FBEscapedParserTest.java @@ -1,12 +1,16 @@ -// SPDX-FileCopyrightText: Copyright 2012-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.escape; +import org.firebirdsql.common.FBTestProperties; +import org.firebirdsql.jdbc.QuoteStrategy; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; +import java.sql.SQLException; + import static org.firebirdsql.jdbc.escape.EscapeFunctionAsserts.assertParseException; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -17,12 +21,18 @@ */ class FBEscapedParserTest { + private final FBEscapedParser parser = + FBEscapedParser.of(FBTestProperties.maximumVersionSupported(), QuoteStrategy.DIALECT_3); + + private String toNative(String input) throws SQLException { + return parser.toNative(input); + } + @Test void testStringWithoutEscapes() throws Exception { final String input = "SELECT * FROM some_table WHERE x = 'xyz'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(input, parseResult, "Expected output identical to input for string without escapes"); + assertEquals(input, toNative(input), "Expected output identical to input for string without escapes"); } @ParameterizedTest @@ -31,8 +41,7 @@ void testEscapeEscape(String escape) throws Exception { final String input = "SELECT * FROM some_table WHERE x LIKE '_x&_yz' {" + escape + " '&'}"; final String expectedOutput = "SELECT * FROM some_table WHERE x LIKE '_x&_yz' ESCAPE '&'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {escape ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {escape ..}"); } @ParameterizedTest @@ -41,8 +50,7 @@ void testFunctionEscape(String fn) throws Exception { final String input = "SELECT * FROM some_table WHERE {" + fn + " abs(x)} = ?"; final String expectedOutput = "SELECT * FROM some_table WHERE abs(x) = ?"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {fn ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {fn ..}"); } @ParameterizedTest @@ -51,8 +59,7 @@ void testDateEscape(String d) throws Exception { final String input = "SELECT * FROM some_table WHERE x = {" + d + " '2012-12-28'}"; final String expectedOutput = "SELECT * FROM some_table WHERE x = DATE '2012-12-28'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {d ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {d ..}"); } @ParameterizedTest @@ -61,8 +68,7 @@ void testTimeEscape(String t) throws Exception { final String input = "SELECT * FROM some_table WHERE x = {" + t + " '22:15:28'}"; final String expectedOutput = "SELECT * FROM some_table WHERE x = TIME '22:15:28'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {t ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {t ..}"); } @ParameterizedTest @@ -71,8 +77,7 @@ void testTimestampEscape(String ts) throws Exception { final String input = "SELECT * FROM some_table WHERE x = {" + ts + " '2012-12-28 22:15:28'}"; final String expectedOutput = "SELECT * FROM some_table WHERE x = TIMESTAMP '2012-12-28 22:15:28'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {ts ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {ts ..}"); } @ParameterizedTest @@ -81,8 +86,7 @@ void testOuterjoinEscape(String oj) throws Exception { final String input = "SELECT * FROM {" + oj + " some_table FULL OUTER JOIN some_other_table ON some_table.x = some_other_table.x}"; final String expectedOutput = "SELECT * FROM some_table FULL OUTER JOIN some_other_table ON some_table.x = some_other_table.x"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {oj ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {oj ..}"); } @ParameterizedTest @@ -91,8 +95,7 @@ void testSimpleLimitEscape(String limit) throws Exception { final String input = "SELECT * FROM some_table {" + limit + " 10}"; final String expectedOutput = "SELECT * FROM some_table ROWS 10"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {limit ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {limit ..}"); } @ParameterizedTest @@ -105,8 +108,7 @@ void testExtendedLimitEscape(String limit, String offset) throws Exception { final String input = "SELECT * FROM some_table {" + limit + " 10 " + offset + " 15}"; final String expectedOutput = "SELECT * FROM some_table ROWS 15 TO 15+10"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {limit ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {limit ..}"); } @Test @@ -114,8 +116,7 @@ void testSimpleLimitEscapeWithParameter() throws Exception { final String input = "SELECT * FROM some_table {limit ?}"; final String expectedOutput = "SELECT * FROM some_table ROWS ?"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {limit ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {limit ..}"); } @Test @@ -123,8 +124,7 @@ void testExtendedLimitEscapeRowsParameter() throws Exception { final String input = "SELECT * FROM some_table {limit ? offset 15}"; final String expectedOutput = "SELECT * FROM some_table ROWS 15 TO 15+?"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {limit ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {limit ..}"); } /** @@ -137,7 +137,7 @@ void testExtendedLimitEscapeRowsParameter() throws Exception { void testExtendedLimitEscapeOffsetParameter() { final String input = "SELECT * FROM some_table {limit 10 offset ?}"; - assertParseException(() -> FBEscapedParser.toNativeSql(input), + assertParseException(() -> toNative(input), "Extended limit escape ({limit offset }) does not support parameters for "); } @@ -151,37 +151,34 @@ void testExtendedLimitEscapeOffsetParameter() { void testExtendedLimitEscapeRowsAndOffsetParameter() { final String input = "SELECT * FROM some_table {limit ? offset ?}"; - assertParseException(() -> FBEscapedParser.toNativeSql(input), + assertParseException(() -> toNative(input), "Extended limit escape ({limit offset }) does not support parameters for "); } @ParameterizedTest @ValueSource(strings = { "call", "CALL" }) void testCallEscape(String call) throws Exception { - final String input = "{" + call + " FUNCTION(?,?)}"; - final String expectedOutput = "EXECUTE PROCEDURE FUNCTION(?,?)"; + final String input = "{" + call + " FUNC(?,?)}"; + final String expectedOutput = "EXECUTE PROCEDURE \"FUNC\"(?,?)"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {call ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {call ..}"); } @ParameterizedTest @ValueSource(strings = { "call", "CALL" }) void testQuestionmarkCallEscape(String call) throws Exception { - final String input = "{?=" + call + " FUNCTION(?,?)}"; - final String expectedOutput = "EXECUTE PROCEDURE FUNCTION(?,?,?)"; + final String input = "{?=" + call + " FUNC(?,?)}"; + final String expectedOutput = "EXECUTE PROCEDURE \"FUNC\"(?,?,?)"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {call ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {call ..}"); } @Test void testQuestionmarkCallEscapeExtraWhitespace() throws Exception { - final String input = "{? = call FUNCTION(?,?)}"; - final String expectedOutput = "EXECUTE PROCEDURE FUNCTION(?,?,?)"; + final String input = "{? = call FUNC(?,?)}"; + final String expectedOutput = "EXECUTE PROCEDURE \"FUNC\"(?,?,?)"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {call ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {call ..}"); } @Test @@ -189,7 +186,7 @@ void testUnsupportedKeyword() { // NOTE: need to include an existent keyword, otherwise string isn't parsed at all final String input = "{fn ABS(?)} {doesnotexist xyz}"; - assertParseException(() -> FBEscapedParser.toNativeSql(input), + assertParseException(() -> toNative(input), "Unknown keyword doesnotexist for escaped syntax."); } @@ -197,14 +194,14 @@ void testUnsupportedKeyword() { void testTooManyCurlyBraceOpen() { final String input = "{escape '&'"; - assertParseException(() -> FBEscapedParser.toNativeSql(input), "Unbalanced JDBC escape, too many '{'"); + assertParseException(() -> toNative(input), "Unbalanced JDBC escape, too many '{'"); } @Test void testTooManyCurlyBraceClose() { final String input = "{escape '&'}}"; - assertParseException(() -> FBEscapedParser.toNativeSql(input), "Unbalanced JDBC escape, too many '}'"); + assertParseException(() -> toNative(input), "Unbalanced JDBC escape, too many '}'"); } @Test @@ -212,7 +209,7 @@ void testCurlyBraceOpenClose() { // NOTE: need to include an existent keyword, otherwise string isn't parsed at all final String input = "{escape '&'} {}"; - assertParseException(() -> FBEscapedParser.toNativeSql(input), + assertParseException(() -> toNative(input), "Unexpected first character inside JDBC escape: }"); } @@ -221,8 +218,7 @@ void testAdditionalWhitespaceBetweenEscapeAndParameter() throws Exception { final String input = "SELECT * FROM some_table WHERE x LIKE '_x&_yz' {escape '&'}"; final String expectedOutput = "SELECT * FROM some_table WHERE x LIKE '_x&_yz' ESCAPE '&'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {escape ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {escape ..}"); } @Test @@ -230,8 +226,7 @@ void testAdditionalWhitespaceAfterParameter() throws Exception { final String input = "SELECT * FROM some_table WHERE x LIKE '_x&_yz' {escape '&' }"; final String expectedOutput = "SELECT * FROM some_table WHERE x LIKE '_x&_yz' ESCAPE '&'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for {escape ..}"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for {escape ..}"); } @Test @@ -239,8 +234,7 @@ void testNestedEscapes() throws Exception { final String input = "{fn LTRIM({fn RTRIM(' abc ')})}"; final String expectedOutput = "TRIM(LEADING FROM TRIM(TRAILING FROM ' abc '))"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for nested escapes"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for nested escapes"); } /** @@ -251,8 +245,7 @@ void testWhitespaceInParameter() throws Exception { final String input = "{fn LTRIM(CAST( ?\tAS VARCHAR(10)))}"; final String expectedOutput = "TRIM(LEADING FROM CAST( ? AS VARCHAR(10)))"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for nested escapes"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for nested escapes"); } /** @@ -263,8 +256,7 @@ void testEscapeInLineComment() throws Exception { final String input = "{fn LTRIM(CAST( ?\tAS VARCHAR(10)))} --{fn LTRIM(CAST( ?\tAS VARCHAR(10)))}\n{fn LTRIM(CAST( ?\tAS VARCHAR(10)))}"; final String expectedOutput = "TRIM(LEADING FROM CAST( ? AS VARCHAR(10))) --{fn LTRIM(CAST( ?\tAS VARCHAR(10)))}\nTRIM(LEADING FROM CAST( ? AS VARCHAR(10)))"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for nested escapes"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for nested escapes"); } /** @@ -275,8 +267,7 @@ void testEscapeInBlockComment() throws Exception { final String input = "{fn LTRIM(CAST( ?\tAS VARCHAR(10)))} /*{fn LTRIM(CAST( ?\tAS VARCHAR(10)))}\n*/{fn LTRIM(CAST( ?\tAS VARCHAR(10)))}"; final String expectedOutput = "TRIM(LEADING FROM CAST( ? AS VARCHAR(10))) /*{fn LTRIM(CAST( ?\tAS VARCHAR(10)))}\n*/TRIM(LEADING FROM CAST( ? AS VARCHAR(10)))"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for nested escapes"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for nested escapes"); } /** @@ -287,8 +278,7 @@ void testLineCommentStartFollowedByEscape() throws Exception { final String input = "6-{fn EXP(2)}"; final String expectedOutput = "6-EXP(2)"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for nested escapes"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for nested escapes"); } /** @@ -299,16 +289,14 @@ void testBlockCommentStartFollowedByEscape() throws Exception { final String input = "6/{fn EXP(2)}"; final String expectedOutput = "6/EXP(2)"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output for nested escapes"); + assertEquals(expectedOutput, toNative(input), "Unexpected output for nested escapes"); } @Test void testQLiteral_basic() throws Exception { final String input = "q'x {fn EXP(2)} x'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(input, parseResult, "Expected identical output for Q-literal with JDBC escape in literal"); + assertEquals(input, toNative(input), "Expected identical output for Q-literal with JDBC escape in literal"); } @Test @@ -316,8 +304,7 @@ void testQLiteral_processesEscapeAfterLiteral() throws Exception { final String input = "Q'x {fn EXP(2)} x'{fn EXP(2)}"; final String expectedOutput = "Q'x {fn EXP(2)} x'EXP(2)"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output"); + assertEquals(expectedOutput, toNative(input), "Unexpected output"); } @Test @@ -325,15 +312,14 @@ void testQButNotLiteral() throws Exception { final String input = "qMx {fn EXP(2)} x'"; final String expectedOutput = "qMx EXP(2) x'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(expectedOutput, parseResult, "Unexpected output"); + assertEquals(expectedOutput, toNative(input), "Unexpected output"); } @Test void testQLiteralStart_AtEndOfString_throwsParseException() { final String input = "{fn EXP(2)} q'"; - assertParseException(() -> FBEscapedParser.toNativeSql(input), + assertParseException(() -> toNative(input), "Unexpected end of string at parser state Q_LITERAL_START"); } @@ -341,7 +327,7 @@ void testQLiteralStart_AtEndOfString_throwsParseException() { void testQLiteral_InLiteralEndOfString_throwsParseException() { final String input = "{fn EXP(2)} q'abc"; - assertParseException(() -> FBEscapedParser.toNativeSql(input), + assertParseException(() -> toNative(input), "Unexpected end of string at parser state Q_LITERAL_START"); } @@ -360,7 +346,6 @@ void testQLiteralSpecials() throws Exception { private void checkQLiteralSpecialsBalancedStartEnd(char start, char end) throws Exception { final String input = "q'" + start + " {fn EXP(2)} " + end + "'"; - String parseResult = FBEscapedParser.toNativeSql(input); - assertEquals(input, parseResult, "Unexpected output"); + assertEquals(input, toNative(input), "Unexpected output"); } } From 82aa54145f3e2275c7ed0418ff2c70312902c44d Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 9 Oct 2025 14:22:52 +0200 Subject: [PATCH 46/64] Fix test failure on Firebird 5 and older --- .../org/firebirdsql/jdbc/FBCallableStatementSchemaTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/test/org/firebirdsql/jdbc/FBCallableStatementSchemaTest.java b/src/test/org/firebirdsql/jdbc/FBCallableStatementSchemaTest.java index afffdd7ad..fdb5fc997 100644 --- a/src/test/org/firebirdsql/jdbc/FBCallableStatementSchemaTest.java +++ b/src/test/org/firebirdsql/jdbc/FBCallableStatementSchemaTest.java @@ -17,6 +17,7 @@ import java.util.stream.Stream; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.assertions.ResultSetAssertions.assertNextRow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -40,6 +41,10 @@ static void requiresSchemaSupport() { } private static List dbInitStatements() { + if (!getDefaultSupportInfo().supportsSchemas()) { + // UsesDatabaseForAll extension is registered before evaluation of requiresSchemaSupport + return List.of(); + } return Stream.of( Stream.of("T1", "T2", "T3").map(FBCallableStatementSchemaTest::createSchema), Stream.of( From a7542c0d12258ff6ea8d91b780ac0b4ed122d651 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sat, 11 Oct 2025 11:16:24 +0200 Subject: [PATCH 47/64] #882 Document decision not to backport schema support to Jaybird 6 --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index b108cfdbf..1029d02b5 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -6,7 +6,7 @@ == Status * Draft -* Proposed for: Jaybird 7, potential backport to Jaybird 6 and/or Jaybird 5 +* Proposed for: Jaybird 7, potential partial backport to Jaybird 5 == Type @@ -64,7 +64,11 @@ When Jaybird 7 is used on Firebird 5.0 or older, it will behave as before (no sc Further details can be found in <>. -Decision on backport to Jaybird 6 and/or Jaybird 5 is pending, and may be the subject of a separate JDP. +Schema support will _not_ be backported to Jaybird 6 as the required changes are simply too large. +It would mean that Jaybird 6 would contain most of Jaybird 7, and upgrading to a -- theoretical -- Jaybird 6.1 would have similar risks and compatibility issues as upgrading to Jaybird 7. + +Decision on a (partial) backport to Jaybird 5 -- as the "`long-term support`" version for Java 8 -- is still pending (e.g. as a Jaybird 5.1.x), and may be the subject of a separate JDP. +We might only do that on demand and/or when someone is willing to sponsor the work. [#consequences] == Consequences From 04523d7d6f53d117645385e40a3d8b0ea84aaaad Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sat, 11 Oct 2025 11:23:35 +0200 Subject: [PATCH 48/64] Misc. copy editing and fixing typos --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index 1029d02b5..b869fd328 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -31,7 +31,7 @@ Important details related to schemas: The session default can be configured with `isc_dpb_search_path` (string DPB item). The current search path can be altered with `SET SEARCH_PATH TO ...`. `ALTER SESSION RESET` reverts to the session default. -* If `SYSTEM` is not on the search path, it is automatically searched last +* If `SYSTEM` is not on the search path, it is automatically appended to the search path to be searched last * The "`current`" schema cannot be set separately; the first valid (i.e. existing) schema listed in the search path is considered the current schema. * `CURRENT_SCHEMA` and `RDB$GET_CONTEXT('SYSTEM', 'CURRENT_SCHEMA')` return the first valid schema from the search path @@ -41,7 +41,7 @@ This is done -- with some exceptions -- at prepare time. * TPB has new item `isc_tpb_lock_table_schema` to specify the schema of a table to be locked (1 byte length + string data) * Gbak has additional options to include/exclude (skip) schema data in backup or restore, similar to existing options to include/exclude tables * Gstat has additional options to specify a schema for operations involving tables -* For validation, `val_sch_incl` and `val_sch_excl` (I don't think we use the equivalent,`val_tab_incl`/`val_tab_excl` in Jaybird, so might not be relevant) +* For validation, `val_sch_incl` and `val_sch_excl` (I don't think we use the related items `val_tab_incl`/`val_tab_excl` in Jaybird, so might not be relevant) * Stored procedure resolution: ** Unqualified stored procedures (``) are searched on the search path ** Qualified stored procedures (`.`) are first located by schema and name, and if not found, searched on the search path by package and name. @@ -83,12 +83,11 @@ On Firebird 5.0 and older, this will be silently ignored. the connection will not store this value * `Connection.setSchema(String)` will query the current search path, and if not previously called, it will prepend the schema name to the search path, otherwise it will _replace_ the previously prepended schema name. The schema name is stored _only_ for this replacement operation (i.e. it will not be returned by `getSchema`!) -+ ** The name must match exactly as is stored in the metadata (it is always case-sensitive!) ** Jaybird will take care of quoting, and will always quote on dialect 3 ** Existence of the schema is **not** checked, so it is possible the current schema does not change with this operation, as `CURRENT_SCHEMA` reports the first _valid_ schema ** JDBC specifies that "`__Calling ``setSchema`` has no effect on previously created or prepared Statement objects.__`"; -Jaybird cannot honour this requirement for plain `Statement`, as schema resolution is on prepare time (which for plain `Statement` is on execute), and not always for `CallableStatement` (as the implementation may delay actual prepare until execution, though we do try to identify the procedure when the callable statement is created and use that to fully-qualify the procedure). +Jaybird cannot honour this requirement for plain `Statement`, as schema resolution is on prepare time (which for plain `Statement` is on execute), and not always for `CallableStatement`, as the implementation may delay actual prepare until execution, though we do try to identify the procedure when the callable statement is created and use that to fully-qualify the procedure. * Request `isc_info_sql_relation_schema` after preparing a query, record it in `FieldDescriptor`, and return it were relevant for JDBC (e.g. `ResultSetMetaData.getSchemaName(int)`) ** For Firebird 5.0 and older, we need to ensure that JDBC methods continue to report the correct value (i.e. `""` for schema-less objects) * A Firebird 6.0 variant of the `DatabaseMetaData` and other internal metadata queries needs to be written to address at least the following things: @@ -99,12 +98,12 @@ Jaybird cannot honour this requirement for plain `Statement`, as schema resoluti ** `getCatalogs`: TODO: Maybe add a custom column with a list of schema names for `useCatalogAsPackage=true`? * `FirebirdConnection` ** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported -** Added method `List getSearchPatList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported +** Added method `List getSearchPathList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported * `FBCallableStatement` ** On creating the instance, the stored procedure is parsed and identified in the database metadata, including selectability, unless `ignoreProcedureType` is `true` -*** Parsing of callable statements is changed to be able to identify schema, package and procedure, including scope specifiers +*** Parsing of callable statements is changed to be able to identify schema, package and procedure name, including scope specifiers ** Jaybird emulates the lookup rules as used by Firebird, and -- if found -- records the identified procedure so subsequent internal prepares refer to the same procedure, even if the search path changes; -this fulfills the JDBC requirements that a `CallableStatement` is not sensitive to current schema changes *if* Jaybird is able to identify the procedure, behaviour is undefined if the procedure was not found. +this fulfills the JDBC requirements that a `CallableStatement` is not sensitive to current schema changes, but only *if* Jaybird is able to identify the procedure, behaviour is undefined if the procedure was not found. ** The API of `StoredProcedureMetaData` (internal API) is changed to not report selectability, but to update the `FBProcedureCall` instance with selectability and other information, like identified schema and/or package. ** For qualified *and* unambiguous procedure reference, the selectability is cached *per connection*, for unqualified or ambiguous procedure reference, the lookup is performed on each `Connection.prepareCall`, to account for search path changes ** Support for packages was missing in the handling of callable statements, and is added, also for older versions From 381b7925890341bdc7204385c1f6df5e764e7660 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 13 Oct 2025 11:46:32 +0200 Subject: [PATCH 49/64] #882 Add JB_PROCEDURE_SOURCE column to getProcedures Remove previously added getProcedureSourceCode(String, String) and deprecate getProcedureSourceCode(String) in favour of getProcedures --- src/docs/asciidoc/release_notes.adoc | 20 +++++++++- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 14 +++---- .../jdbc/FirebirdDatabaseMetaData.java | 39 +++++++++---------- .../jdbc/metadata/GetProcedures.java | 20 +++++++--- .../FBDatabaseMetaDataProceduresTest.java | 35 ++++++++++++++++- 5 files changed, 90 insertions(+), 38 deletions(-) diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index d5f01c2d6..3077b0919 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -532,8 +532,24 @@ Within the limitations and specification of the JDBC API, this method cannot be ** `getColumnPrivileges` and `getTablePrivileges` received an additional column, `JB_GRANTEE_SCHEMA`, which is non-``null`` for grantees that are schema-bound (e.g. procedures). + As this is a non-standard column, we recommend to always retrieve it by name. -** `getProcedureSourceCode`/`getTriggerSourceCode`/`getViewSourceCode` now also have an overload accepting the schema; -the overloads without a `schema` parameter, or `schema` is `null` will return the source code of the first match found. +** `getProcedures` received two additional columns. ++ +As these are non-standard columns, we recommend to always retrieve them by name. ++ +*** `JB_PROCEDURE_TYPE` -- value of column `RDB$PROCEDURE_TYPE` ++ +For the possible values, the following constants were added to `FirebirdDatabaseMetaData`: ++ +**** `jbProcedureTypeUnknown` (`0`) +**** `jbProcedureTypeSelectable` (`1`) +**** `jbProcedureTypeExecutable` (`2`). +*** `JB_PROCEDURE_SOURCE` -- value of column `RDB$PROCEDURE_SOURCE`: the body of the stored procedure; +this column is `null` for procedures in a package. +** `getProcedureSourceCode` is deprecated, the recommended replacement is `DatabaseMetaData.getProcedures(String, String, String)`, column `JB_PROCEDURE_SOURCE`. +** `getTriggerSourceCode`/`getViewSourceCode` now also have an overload accepting the schema; +the overloads without a `schema` parameter, or `schema` is `null` will return the source code of the first match found (schema order is undefined). +This behaviour also applies for -- now deprecated -- `getProcedureSourceCode(String)`. ++ The `schema` parameter is ignored on Firebird 5.0 and older. ** `getSchemas()` returns all defined schemas ** `getSchemas(String catalog, String schemaPattern)` returns all schemas matching the `LIKE` pattern `schemaPattern`, with the following caveats diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index b6d42bc4d..d2d54ef49 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1195,6 +1195,8 @@ public boolean dataDefinitionIgnoredInTransactions() throws SQLException { *
  • {@link #jbProcedureTypeExecutable} ({@code 2}) — executable
  • * * + *
  • JB_PROCEDURE_SOURCE String => source of the body of the stored procedure + * ({@code RDB$PROCEDURES.RDB$PROCEDURE_SOURCE}); {@code null} for procedures in a package
  • * *

    *

    @@ -1209,8 +1211,9 @@ public boolean dataDefinitionIgnoredInTransactions() throws SQLException { *

  • Column {@code PROCEDURE_CAT} for normal procedures is empty string ({@code ""}) instead of {@code null}, * for packaged procedures it is the package name
  • *
  • Column {@code SPECIFIC_NAME} for packaged procedures will report - * {@code .} (normal procedures will report the same as column - * {@code PROCEDURE_NAME}, the unquoted name)
  • + * {@code [.].} (on Firebird 5.0 and older, normal + * procedures will report the same as column {@code PROCEDURE_NAME}, the unquoted name, on Firebird 6.0 and higher, + * {@code .}) * */ @Override @@ -1870,12 +1873,7 @@ public boolean generatedKeyAlwaysReturned() throws SQLException { @Override public String getProcedureSourceCode(String procedureName) throws SQLException { - return getProcedureSourceCode(null, procedureName); - } - - @Override - public String getProcedureSourceCode(String schema, String procedureName) throws SQLException { - return getSourceCode(schema, procedureName, SourceObjectType.PROCEDURE); + return getSourceCode(null, procedureName, SourceObjectType.PROCEDURE); } @Override diff --git a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java index f249571d4..54be0045e 100644 --- a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java @@ -22,29 +22,34 @@ @SuppressWarnings("unused") public interface FirebirdDatabaseMetaData extends DatabaseMetaData { - // TODO Add schema support: remove/deprecate getProcedureSourceCode(String) and remove new alternative with schema, - // and instead add a column to getProcedures(..) - /** * Firebird procedure type is unknown (value of column {@code JB_PROCEDURE_TYPE} of * {@link #getProcedures(String, String, String)}) + * + * @since 7 */ int jbProcedureTypeUnknown = 0; /** * Firebird procedure type is selectable (value of column {@code JB_PROCEDURE_TYPE} of * {@link #getProcedures(String, String, String)}) + * + * @since 7 */ int jbProcedureTypeSelectable = 1; /** * Firebird procedure type is executable (value of column {@code JB_PROCEDURE_TYPE} of * {@link #getProcedures(String, String, String)}) + * + * @since 7 */ int jbProcedureTypeExecutable = 2; /** * Get the source of a stored procedure. *

    - * On Firebird 6.0 and higher, it is recommended to use {@link #getProcedureSourceCode(String, String)} instead. + * WARNING: On Firebird 6.0 and higher, the sources returned are for the first procedure found + * (with an undefined schema order!), use {@link DatabaseMetaData#getProcedures(String, String, String)} instead + * (column {@code JB_PROCEDURE_SOURCE}). *

    * * @param procedureName @@ -52,29 +57,17 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * @return source of the stored procedure * @throws SQLException * if specified procedure cannot be found - * @see #getProcedureSourceCode(String, String) + * @deprecated use {@link DatabaseMetaData#getProcedures(String, String, String)}, column + * {@code JB_PROCEDURE_SOURCE}; there are currently no plans to remove this method */ + @Deprecated(forRemoval = false, since = "7") String getProcedureSourceCode(String procedureName) throws SQLException; - /** - * Get the source of a stored procedure. - * - * @param schema - * schema of the stored procedure ({@code null} drops the schema from the search; ignored on Firebird 5.0 - * and older) - * @param procedureName - * name of the stored procedure - * @return source of the stored procedure - * @throws SQLException - * if specified procedure cannot be found - * @since 7 - */ - String getProcedureSourceCode(String schema, String procedureName) throws SQLException; - /** * Get the source of a trigger. *

    - * On Firebird 6.0 and higher, it is recommended to use {@link #getTriggerSourceCode(String, String)} instead. + * WARNING: On Firebird 6.0 and higher, the sources returned are for the first trigger found + * (with an undefined schema order!), use {@link #getTriggerSourceCode(String, String)} instead. *

    * * @param triggerName @@ -105,6 +98,10 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { *

    * On Firebird 6.0 and higher, it is recommended to use {@link #getViewSourceCode(String, String)} instead. *

    + *

    + * WARNING: On Firebird 6.0 and higher, the sources returned are for the first view found + * (with an undefined schema order!), use {@link #getViewSourceCode(String, String)} instead + *

    * * @param viewName * name of the view diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java index ddd5504f9..1e47ff49f 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java @@ -36,7 +36,7 @@ public abstract sealed class GetProcedures extends AbstractMetadataMethod { private static final String COLUMN_SCHEMA_NAME = "RDB$SCHEMA_NAME"; private static final String COLUMN_PACKAGE_NAME = "RDB$PACKAGE_NAME"; - private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(10) + private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(11) .at(0).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "PROCEDURE_CAT", PROCEDURES).addField() .at(1).simple(SQL_VARYING | 1, OBJECT_NAME_LENGTH, "PROCEDURE_SCHEM", PROCEDURES).addField() .at(2).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "PROCEDURE_NAME", PROCEDURES).addField() @@ -49,6 +49,8 @@ public abstract sealed class GetProcedures extends AbstractMetadataMethod { // space for quoted package name, ".", quoted procedure name (assuming no double quotes in name) .at(8).simple(SQL_VARYING, 2 * OBJECT_NAME_LENGTH + 5, "SPECIFIC_NAME", PROCEDURES).addField() .at(9).simple(SQL_SHORT, 0, "JB_PROCEDURE_TYPE", PROCEDURES).addField() + // Field in Firebird is actually a blob, using Integer.MAX_VALUE for length + .at(10).simple(SQL_VARYING, Integer.MAX_VALUE, "JB_PROCEDURE_SOURCE", PROCEDURES).addField() .toRowDescriptor(); private GetProcedures(DbMetadataMediator mediator) { @@ -81,6 +83,7 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .at(7).setShort(rs.getShort("PROCEDURE_TYPE") == 0 ? procedureNoResult : procedureReturnsResult) .at(8).setString(toSpecificName(catalog, schema, procedureName)) .at(9).setShort(rs.getShort("JB_PROCEDURE_TYPE")) + .at(10).setString(rs.getString("JB_PROCEDURE_SOURCE")) .toRowValue(true); } @@ -114,7 +117,8 @@ private static final class FB2_5 extends GetProcedures { RDB$PROCEDURE_NAME as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, - RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE, + RDB$PROCEDURE_SOURCE as JB_PROCEDURE_SOURCE from RDB$PROCEDURES"""; private static final String GET_PROCEDURES_ORDER_BY_2_5 = "\norder by RDB$PROCEDURE_NAME"; @@ -146,7 +150,8 @@ private static final class FB3 extends GetProcedures { trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, - RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE, + RDB$PROCEDURE_SOURCE as JB_PROCEDURE_SOURCE from RDB$PROCEDURES where RDB$PACKAGE_NAME is null"""; @@ -180,7 +185,8 @@ private static final class FB3CatalogAsPackage extends GetProcedures { trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, - RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE, + RDB$PROCEDURE_SOURCE as JB_PROCEDURE_SOURCE from RDB$PROCEDURES"""; private static final String GET_PROCEDURES_ORDER_BY_3_W_PKG = @@ -228,7 +234,8 @@ private static final class FB6 extends GetProcedures { trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, - RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE, + RDB$PROCEDURE_SOURCE as JB_PROCEDURE_SOURCE from SYSTEM.RDB$PROCEDURES where RDB$PACKAGE_NAME is null"""; @@ -268,7 +275,8 @@ private static final class FB6CatalogAsPackage extends GetProcedures { trim(trailing from RDB$PROCEDURE_NAME) as PROCEDURE_NAME, RDB$DESCRIPTION as REMARKS, RDB$PROCEDURE_OUTPUTS as PROCEDURE_TYPE, - RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE + RDB$PROCEDURE_TYPE as JB_PROCEDURE_TYPE, + RDB$PROCEDURE_SOURCE as JB_PROCEDURE_SOURCE from SYSTEM.RDB$PROCEDURES"""; private static final String GET_PROCEDURES_ORDER_BY_6_W_PKG = diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java index 0d88008c3..b812147eb 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java @@ -388,7 +388,9 @@ private enum ProcedureMetaData implements MetaDataInfo { FUTURE3(6, String.class), REMARKS(7, String.class), PROCEDURE_TYPE(8, Short.class), - SPECIFIC_NAME(9, String.class) + SPECIFIC_NAME(9, String.class), + JB_PROCEDURE_TYPE(10, Short.class), + JB_PROCEDURE_SOURCE(11, String.class), ; private final int position; @@ -410,14 +412,36 @@ public Class getColumnClass() { } } + /** + * Returns the body of the stored procedure (after the first occurrence of {@code "\nAS\n"} or {@code "\nas\n"}). + * + * @param procedureSource + * stored procedure source + * @return body, or {@code null} if there was no occurrence of the expected token (see above) + */ + private static String extractBody(String procedureSource) { + int index = procedureSource.indexOf("\nAS\n"); + int indexAs = procedureSource.indexOf("\nas\n"); + if (index == -1) { + index = indexAs; + } else if (indexAs < index && indexAs != -1) { + index = indexAs; + } + + if (index == -1) return null; + return procedureSource.substring(index + 4); + } + private enum ProcedureTestData { NORMAL_PROC_NO_RETURN("NORMAL_PROC_NO_RETURN", List.of(CREATE_NORMAL_PROC_NO_RETURN)) { @Override Map getSpecificValidationRules(Map rules) { rules.put(ProcedureMetaData.PROCEDURE_NAME, "NORMAL_PROC_NO_RETURN"); rules.put(ProcedureMetaData.PROCEDURE_TYPE, DatabaseMetaData.procedureNoResult); + rules.put(ProcedureMetaData.JB_PROCEDURE_TYPE, FirebirdDatabaseMetaData.jbProcedureTypeExecutable); rules.put(ProcedureMetaData.SPECIFIC_NAME, ifSchemaElse( ObjectReference.of("PUBLIC", "NORMAL_PROC_NO_RETURN").toString(), "NORMAL_PROC_NO_RETURN")); + rules.put(ProcedureMetaData.JB_PROCEDURE_SOURCE, extractBody(CREATE_NORMAL_PROC_NO_RETURN)); return rules; } }, @@ -427,9 +451,11 @@ Map getSpecificValidationRules(Map getSpecificValidationRules(Map rules) { rules.put(ProcedureMetaData.PROCEDURE_NAME, "NORMAL_PROC_WITH_RETURN"); rules.put(ProcedureMetaData.PROCEDURE_TYPE, DatabaseMetaData.procedureReturnsResult); + rules.put(ProcedureMetaData.JB_PROCEDURE_TYPE, FirebirdDatabaseMetaData.jbProcedureTypeExecutable); rules.put(ProcedureMetaData.REMARKS, "Some comment"); rules.put(ProcedureMetaData.SPECIFIC_NAME, ifSchemaElse( ObjectReference.of("PUBLIC", "NORMAL_PROC_WITH_RETURN").toString(), "NORMAL_PROC_WITH_RETURN")); + rules.put(ProcedureMetaData.JB_PROCEDURE_SOURCE, extractBody(CREATE_NORMAL_PROC_WITH_RETURN)); return rules; } @@ -439,8 +465,10 @@ Map getSpecificValidationRules(Map getSpecificValidationRules(Map rules) { rules.put(ProcedureMetaData.PROCEDURE_NAME, "quoted_proc_no_return"); rules.put(ProcedureMetaData.PROCEDURE_TYPE, DatabaseMetaData.procedureNoResult); + rules.put(ProcedureMetaData.JB_PROCEDURE_TYPE, FirebirdDatabaseMetaData.jbProcedureTypeExecutable); rules.put(ProcedureMetaData.SPECIFIC_NAME, ifSchemaElse( ObjectReference.of("PUBLIC", "quoted_proc_no_return").toString(), "quoted_proc_no_return")); + rules.put(ProcedureMetaData.JB_PROCEDURE_SOURCE, extractBody(CREATE_QUOTED_PROC_NO_RETURN)); return rules; } }, @@ -451,8 +479,11 @@ Map getSpecificValidationRules(Map getSpecificValidationRules(Map Date: Mon, 13 Oct 2025 12:02:51 +0200 Subject: [PATCH 50/64] #882 Reject idea to add column with schemas to getCatalogs --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 5 ++++- src/main/org/firebirdsql/jdbc/metadata/GetCatalogs.java | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index b869fd328..5eab9e398 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -95,7 +95,10 @@ Jaybird cannot honour this requirement for plain `Statement`, as schema resoluti ** Returning schema names, and qualified object names where relevant (e.g. in `DatabaseMetaData` result sets) ** Include schema names in joins to ensure matching the right objects ** Allow searching for schema or schema pattern as specified in JDBC, or were needed for internal metadata queries -** `getCatalogs`: TODO: Maybe add a custom column with a list of schema names for `useCatalogAsPackage=true`? +** `getCatalogs`; +it is not possible to identify the schema(s) within the confines of JDBC. ++ +We considered adding a column that lists the schema(s) that contain the package name, but we don't think it will see use in practice. * `FirebirdConnection` ** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported ** Added method `List getSearchPathList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetCatalogs.java b/src/main/org/firebirdsql/jdbc/metadata/GetCatalogs.java index 314f05d84..198306de6 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetCatalogs.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetCatalogs.java @@ -24,8 +24,6 @@ */ public sealed class GetCatalogs extends AbstractMetadataMethod { - // TODO Add schema support: maybe add a custom column with a list of schema names for useCatalogsAsPackage? - private static final RowDescriptor ROW_DESCRIPTOR = DbMetadataMediator.newRowDescriptorBuilder(1) .at(0).simple(SQL_VARYING, OBJECT_NAME_LENGTH, "TABLE_CAT", "TABLECATALOGS").addField() .toRowDescriptor(); From 567cdea01fce6aaeb214d8bd56452de3e3e31cfd Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Fri, 17 Oct 2025 13:13:59 +0200 Subject: [PATCH 51/64] Remove unnecessary caching from BasicVersion - minor cleanup/improvements to other version classes --- .../org/firebirdsql/gds/AbstractVersion.java | 3 +- .../org/firebirdsql/gds/ng/OdsVersion.java | 5 -- .../jaybird/util/BasicVersion.java | 77 +++---------------- .../jdbc/escape/FBEscapedCallParser.java | 7 +- .../jaybird/util/BasicVersionTest.java | 76 ++---------------- 5 files changed, 23 insertions(+), 145 deletions(-) diff --git a/src/main/org/firebirdsql/gds/AbstractVersion.java b/src/main/org/firebirdsql/gds/AbstractVersion.java index b39375614..81d4bc2f8 100644 --- a/src/main/org/firebirdsql/gds/AbstractVersion.java +++ b/src/main/org/firebirdsql/gds/AbstractVersion.java @@ -7,7 +7,6 @@ import java.io.Serial; import java.io.Serializable; -import java.util.Objects; /** * Abstract version for {@code major.minor} version information. @@ -78,7 +77,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(major, minor); + return 31 * major + minor; } @Override diff --git a/src/main/org/firebirdsql/gds/ng/OdsVersion.java b/src/main/org/firebirdsql/gds/ng/OdsVersion.java index cb1d2df76..78d5954fc 100644 --- a/src/main/org/firebirdsql/gds/ng/OdsVersion.java +++ b/src/main/org/firebirdsql/gds/ng/OdsVersion.java @@ -82,11 +82,6 @@ public OdsVersion withMinor(int minor) { return minor() != minor ? of(major(), minor) : this; } - @Override - public int hashCode() { - return key(major(), minor()); - } - @Serial private Object readResolve() { // Return cached variant diff --git a/src/main/org/firebirdsql/jaybird/util/BasicVersion.java b/src/main/org/firebirdsql/jaybird/util/BasicVersion.java index 528cdef59..f85817277 100644 --- a/src/main/org/firebirdsql/jaybird/util/BasicVersion.java +++ b/src/main/org/firebirdsql/jaybird/util/BasicVersion.java @@ -5,14 +5,9 @@ import org.firebirdsql.gds.AbstractVersion; import java.io.Serial; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; /** * Value class representing a version with {@code major.minor} version information. - *

    - * Implementation limit: {@code major} and {@code minor} must be between 0 and 0xFFFF (65535). - *

    * * @since 7 */ @@ -21,68 +16,27 @@ public final class BasicVersion extends AbstractVersion { @Serial private static final long serialVersionUID = -2133746651860946034L; - private static final Map VERSION_CACHE = new ConcurrentHashMap<>(); - private BasicVersion(int major, int minor) { super(major, minor); } /** - * @return a - possibly cached - basic version object with {@code major} and {@code minor} - */ - public static BasicVersion of(int major, int minor) { - if ((major & 0xFFFF) != major || (minor & 0xFFFF) != minor) { - throw new IllegalArgumentException("Implementation limit for major or minor exceeded"); - } - return VERSION_CACHE.computeIfAbsent(key(major, minor), ignored -> new BasicVersion(major, minor)); - } - - @Override - public BasicVersion toBasicVersion() { - return this; - } - - /** - * Returns a - possibly cached - instance with the specified major version and the minor version of this instance. - * - * @param major - * major version - * @return instance with value of parameter {@code major} and {@link #minor()} of this instance + * @return a basic version object with {@code major} and minor {@code 0} */ - public BasicVersion withMajor(int major) { - return major() != major ? of(major, minor()) : this; - } - - /** - * Returns a - possibly cached - instance with the major version of this instance and the specified minor version. - * - * @param minor - * minor version - * @return instance with {@link #major()} of this instance and value of parameter {@code minor} - */ - public BasicVersion withMinor(int minor) { - return minor() != minor ? of(major(), minor) : this; - } - - /** - * @return a - possibly cached - basic version object with major {@code 0} and minor {@code 0} - */ - public static BasicVersion none() { - return of(0, 0); + public static BasicVersion of(int major) { + return of(major, 0); } /** - * @return a - possibly cached - basic version object with {@code major} and minor {@code 0} + * @return a basic version object with {@code major} and {@code minor} */ - public static BasicVersion of(int major) { - return of(major, 0); + public static BasicVersion of(int major, int minor) { + return new BasicVersion(major, minor); } - private static int key(int major, int minor) { - /* In practice, relatively small values occur. Striping them will ensure that in most cases a value with less - than 7 bits set will be produced, which will allow the cache key to be an Integer from the Integer cache (for - major <= 31 and minor <= 3) */ - return (major & 0x1F) | ((minor & 0xFFFF) << 5) | ((major & 0xFFE0) << 16); + @Override + public BasicVersion toBasicVersion() { + return this; } /** @@ -97,7 +51,7 @@ public static BasicVersion of(BasicVersion version) { } /** - * Factory method to derive a - possibly cached - basic version from {@code version}. + * Factory method to derive a basic version from {@code version}. * * @param version * version @@ -108,15 +62,4 @@ public static BasicVersion of(AbstractVersion version) { return version instanceof BasicVersion bv ? bv : of(version.major(), version.minor()); } - @Override - public int hashCode() { - return key(major(), minor()); - } - - @Serial - private Object readResolve() { - // Return cached variant - return of(major(), minor()); - } - } diff --git a/src/main/org/firebirdsql/jdbc/escape/FBEscapedCallParser.java b/src/main/org/firebirdsql/jdbc/escape/FBEscapedCallParser.java index fb08b2a27..93e90276f 100644 --- a/src/main/org/firebirdsql/jdbc/escape/FBEscapedCallParser.java +++ b/src/main/org/firebirdsql/jdbc/escape/FBEscapedCallParser.java @@ -485,10 +485,15 @@ public BasicVersion firebirdVersion() { } public static SyntaxVersion of(AbstractVersion version) { - return of(version.major()); + return of(version.major(), version.minor()); } public static SyntaxVersion of(int majorVersion) { + return of(majorVersion, 0); + } + + public static SyntaxVersion of(int majorVersion, @SuppressWarnings("unused") int minorVersion) { + // Currently no syntax versions where the minor matters if (majorVersion >= 6) { return FIREBIRD_6_0; } else if (majorVersion >= 3) { diff --git a/src/test/org/firebirdsql/jaybird/util/BasicVersionTest.java b/src/test/org/firebirdsql/jaybird/util/BasicVersionTest.java index 827dedb4e..ba576ad19 100644 --- a/src/test/org/firebirdsql/jaybird/util/BasicVersionTest.java +++ b/src/test/org/firebirdsql/jaybird/util/BasicVersionTest.java @@ -2,21 +2,12 @@ // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jaybird.util; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.MethodSource; - -import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodType; -import java.util.stream.Stream; import static org.firebirdsql.common.matchers.ComparableMatcherFactory.compares; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; /** * Tests for {@link BasicVersion} and parts of {@link org.firebirdsql.gds.AbstractVersion}. @@ -35,10 +26,12 @@ class BasicVersionTest { 3, 0 4, 0 5, 0 - # minimum allowed - 0, 0 - # maximum allowed - 65535, 65535 + -1, 0 + 0, -1 + -2147483648, 0 + 0, -2147483648 + 2147483647, 0 + 0, 2147483647 """) void versionReturnedByOf(int major, int minor) { var version = BasicVersion.of(major, minor); @@ -46,45 +39,6 @@ void versionReturnedByOf(int major, int minor) { assertEquals(minor, version.minor(), "minor"); } - @Test - void withMajor() { - assertEquals(BasicVersion.of(2, 5), BasicVersion.of(1, 5).withMajor(2)); - } - - @Test - void withMinor() { - assertEquals(BasicVersion.of(2, 1), BasicVersion.of(2, 5).withMinor(1)); - } - - @ParameterizedTest - @MethodSource - void keyStriping(int major, int minor, int expectedKey) throws Throwable { - var lookup = MethodHandles.privateLookupIn(BasicVersion.class, MethodHandles.lookup()); - MethodHandle keyMethod = - lookup.findStatic(BasicVersion.class, "key", MethodType.methodType(int.class, int.class, int.class)); - int key = (int) keyMethod.invoke(major, minor); - assertEquals(expectedKey, key, - "expected %s, received: %s".formatted(keyAsString(expectedKey), keyAsString(key))); - } - - static Stream keyStriping() { - //@formatting:off - return Stream.of( - Arguments.of(0b1000_0000_0000_0001, 0b1100_0000_0000_0011, 0b1000_0000_0001_1000_0000_0000_0110_0001), - Arguments.of(0xFFFF, 0, 0b1111_1111_1110_0000_0000_0000_0001_1111), - Arguments.of(0, 0xFFFF, 0b0000_0000_0001_1111_1111_1111_1110_0000), - Arguments.of(1, 0, 1), - Arguments.of(1, 5, 0b0000_0000_0000_0000_0000_0000_1010_0001), - Arguments.of(2, 0, 2), - Arguments.of(2, 1, 0b0000_0000_0000_0000_0000_0000_0010_0010), - Arguments.of(2, 5, 0b0000_0000_0000_0000_0000_0000_1010_0010), - Arguments.of(3, 0, 3), - Arguments.of(4, 0, 4), - Arguments.of(5, 0, 5), - Arguments.of(31, 3, 0b0000_0000_0000_0000_0000_0000_0111_1111)); - //@formatting:one - } - @ParameterizedTest @CsvSource(useHeadersInDisplayName = true, textBlock = """ op1Major, op1Minor, expectedComparison, op2Major, op2Minor @@ -101,20 +55,6 @@ void compareTo(int op1Major, int op1Minor, String expectedComparison, int op2Maj compares(expectedComparison, BasicVersion.of(op2Major, op2Minor))); } - @ParameterizedTest - @CsvSource(useHeadersInDisplayName = true, textBlock = """ - major, minor - -1, 0 - 0, -1 - -1, -1 - 65536, 0 - 0, 65536 - 65536, 65536 - """) - void of_outOfRangeMajorMinor(int major, int minor) { - assertThrows(IllegalArgumentException.class, () -> BasicVersion.of(major, minor)); - } - @ParameterizedTest @CsvSource(useHeadersInDisplayName = true, textBlock = """ major, minor, checkMajor, expectedResult @@ -154,8 +94,4 @@ void isEqualOrAbove_major_minor(int major, int minor, int checkMajor, int checkM "result of (" + version + ").isEqualOrAbove(" + checkMajor + ',' + checkMinor + ")"); } - private static String keyAsString(int key) { - return Long.toString(key & 0x0FFFFFFFFL, 2); - } - } \ No newline at end of file From e2e4e87bf351f063216cf6eef2392985385b62c5 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Fri, 17 Oct 2025 16:02:02 +0200 Subject: [PATCH 52/64] #882 Schema support for StatisticsManager.getTableStatistics --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 4 + src/docs/asciidoc/release_notes.adoc | 5 ++ .../org/firebirdsql/gds/ClumpletReader.java | 4 +- .../org/firebirdsql/gds/ISCConstants.java | 6 +- .../firebirdsql/gds/ServiceRequestBuffer.java | 19 ++++- .../management/FBStatisticsManager.java | 31 ++++---- .../management/StatisticsManager.java | 54 +++++++++++-- .../management/FBStatisticsManagerTest.java | 78 ++++++++++++++++++- 8 files changed, 173 insertions(+), 28 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index 5eab9e398..a4310aa46 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -111,6 +111,10 @@ this fulfills the JDBC requirements that a `CallableStatement` is not sensitive ** For qualified *and* unambiguous procedure reference, the selectability is cached *per connection*, for unqualified or ambiguous procedure reference, the lookup is performed on each `Connection.prepareCall`, to account for search path changes ** Support for packages was missing in the handling of callable statements, and is added, also for older versions * TODO: Define effects for management API +** `StatisticsManager` +*** `getTableStatistics` received an overload to also accept a list of schemas (`sts_schema`) +** `FBTableStatisticsManager`/`TableStatistics` +*** TODO: API and internals need to be redesigned to account for schemas * TODO: Add information to Jaybird manual Note to self: use `// TODO Add schema support` in places that you identify need to get/improve schema support, while working on schema support elsewhere diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 3077b0919..8878ea35d 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -560,6 +560,11 @@ we recommend to always use `null` for `catalog` * `FirebirdConnection`/`FBConnection` ** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported ** Added method `List getSearchPatList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported +* `StatisticsManager` +** `getTableStatistics` +*** `getTableStatistics(String[] tableNames)` was changed to accept varargs (`getTableStatistics(String... tableNames)`) +*** Added overload `getTableStatistics(List tableNames)` with same behaviour as `getTableStatistics(String... tableNames)` +*** Added overload `getTableStatistics(List schemas, List tableNames)` -- if `schemas` is non-empty, on Firebird 6.0 and higher, it will restrict the search for tables to the specified schemas // TODO add major changes diff --git a/src/main/org/firebirdsql/gds/ClumpletReader.java b/src/main/org/firebirdsql/gds/ClumpletReader.java index 523832312..8b0268741 100644 --- a/src/main/org/firebirdsql/gds/ClumpletReader.java +++ b/src/main/org/firebirdsql/gds/ClumpletReader.java @@ -142,8 +142,8 @@ yield switch (spbState) { default -> throw invalidStructure("unknown parameter for setting database properties"); }; case ISCConstants.isc_action_svc_db_stats -> switch (tag) { - case SpbItems.isc_spb_dbname, SpbItems.isc_spb_command_line, ISCConstants.isc_spb_sts_table -> - ClumpletType.StringSpb; + case SpbItems.isc_spb_dbname, SpbItems.isc_spb_command_line, + ISCConstants.isc_spb_sts_table, ISCConstants.isc_spb_sts_schema -> ClumpletType.StringSpb; case SpbItems.isc_spb_options -> ClumpletType.IntSpb; default -> throw invalidStructure("unknown parameter for getting statistics"); }; diff --git a/src/main/org/firebirdsql/gds/ISCConstants.java b/src/main/org/firebirdsql/gds/ISCConstants.java index 8fe688d50..0ce4c1c17 100644 --- a/src/main/org/firebirdsql/gds/ISCConstants.java +++ b/src/main/org/firebirdsql/gds/ISCConstants.java @@ -336,10 +336,14 @@ public interface ISCConstants { int isc_spb_sts_idx_pages = 0x08; int isc_spb_sts_sys_relations = 0x10; int isc_spb_sts_record_versions = 0x20; - int isc_spb_sts_table = 0x40; + // No longer a flag + //int isc_spb_sts_table = 0x40; int isc_spb_sts_nocreation = 0x80; int isc_spb_sts_encryption = 0x100; + int isc_spb_sts_table = 64; + int isc_spb_sts_schema = 65; + // Common, structural codes int isc_info_end = 1; diff --git a/src/main/org/firebirdsql/gds/ServiceRequestBuffer.java b/src/main/org/firebirdsql/gds/ServiceRequestBuffer.java index f749b492b..280f0ecab 100644 --- a/src/main/org/firebirdsql/gds/ServiceRequestBuffer.java +++ b/src/main/org/firebirdsql/gds/ServiceRequestBuffer.java @@ -2,7 +2,7 @@ SPDX-FileCopyrightText: Copyright 2003 Ryan Baldwin SPDX-FileCopyrightText: Copyright 2004-2006 Roman Rokytskyy SPDX-FileCopyrightText: Copyright 2004 Gabriel Reid - SPDX-FileCopyrightText: Copyright 2014-2022 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2014-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause */ package org.firebirdsql.gds; @@ -14,6 +14,7 @@ * Firebird API documentation and specifies the attributes for the Services API * operation. */ +@SuppressWarnings("unused") public interface ServiceRequestBuffer extends ParameterBuffer { //@formatter:off @@ -237,7 +238,7 @@ public interface ServiceRequestBuffer extends ParameterBuffer { int STATS_DB_NAME = SpbItems.isc_spb_dbname; // Database statistics options. - int STATS_OPTIONS = SpbItems.isc_spb_dbname; + int STATS_OPTIONS = SpbItems.isc_spb_options; // Each constant below represents a bit in a bit mask. int STATS_DATA_PAGES = ISCConstants.isc_spb_sts_data_pages; @@ -246,9 +247,21 @@ public interface ServiceRequestBuffer extends ParameterBuffer { int STATS_INDEX_PAGES = ISCConstants.isc_spb_sts_idx_pages; int STATS_SYSTEM_RELATIONS = ISCConstants.isc_spb_sts_sys_relations; int STATS_RECORD_VERSIONS = ISCConstants.isc_spb_sts_record_versions; - int STATS_TABLE = ISCConstants.isc_spb_sts_table; int STATS_NOCREATION = ISCConstants.isc_spb_sts_nocreation; + /** + * Repeatable buffer item for a table name for operation {@link #STATS_DATA_PAGES}. + *

    + * For backwards compatibility, it can also be used as a flag, with a space separated list of table names in buffer + * item {@link SpbItems#isc_spb_command_line}. + *

    + */ + int STATS_TABLE = ISCConstants.isc_spb_sts_table; + /** + * Repeatable buffer item for a schema name for operation {@link #STATS_DATA_PAGES}. + */ + int STATS_SCHEMA = ISCConstants.isc_spb_sts_schema; + //@formatter:on } diff --git a/src/main/org/firebirdsql/management/FBStatisticsManager.java b/src/main/org/firebirdsql/management/FBStatisticsManager.java index 9ac0e837a..cf6c65d47 100644 --- a/src/main/org/firebirdsql/management/FBStatisticsManager.java +++ b/src/main/org/firebirdsql/management/FBStatisticsManager.java @@ -2,7 +2,7 @@ SPDX-FileCopyrightText: Copyright 2004-2005 Gabriel Reid SPDX-FileCopyrightText: Copyright 2005-2006 Roman Rokytskyy SPDX-FileCopyrightText: Copyright 2005 Steven Jardine - SPDX-FileCopyrightText: Copyright 2011-2024 Mark Rotteveel + SPDX-FileCopyrightText: Copyright 2011-2025 Mark Rotteveel SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause */ package org.firebirdsql.management; @@ -20,6 +20,7 @@ import java.sql.Connection; import java.sql.SQLException; +import java.util.List; import static org.firebirdsql.gds.ISCConstants.*; import static org.firebirdsql.gds.VaxEncoding.iscVaxInteger2; @@ -62,6 +63,7 @@ public FBStatisticsManager() { * @param gdsType * type must be PURE_JAVA, EMBEDDED, or NATIVE */ + @SuppressWarnings("unused") public FBStatisticsManager(String gdsType) { super(gdsType); } @@ -104,18 +106,21 @@ public void getDatabaseStatistics(int options) throws SQLException { } } - public void getTableStatistics(String[] tableNames) throws SQLException { - // create space-separated list of tables - StringBuilder commandLine = new StringBuilder(); - for (int i = 0; i < tableNames.length; i++) { - commandLine.append(tableNames[i]); - if (i < tableNames.length - 1) - commandLine.append(' '); - } - - try (FbService service = attachServiceManager()) { - ServiceRequestBuffer srb = createStatsSRB(service, isc_spb_sts_table); - srb.addArgument(SpbItems.isc_spb_command_line, commandLine.toString()); + @Override + public void getTableStatistics(List schemas, List tableNames) throws SQLException { + try (var service = attachServiceManager()) { + ServiceRequestBuffer srb; + GDSServerVersion serverVersion = service.getServerVersion(); + if (serverVersion.isEqualOrAbove(3)) { + srb = createStatsSRB(service, 0); + if (serverVersion.isEqualOrAbove(6)) { + schemas.forEach(schema -> srb.addArgument(isc_spb_sts_schema, schema)); + } + tableNames.forEach(tableName -> srb.addArgument(isc_spb_sts_table, tableName)); + } else { + srb = createStatsSRB(service, isc_spb_sts_table); + srb.addArgument(SpbItems.isc_spb_command_line, String.join(" ", tableNames)); + } executeServicesOperation(service, srb); } } diff --git a/src/main/org/firebirdsql/management/StatisticsManager.java b/src/main/org/firebirdsql/management/StatisticsManager.java index 736e29214..f6951b422 100644 --- a/src/main/org/firebirdsql/management/StatisticsManager.java +++ b/src/main/org/firebirdsql/management/StatisticsManager.java @@ -1,12 +1,14 @@ // SPDX-FileCopyrightText: Copyright 2004 Gabriel Reid // SPDX-FileCopyrightText: Copyright 2006 Roman Rokytskyy -// SPDX-FileCopyrightText: Copyright 2016-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2016-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-3-Clause package org.firebirdsql.management; import org.firebirdsql.gds.ISCConstants; import java.sql.SQLException; +import java.util.Arrays; +import java.util.List; /** * A {@code StatisticsManager} is responsible for replicating the functionality of the {@code gstat} command-line tool. @@ -109,6 +111,40 @@ public interface StatisticsManager extends ServiceManager { */ void getDatabaseStatistics(int options) throws SQLException; + /** + * Get the table statistics. + *

    + * For a more detailed description, see {@link #getTableStatistics(List, List)}. + *

    + * + * @param tableNames + * table names to analyze + * @throws SQLException + * if something went wrong + * @see #getTableStatistics(List) + * @see #getTableStatistics(List, List) + */ + default void getTableStatistics(String... tableNames) throws SQLException { + getTableStatistics(Arrays.asList(tableNames)); + } + + /** + * Get the table statistics. + *

    + * For a more detailed description, see {@link #getTableStatistics(List, List)}. + *

    + * + * @param tableNames + * table names to analyze + * @throws SQLException + * if something went wrong + * @see #getTableStatistics(List, List) + * @since 7 + */ + default void getTableStatistics(List tableNames) throws SQLException { + getTableStatistics(List.of(), tableNames); + } + /** * Get the table statistics. *

    @@ -123,15 +159,23 @@ public interface StatisticsManager extends ServiceManager { * *

    *

    - * Invoking this method is equivalent to the behaviour of {@code gstat -t } on the command-line. + * Invoking this method is equivalent to the behaviour of + * {@code gstat -a [ -sch ]... [ -t
    ]...} on the commandline. For — unsupported — + * Firebird 2.5 and older, it's equivalent to {@code gstat -t
    [
    ... ]}. *

    * + * @param schemaNames + * schemas to analyze; if empty, all schemas are analyzed (ignored on Firebird 5.0 or older) * @param tableNames - * array of table names to analyze. + * table names to analyze; if empty, all tables (restricted by {@code schemaNames}) are analyzed (on — + * unsupported — Firebird 2.5 and older, this will result in an error as it will require at least one + * table name) * @throws SQLException - * if something went wrong. + * if something went wrong (in current Firebird versions this includes when any of the tables cannot be + * found) + * @since 7 */ - void getTableStatistics(String[] tableNames) throws SQLException; + void getTableStatistics(List schemaNames, List tableNames) throws SQLException; /** * Get transaction information of the database specified in {@code database}. diff --git a/src/test/org/firebirdsql/management/FBStatisticsManagerTest.java b/src/test/org/firebirdsql/management/FBStatisticsManagerTest.java index 212b74439..c8868e066 100644 --- a/src/test/org/firebirdsql/management/FBStatisticsManagerTest.java +++ b/src/test/org/firebirdsql/management/FBStatisticsManagerTest.java @@ -12,16 +12,22 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.sql.Connection; import java.sql.SQLException; import java.sql.Statement; +import java.util.List; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.firebirdsql.common.FBTestProperties.*; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.matchers.GdsTypeMatchers.isEmbeddedType; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -37,8 +43,8 @@ class FBStatisticsManagerTest { private OutputStream loggingStream; private static final String DEFAULT_TABLE = """ - CREATE TABLE TEST ( - TESTVAL INTEGER NOT NULL + create table TEST ( + TESTVAL integer constraint PK_TEST primary key )"""; @BeforeEach @@ -103,12 +109,13 @@ void testGetSystemStats() throws SQLException { @Test void testGetTableStatistics() throws SQLException { createTestTable(); - statManager.getTableStatistics(new String[] { "TEST" }); + statManager.getTableStatistics("TEST"); String statistics = loggingStream.toString(); assertThat(statistics) .describedAs("The database page analysis must be in the statistics").contains("Data pages") - .describedAs("The table name must be in the statistics").contains("TEST"); + .describedAs("The table name must be in the statistics").contains("TEST") + .describedAs("The (primary key) index must be in the statistics").contains("PK_TEST"); } @Test @@ -133,6 +140,69 @@ void testGetDatabaseTransactionInfo_usingServiceConfig() throws SQLException { } } + @ParameterizedTest + @MethodSource + void testGetTableStatistics_limitBySchema(List schemas, List tables, List expectedTables, + List unexpectedTables) throws Exception { + assumeSchemaSupport(); + schemaTestSetup(); + statManager.getTableStatistics(schemas, tables); + String statistics = loggingStream.toString(); + + assertThat(statistics) + .describedAs("The database page analysis must be in the statistics").contains("Data pages"); + if (!expectedTables.isEmpty()) { + assertThat(statistics) + .describedAs("These table names must be in the statistics").contains(expectedTables); + } + if (!unexpectedTables.isEmpty()) { + assertThat(statistics) + .describedAs("These table names must not be in the statistics").doesNotContain(unexpectedTables); + } + } + + static Stream testGetTableStatistics_limitBySchema() { + final List empty = List.of(); + return Stream.of( + Arguments.of(empty, empty, + List.of("\"PUBLIC\".\"TBL1\"", "\"SCH1\".\"TBL1\"", "\"SCH1\".\"TBL2\"", "\"SCH2\".\"TBL2\"", + "\"SCH2\".\"TBL2\""), + empty), + Arguments.of(List.of("PUBLIC"), empty, List.of("\"PUBLIC\".\"TBL1\""), List.of("\"SCH1\".\"TBL1\"", + "\"SCH1\".\"TBL2\"", "\"SCH2\".\"TBL2\"", "\"SCH2\".\"TBL2\"")), + Arguments.of(List.of("PUBLIC", "SCH2"), empty, + List.of("\"PUBLIC\".\"TBL1\"", "\"SCH2\".\"TBL2\"", "\"SCH2\".\"TBL2\""), + List.of("\"SCH1\".\"TBL1\"", "\"SCH1\".\"TBL2\"")), + Arguments.of(empty, List.of("TBL1"), List.of("\"PUBLIC\".\"TBL1\"", "\"SCH1\".\"TBL1\""), + List.of("\"SCH1\".\"TBL2\"", "\"SCH2\".\"TBL2\"", "\"SCH2\".\"TBL2\"")), + Arguments.of(List.of("SCH1"), List.of("TBL1"), List.of("\"SCH1\".\"TBL1\""), + List.of("\"PUBLIC\".\"TBL1\"", "\"SCH1\".\"TBL2\"", "\"SCH2\".\"TBL2\"", "\"SCH2\".\"TBL3\"")), + Arguments.of(List.of("SCH1", "SCH2"), List.of("TBL1"), List.of("\"SCH1\".\"TBL1\""), + List.of("\"PUBLIC\".\"TBL1\"", "\"SCH1\".\"TBL2\"", "\"SCH2\".\"TBL2\"", "\"SCH2\".\"TBL3\"")), + Arguments.of(List.of("SCH1", "SCH2"), List.of("TBL1", "TBL3"), + List.of("\"SCH1\".\"TBL1\"", "\"SCH2\".\"TBL3\""), + List.of("\"PUBLIC\".\"TBL1\"", "\"SCH1\".\"TBL2\"", "\"SCH2\".\"TBL2\"")) + ); + } + + private static void schemaTestSetup() throws SQLException { + try (var connection = getConnectionViaDriverManager(); + var stmt = connection.createStatement()) { + connection.setAutoCommit(false); + for (String sql : List.of( + "create schema SCH1", + "create schema SCH2", + "create table PUBLIC.TBL1 (ID integer)", + "create table SCH1.TBL1 (ID integer)", + "create table SCH1.TBL2 (ID integer)", + "create table SCH2.TBL2 (ID integer)", + "create table SCH2.TBL3 (ID integer)")) { + stmt.execute(sql); + } + connection.commit(); + } + } + private int getExpectedOldest(FirebirdSupportInfo supportInfo) { if (supportInfo.isVersionEqualOrAbove(4, 0, 2)) { return isEmbeddedType().matches(GDS_TYPE) ? 1 : 2; From 2353b24dfa9f54512192e6e4a6f86a9fcfaf9c8e Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sat, 18 Oct 2025 15:12:51 +0200 Subject: [PATCH 53/64] #882 Schema support for FBTableStatisticsManager --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 9 +- src/docs/asciidoc/release_notes.adoc | 8 ++ .../management/FBTableStatisticsManager.java | 109 ++++++++++++++---- .../management/TableStatistics.java | 69 ++++++++--- .../FBTableStatisticsManagerTest.java | 36 ++++-- 5 files changed, 182 insertions(+), 49 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index a4310aa46..469664bc5 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -110,11 +110,16 @@ this fulfills the JDBC requirements that a `CallableStatement` is not sensitive ** The API of `StoredProcedureMetaData` (internal API) is changed to not report selectability, but to update the `FBProcedureCall` instance with selectability and other information, like identified schema and/or package. ** For qualified *and* unambiguous procedure reference, the selectability is cached *per connection*, for unqualified or ambiguous procedure reference, the lookup is performed on each `Connection.prepareCall`, to account for search path changes ** Support for packages was missing in the handling of callable statements, and is added, also for older versions -* TODO: Define effects for management API +* Effects for management API ** `StatisticsManager` *** `getTableStatistics` received an overload to also accept a list of schemas (`sts_schema`) ** `FBTableStatisticsManager`/`TableStatistics` -*** TODO: API and internals need to be redesigned to account for schemas +*** Internally `ObjectReference` is used for the table instead of a String +*** The key of the map returned by `getTableStatistics()` is a qualified table reference (i.e. `{ | .}`. +For schemaless tables, the unquoted table name is used as the key for backwards compatibility when used against Firebird 5.0 and older. +*** `TableStatistics` received two extra accessors: +**** `schema()` with the schema, or empty string if schemaless (or not found) +**** `tableReference()` with the qualified table reference (i.e. `[.]` (contrary to the key of getTableStatistics, it's always quoted!)) * TODO: Add information to Jaybird manual Note to self: use `// TODO Add schema support` in places that you identify need to get/improve schema support, while working on schema support elsewhere diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 8878ea35d..5452f1303 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -565,6 +565,14 @@ we recommend to always use `null` for `catalog` *** `getTableStatistics(String[] tableNames)` was changed to accept varargs (`getTableStatistics(String... tableNames)`) *** Added overload `getTableStatistics(List tableNames)` with same behaviour as `getTableStatistics(String... tableNames)` *** Added overload `getTableStatistics(List schemas, List tableNames)` -- if `schemas` is non-empty, on Firebird 6.0 and higher, it will restrict the search for tables to the specified schemas +* `FBTableStatisticsManager` (experimental feature) +** For schema-bound tables, the key of the map returned by `getTableStatistics()` is a fully qualified and quoted table reference (i.e. `.`). +For schemaless tables (Firebird 5.0 and older, or tables that were not found), the key is still the unquoted ``. +** The static method `toTableReference(String schema, String tableName)` can be used to create a table reference in the same format as the key of the map returned by `getTableStatistics()`. +The `schema` can be `null` or empty string for schemaless tables (i.e. Firebird 5.0 or older) +** The `TableStatistics` object received additional accessors: +*** `schema()` with the schema, or empty string for schemaless (Firebird 5.0 or older) or if the table was not found +*** `tableReference()` with the fully qualified and quoted table reference (i.e. `[.]`) // TODO add major changes diff --git a/src/main/org/firebirdsql/management/FBTableStatisticsManager.java b/src/main/org/firebirdsql/management/FBTableStatisticsManager.java index 1e26f8a78..f4040a940 100644 --- a/src/main/org/firebirdsql/management/FBTableStatisticsManager.java +++ b/src/main/org/firebirdsql/management/FBTableStatisticsManager.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.management; @@ -7,8 +7,11 @@ import org.firebirdsql.gds.ng.FbExceptionBuilder; import org.firebirdsql.gds.ng.InfoProcessor; import org.firebirdsql.gds.ng.InfoTruncatedException; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.jdbc.FirebirdConnection; import org.firebirdsql.util.Volatile; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import java.sql.Connection; import java.sql.DatabaseMetaData; @@ -17,9 +20,11 @@ import java.sql.SQLNonTransientException; import java.util.HashMap; import java.util.Map; -import java.util.stream.Collectors; +import java.util.function.Function; +import static java.util.stream.Collectors.toMap; import static org.firebirdsql.gds.ISCConstants.*; +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrEmpty; /** * Provides access to the table statistics of a {@link java.sql.Connection}. @@ -39,12 +44,13 @@ * @since 5 */ @Volatile(reason = "Experimental") +@NullMarked public final class FBTableStatisticsManager implements AutoCloseable { private static final int MAX_RETRIES = 3; - private Map tableMapping = new HashMap<>(); - private FirebirdConnection connection; + private Map tableMapping = new HashMap<>(); + private @Nullable FirebirdConnection connection; /** * Table slack is a number which is used to pad the table count used for calculating the buffer size in an attempt * to prevent or fix truncation of the info request. It is incremented when a truncation is handled. @@ -77,24 +83,31 @@ public static FBTableStatisticsManager of(Connection connection) throws SQLExcep *

    * A table is only present in the map if this connection touched it in a way which generated a statistic. *

    + *

    + * The method {@link #toKey(String, String)} can be used to produce a key as used for entries in the map. + *

    * - * @return map from table name to table statistics + * @return map from table reference ({@code } for schemaless, or + * {@code .} for schema-bound) to table statistics * @throws InfoTruncatedException * if a truncated response is received, after retrying 3 times (total: 4 attempts) while increasing * the buffer size; it is possible that subsequent calls to this method may recover (as that will increase * the buffer size even more) * @throws SQLException * if the connection is closed, or if obtaining the statistics failed due to a database access error + * @see #toKey(String, String) */ public Map getTableStatistics() throws SQLException { checkClosed(); - FbDatabase db = connection.getFbDatabase(); + @SuppressWarnings("DataFlowIssue") FbDatabase db = connection.getFbDatabase(); InfoTruncatedException lastTruncation; - TableStatisticsProcessor tableStatisticsProcessor = new TableStatisticsProcessor(); + var tableStatisticsProcessor = new TableStatisticsProcessor(); int attempt = 0; do { try { - return db.getDatabaseInfo(getInfoItems(), bufferSize(getTableCount()), tableStatisticsProcessor); + return db.getDatabaseInfo(getInfoItems(), bufferSize(getTableCount()), tableStatisticsProcessor) + .values().stream() + .collect(toMap(FBTableStatisticsManager::toKey, Function.identity())); } catch (InfoTruncatedException e) { /* Occurrence of truncation should be rare. It could occur if all tables have all statistics items, and new tables are added after the last updateMapping() call or statistics were previously requested by @@ -109,6 +122,53 @@ Here, tableSlack is incremented to account for tables removed, while updateTable throw lastTruncation; } + /** + * Produces a key to the map returned by {@link #getTableStatistics()}. + * + * @param schema + * schema, or {@code null} or empty string for schemaless + * @param tableName + * table name + * @return key: {@code } for schemaless, or {@code .} for schema-bound + * @since 7 + */ + public static String toKey(@Nullable String schema, String tableName) { + return isNullOrEmpty(schema) ? tableName : toKey(ObjectReference.of(schema, tableName)); + } + + /** + * Produces a key to the map returned by {@link #getTableStatistics()}. + * + * @param tableStatistics + * table statistics object + * @return key + * @see #toKey(String, String) + * @since 7 + */ + public static String toKey(TableStatistics tableStatistics) { + return toKey(tableStatistics.table()); + } + + /** + * Produces a key to the map returned by {@link #getTableStatistics()}. + *

    + * The behaviour is undefined when called with an {@link ObjectReference} of more than two identifiers. + *

    + * + * @param objectReference + * table object reference + * @return key + * @see #toKey(String, String) + * @since 7 + */ + static String toKey(ObjectReference objectReference) { + if (objectReference.size() == 1) { + return objectReference.first().name(); + } + // We assume object reference is size 2, but this will 'work' even if that assumption is wrong + return objectReference.toString(); + } + /** * @return the actual table count (so excluding {@link #tableSlack}). */ @@ -132,8 +192,7 @@ private int getTableCount() throws SQLException { @Override public void close() { connection = null; - tableMapping.clear(); - tableMapping = null; + tableMapping = Map.of(); } private void checkClosed() throws SQLException { @@ -146,11 +205,12 @@ private void checkClosed() throws SQLException { } private void updateTableMapping() throws SQLException { - DatabaseMetaData md = connection.getMetaData(); + @SuppressWarnings("DataFlowIssue") DatabaseMetaData md = connection.getMetaData(); try (ResultSet rs = md.getTables( null, null, "%", new String[] { "SYSTEM TABLE", "TABLE", "GLOBAL TEMPORARY" })) { while (rs.next()) { - tableMapping.put(rs.getInt("JB_RELATION_ID"), rs.getString("TABLE_NAME")); + tableMapping.put(rs.getInt("JB_RELATION_ID"), + ObjectReference.of(rs.getString("TABLE_SCHEM"), rs.getString("TABLE_NAME"))); } } } @@ -186,17 +246,17 @@ private static byte[] getInfoItems() { * {@link #updateTableMapping()} from this processor. *

    */ - private final class TableStatisticsProcessor implements InfoProcessor> { + private final class TableStatisticsProcessor implements InfoProcessor> { - private final Map statisticsBuilders = new HashMap<>(); + private final Map statisticsBuilders = new HashMap<>(); private boolean allowTableMappingUpdate = true; @Override - public Map process(byte[] infoResponse) throws SQLException { + public Map process(byte[] infoResponse) throws SQLException { try { decodeResponse(infoResponse); return statisticsBuilders.entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toTableStatistics())); + .collect(toMap(Map.Entry::getKey, e -> e.getValue().toTableStatistics())); } finally { statisticsBuilders.clear(); } @@ -245,28 +305,27 @@ void processStatistics(int statistic, byte[] buffer, int start, int end) throws } } - private String getTableName(Integer tableId) throws SQLException { - String tableName = tableMapping.get(tableId); - if (tableName == null) { + private ObjectReference getTable(Integer tableId) throws SQLException { + ObjectReference table = tableMapping.get(tableId); + if (table == null) { // mapping empty or out of date (e.g. new table created since the last update) if (allowTableMappingUpdate) { updateTableMapping(); // Ensure that if we have multiple tables missing, we don't repeatedly update the table mapping, as // that wouldn't result in new information. allowTableMappingUpdate = false; - tableName = tableMapping.get(tableId); + table = tableMapping.get(tableId); } - if (tableName == null) { + if (table == null) { // fallback - tableName = "UNKNOWN_TABLE_ID_" + tableId; + table = ObjectReference.of("UNKNOWN_TABLE_ID_" + tableId); } } - return tableName; + return table; } private TableStatistics.TableStatisticsBuilder getBuilder(int tableId) throws SQLException { - String tableName = getTableName(tableId); - return statisticsBuilders.computeIfAbsent(tableName, TableStatistics::builder); + return statisticsBuilders.computeIfAbsent(getTable(tableId), TableStatistics::builder); } } } diff --git a/src/main/org/firebirdsql/management/TableStatistics.java b/src/main/org/firebirdsql/management/TableStatistics.java index eb94dccb4..46f880f41 100644 --- a/src/main/org/firebirdsql/management/TableStatistics.java +++ b/src/main/org/firebirdsql/management/TableStatistics.java @@ -1,10 +1,11 @@ -// SPDX-FileCopyrightText: Copyright 2022-2023 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.management; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.util.Volatile; +import org.jspecify.annotations.NullMarked; -import static java.util.Objects.requireNonNull; import static org.firebirdsql.gds.ISCConstants.isc_info_backout_count; import static org.firebirdsql.gds.ISCConstants.isc_info_delete_count; import static org.firebirdsql.gds.ISCConstants.isc_info_expunge_count; @@ -22,10 +23,11 @@ * @since 5 */ @SuppressWarnings("unused") +@NullMarked @Volatile(reason = "Experimental") public final class TableStatistics { - private final String tableName; + private final ObjectReference table; private final long readSeqCount; private final long readIdxCount; private final long insertCount; @@ -35,9 +37,9 @@ public final class TableStatistics { private final long purgeCount; private final long expungeCount; - private TableStatistics(String tableName, long readSeqCount, long readIdxCount, long insertCount, long updateCount, - long deleteCount, long backoutCount, long purgeCount, long expungeCount) { - this.tableName = requireNonNull(tableName, "tableName"); + private TableStatistics(ObjectReference table, long readSeqCount, long readIdxCount, long insertCount, + long updateCount, long deleteCount, long backoutCount, long purgeCount, long expungeCount) { + this.table = table; this.readSeqCount = readSeqCount; this.readIdxCount = readIdxCount; this.insertCount = insertCount; @@ -49,10 +51,46 @@ private TableStatistics(String tableName, long readSeqCount, long readIdxCount, } /** - * @return table name + * @return table name (or {@code UNKNOWN_TABLE_ID_
    } if the table was not found) + * @see #tableReference() */ public String tableName() { - return tableName; + return table.last().name(); + } + + /** + * @return schema of table (or empty string if schemaless or the table was not found) + * @see #tableReference() + * @since 7 + */ + public String schema() { + return table.size() == 2 ? table.first().name() : ""; + } + + /** + * The table reference. + *

    + * Contrary to the key used for {@link FBTableStatisticsManager#getTableStatistics()}, the table name is always + * quoted, even for schemaless tables. If you want to derive a key for the table from an instance of this class, use + * {@link FBTableStatisticsManager#toKey(TableStatistics)}. + *

    + * + * @return fully qualified and quoted table reference ({@code [.]}) + * @see #tableName() + * @see #schema() + * @see FBTableStatisticsManager#toKey(TableStatistics) + * @since 7 + */ + public String tableReference() { + return table.toString(); + } + + /** + * @return object reference of the table + * @since 7 + */ + ObjectReference table() { + return table; } /** @@ -115,7 +153,7 @@ public long expungeCount() { @Override public String toString() { return "TableStatistics{" + - "tableName='" + tableName + '\'' + + "table='" + table + '\'' + ", readSeqCount=" + readSeqCount + ", readIdxCount=" + readIdxCount + ", insertCount=" + insertCount + @@ -127,13 +165,13 @@ public String toString() { '}'; } - static TableStatisticsBuilder builder(String tableName) { - return new TableStatisticsBuilder(tableName); + static TableStatisticsBuilder builder(ObjectReference table) { + return new TableStatisticsBuilder(table); } static final class TableStatisticsBuilder { - private final String tableName; + private final ObjectReference table; private long readSeqCount; private long readIdxCount; private long insertCount; @@ -143,8 +181,9 @@ static final class TableStatisticsBuilder { private long purgeCount; private long expungeCount; - private TableStatisticsBuilder(String tableName) { - this.tableName = tableName; + private TableStatisticsBuilder(ObjectReference table) { + assert table.size() <= 2 : "table should be an object reference of at most two identifiers"; + this.table = table; } void addStatistic(int statistic, long value) { @@ -164,7 +203,7 @@ void addStatistic(int statistic, long value) { } TableStatistics toTableStatistics() { - return new TableStatistics(tableName, readSeqCount, readIdxCount, insertCount, updateCount, deleteCount, + return new TableStatistics(table, readSeqCount, readIdxCount, insertCount, updateCount, deleteCount, backoutCount, purgeCount, expungeCount); } diff --git a/src/test/org/firebirdsql/management/FBTableStatisticsManagerTest.java b/src/test/org/firebirdsql/management/FBTableStatisticsManagerTest.java index 142545304..218e42cdf 100644 --- a/src/test/org/firebirdsql/management/FBTableStatisticsManagerTest.java +++ b/src/test/org/firebirdsql/management/FBTableStatisticsManagerTest.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2022-2024 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2022-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.management; @@ -9,6 +9,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.sql.Connection; import java.sql.ResultSet; @@ -19,6 +21,7 @@ import java.util.Map; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; import static org.firebirdsql.common.matchers.SQLExceptionMatchers.fbMessageStartsWith; import static org.firebirdsql.common.matchers.SQLExceptionMatchers.message; import static org.hamcrest.MatcherAssert.assertThat; @@ -28,6 +31,7 @@ import static org.hamcrest.Matchers.startsWith; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -56,15 +60,19 @@ void tearDown() throws SQLException { void testTableStatistics() throws SQLException { try (FBTableStatisticsManager statsMan = FBTableStatisticsManager.of(connection); Statement stmt = connection.createStatement()) { + final String key = ifSchemaElse("\"PUBLIC\".\"TEST_TABLE\"", "TEST_TABLE"); + final String tableReference = ifSchemaElse("\"PUBLIC\".\"TEST_TABLE\"", "\"TEST_TABLE\""); assertThat("Expected no statistics for TEST_TABLE", - statsMan.getTableStatistics(), not(hasKey("TEST_TABLE"))); + statsMan.getTableStatistics(), not(hasKey(key))); stmt.execute("insert into TEST_TABLE(INT_VAL) values (1)"); Map statsAfterInsert = statsMan.getTableStatistics(); - assertThat("Expected statistics for TEST_TABLE", statsAfterInsert, hasKey("TEST_TABLE")); - TableStatistics testTableAfterInsert = statsAfterInsert.get("TEST_TABLE"); + assertThat("Expected statistics for TEST_TABLE", statsAfterInsert, hasKey(key)); + TableStatistics testTableAfterInsert = statsAfterInsert.get(key); assertEquals("TEST_TABLE", testTableAfterInsert.tableName(), "tableName"); + assertEquals(ifSchemaElse("PUBLIC", ""), testTableAfterInsert.schema(), "schema"); + assertEquals(tableReference, testTableAfterInsert.tableReference(), "tableReference"); assertEquals(1, testTableAfterInsert.insertCount(), "Expected one insert"); try (ResultSet rs = stmt.executeQuery("select * from TEST_TABLE")) { @@ -72,8 +80,10 @@ void testTableStatistics() throws SQLException { } Map statsAfterSelect = statsMan.getTableStatistics(); - TableStatistics testTableAfterSelect = statsAfterSelect.get("TEST_TABLE"); + TableStatistics testTableAfterSelect = statsAfterSelect.get(key); assertEquals("TEST_TABLE", testTableAfterSelect.tableName(), "tableName"); + assertEquals(ifSchemaElse("PUBLIC", ""), testTableAfterInsert.schema(), "schema"); + assertEquals(tableReference, testTableAfterInsert.tableReference(), "tableReference"); assertEquals(1, testTableAfterSelect.insertCount(), "Expected one insert"); assertEquals(1, testTableAfterSelect.readSeqCount(), "Expected one sequential read"); } @@ -118,9 +128,10 @@ void cannotGetTableStatisticsAfterStatisticsManagerClose() throws Exception { @Test void testTableStatistics_reduceTableCount_multipleInstances() throws SQLException { try (Statement stmt = connection.createStatement()) { + final String key = ifSchemaElse("\"PUBLIC\".\"TEST_TABLE\"", "TEST_TABLE"); try (FBTableStatisticsManager statsMan = FBTableStatisticsManager.of(connection)) { assertThat("Expected no statistics for TEST_TABLE", - statsMan.getTableStatistics(), not(hasKey("TEST_TABLE"))); + statsMan.getTableStatistics(), not(hasKey(key))); stmt.execute("insert into TEST_TABLE(INT_VAL) values (1)"); @@ -129,7 +140,7 @@ void testTableStatistics_reduceTableCount_multipleInstances() throws SQLExceptio } Map statsAfterSelect = statsMan.getTableStatistics(); - statsAfterSelect.get("TEST_TABLE"); + assertNotNull(statsAfterSelect.get(key), "statsAfterSelect for TEST_TABLE"); } stmt.execute("drop table TEST_TABLE"); @@ -142,4 +153,15 @@ void testTableStatistics_reduceTableCount_multipleInstances() throws SQLExceptio } } + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + , TABLE1, TABLE1 + '', TABLE1, TABLE1 + SCHEMA1, TABLE1, "SCHEMA1"."TABLE1" + """) + void testToKey(String schema, String tableName, String expectedTableReference) { + assertEquals(expectedTableReference, FBTableStatisticsManager.toKey(schema, tableName), + "tableReference"); + } + } \ No newline at end of file From c2320edd1f2c0422e21c87f04cbf34b665a52173 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 19 Oct 2025 13:37:41 +0200 Subject: [PATCH 54/64] Fixed wrong name --- src/docs/asciidoc/release_notes.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 5452f1303..f129b1d47 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -568,7 +568,7 @@ we recommend to always use `null` for `catalog` * `FBTableStatisticsManager` (experimental feature) ** For schema-bound tables, the key of the map returned by `getTableStatistics()` is a fully qualified and quoted table reference (i.e. `.`). For schemaless tables (Firebird 5.0 and older, or tables that were not found), the key is still the unquoted ``. -** The static method `toTableReference(String schema, String tableName)` can be used to create a table reference in the same format as the key of the map returned by `getTableStatistics()`. +** The static method `toKey(String schema, String tableName)` can be used to create a table reference in the same format as the key of the map returned by `getTableStatistics()`. The `schema` can be `null` or empty string for schemaless tables (i.e. Firebird 5.0 or older) ** The `TableStatistics` object received additional accessors: *** `schema()` with the schema, or empty string for schemaless (Firebird 5.0 or older) or if the table was not found From 180999c3b7b259fc5d1c8f66e057153263b55884 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sun, 19 Oct 2025 13:43:33 +0200 Subject: [PATCH 55/64] #882 Added setters to FirebirdConnection to set search path --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 14 +-- src/docs/asciidoc/release_notes.adoc | 2 + .../org/firebirdsql/jdbc/FBConnection.java | 11 +++ .../firebirdsql/jdbc/FirebirdConnection.java | 56 +++++++++++ .../org/firebirdsql/jdbc/SchemaChanger.java | 41 +++++++- .../jdbc/FBConnectionSchemaTest.java | 95 +++++++++++++++++++ 6 files changed, 209 insertions(+), 10 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index 469664bc5..608de6f23 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -79,14 +79,15 @@ The following changes are made to Jaybird to support schemas when connecting to + On Firebird 5.0 and older, this will be silently ignored. * In internal queries in Jaybird, and fully qualified object names, we'll use the regular -- unquoted -- identifier `SYSTEM`, even though `SYSTEM` is a SQL:2023 reserved word, to preserve dialect 1 compatibility. -* `Connection.getSchema()` will return the result of `select CURRENT_SCHEMA from SYSTEM.RDB$DATABASE`; +* `Connection` +** `getSchema()` will return the result of `select CURRENT_SCHEMA from SYSTEM.RDB$DATABASE`; the connection will not store this value -* `Connection.setSchema(String)` will query the current search path, and if not previously called, it will prepend the schema name to the search path, otherwise it will _replace_ the previously prepended schema name. +** `setSchema(String)` will query the current search path, and if not previously called, it will prepend the schema name to the search path, otherwise it will _replace_ the previously prepended schema name. The schema name is stored _only_ for this replacement operation (i.e. it will not be returned by `getSchema`!) -** The name must match exactly as is stored in the metadata (it is always case-sensitive!) -** Jaybird will take care of quoting, and will always quote on dialect 3 -** Existence of the schema is **not** checked, so it is possible the current schema does not change with this operation, as `CURRENT_SCHEMA` reports the first _valid_ schema -** JDBC specifies that "`__Calling ``setSchema`` has no effect on previously created or prepared Statement objects.__`"; +*** The name must match exactly as is stored in the metadata (it is always case-sensitive!) +*** Jaybird will take care of quoting, and will always quote on dialect 3 +*** Existence of the schema is **not** checked, so it is possible the current schema does not change with this operation, as `CURRENT_SCHEMA` reports the first _valid_ schema +*** JDBC specifies that "`__Calling ``setSchema`` has no effect on previously created or prepared Statement objects.__`"; Jaybird cannot honour this requirement for plain `Statement`, as schema resolution is on prepare time (which for plain `Statement` is on execute), and not always for `CallableStatement`, as the implementation may delay actual prepare until execution, though we do try to identify the procedure when the callable statement is created and use that to fully-qualify the procedure. * Request `isc_info_sql_relation_schema` after preparing a query, record it in `FieldDescriptor`, and return it were relevant for JDBC (e.g. `ResultSetMetaData.getSchemaName(int)`) ** For Firebird 5.0 and older, we need to ensure that JDBC methods continue to report the correct value (i.e. `""` for schema-less objects) @@ -102,6 +103,7 @@ We considered adding a column that lists the schema(s) that contain the package * `FirebirdConnection` ** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported ** Added method `List getSearchPathList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported +** Added methods `setSearchPath(String)` and `setSearchPathList` with overloads `(String...)` and `(List)` to set the search path. * `FBCallableStatement` ** On creating the instance, the stored procedure is parsed and identified in the database metadata, including selectability, unless `ignoreProcedureType` is `true` *** Parsing of callable statements is changed to be able to identify schema, package and procedure name, including scope specifiers diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index f129b1d47..22b0b738d 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -560,6 +560,8 @@ we recommend to always use `null` for `catalog` * `FirebirdConnection`/`FBConnection` ** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported ** Added method `List getSearchPatList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported +** Added methods `setSearchPath(String)` and `setSearchPathList(String...)/(List)` where added to set the search path; +these methods throw `SQLFeatureNotSupportedException` if schemas are not supported. * `StatisticsManager` ** `getTableStatistics` *** `getTableStatistics(String[] tableNames)` was changed to accept varargs (`getTableStatistics(String... tableNames)`) diff --git a/src/main/org/firebirdsql/jdbc/FBConnection.java b/src/main/org/firebirdsql/jdbc/FBConnection.java index aeb483a56..3ec0afd17 100644 --- a/src/main/org/firebirdsql/jdbc/FBConnection.java +++ b/src/main/org/firebirdsql/jdbc/FBConnection.java @@ -23,6 +23,7 @@ import org.firebirdsql.jaybird.props.DatabaseConnectionProperties; import org.firebirdsql.jaybird.props.PropertyConstants; import org.firebirdsql.jaybird.util.SQLExceptionChainBuilder; +import org.firebirdsql.jaybird.util.SearchPathHelper; import org.firebirdsql.jaybird.xca.FBLocalTransaction; import org.firebirdsql.jaybird.xca.FBManagedConnection; import org.firebirdsql.jdbc.InternalTransactionCoordinator.MetaDataTransactionCoordinator; @@ -1118,11 +1119,21 @@ public final String getSearchPath() throws SQLException { return getSchemaInfo().searchPath(); } + @Override + public final void setSearchPath(String searchPath) throws SQLException { + getSchemaChanger().setSearchPath(searchPath); + } + @Override public final List getSearchPathList() throws SQLException { return getSchemaInfo().toSearchPathList(); } + @Override + public void setSearchPathList(List schemas) throws SQLException { + getSchemaChanger().setSearchPath(SearchPathHelper.toSearchPath(schemas, getQuoteStrategy())); + } + private SchemaChanger.SchemaInfo getSchemaInfo() throws SQLException { try (var ignored = withLock()) { return getSchemaChanger().getCurrentSchemaInfo(); diff --git a/src/main/org/firebirdsql/jdbc/FirebirdConnection.java b/src/main/org/firebirdsql/jdbc/FirebirdConnection.java index 0af165010..30e4a0ae8 100644 --- a/src/main/org/firebirdsql/jdbc/FirebirdConnection.java +++ b/src/main/org/firebirdsql/jdbc/FirebirdConnection.java @@ -11,6 +11,7 @@ import java.sql.Blob; import java.sql.Connection; import java.sql.SQLException; +import java.util.Arrays; import java.util.List; /** @@ -128,6 +129,22 @@ public interface FirebirdConnection extends Connection { */ void resetKnownClientInfoProperties(); + /** + * Sets the search path as if executing {@code SET SEARCH_PATH TO ...}. + * + * @param searchPath + * comma-separated search path (names must be correctly quoted — if needed) + * @throws java.sql.SQLFeatureNotSupportedException + * if the server does not support schemas (Firebird 5.0 or older) + * @throws SQLException + * if {@code schemas} is null or blank, or for database access errors + * @see #getSearchPath() + * @see #setSearchPathList(List) + * @see #setSchema(String) + * @since 7 + */ + void setSearchPath(String searchPath) throws SQLException; + /** * Returns the schema search path. * @@ -135,17 +152,56 @@ public interface FirebirdConnection extends Connection { * supported * @throws SQLException * if the connections is closed, or for database access errors + * @see #setSearchPath(String) * @see #getSearchPathList() * @since 7 */ String getSearchPath() throws SQLException; + /** + * Sets the search path as if executing {@code SET SEARCH_PATH TO ...}. + * + * @param schemas + * schemas to set as search path (names must be unquoted) + * @throws java.sql.SQLFeatureNotSupportedException + * if the server does not support schemas (Firebird 5.0 or older) + * @throws SQLException + * if {@code schemas} is empty, or for database access errors + * @see #setSearchPathList(List) + * @see #getSearchPathList() + * @see #setSearchPath(String) + * @see #setSchema(String) + * @since 7 + */ + default void setSearchPathList(String... schemas) throws SQLException { + setSearchPathList(Arrays.asList(schemas)); + } + + /** + * Sets the search path as if executing {@code SET SEARCH_PATH TO ...}. + * + * @param schemas + * schemas to set as search path (names must be unquoted) + * @throws java.sql.SQLFeatureNotSupportedException + * if the server does not support schemas (Firebird 5.0 or older) + * @throws SQLException + * if {@code schemas} is empty, or for database access errors + * @see #setSearchPathList(List) + * @see #getSearchPathList() + * @see #setSearchPath(String) + * @see #setSchema(String) + * @since 7 + */ + void setSearchPathList(List schemas) throws SQLException; + /** * Returns the schema search path as a list of unquoted schema names. * * @return list of unquoted schema names, or an empty list if schemas are not supported * @throws SQLException * if the connection is closed, or for database access errors + * @see #setSearchPathList(String...) + * @see #setSearchPathList(List) * @see #getSearchPath() * @since 7 */ diff --git a/src/main/org/firebirdsql/jdbc/SchemaChanger.java b/src/main/org/firebirdsql/jdbc/SchemaChanger.java index 2466b2b07..e507e1a3b 100644 --- a/src/main/org/firebirdsql/jdbc/SchemaChanger.java +++ b/src/main/org/firebirdsql/jdbc/SchemaChanger.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Objects; +import static org.firebirdsql.jaybird.util.StringUtils.isNullOrBlank; import static org.firebirdsql.util.FirebirdSupportInfo.supportInfoFor; /** @@ -41,9 +42,20 @@ sealed abstract class SchemaChanger { * @throws SQLException * for database access errors, or if {@code schema} is {@code null} or blank if schemas are * supported + * @see #setSearchPath(String) */ abstract void setSchema(String schema) throws SQLException; + /** + * Sets the search path, overriding any previously set current schema or search path. + * + * @param searchPath new search path to set (non-{@code null} and not blank, comma-separate, and quoted if needed) + * @throws java.sql.SQLFeatureNotSupportedException if schemas are not supported (Firebird 5.0 and older) + * @throws SQLException for database access errors, or if {@code searchPath} is {@code null} or blank + * @see #setSchema(String) + */ + abstract void setSearchPath(String searchPath) throws SQLException; + /** * Current schema and search path. *

    @@ -134,7 +146,7 @@ SchemaInfo getCurrentSchemaInfo() throws SQLException { @Override void setSchema(String schema) throws SQLException { - if (schema == null || schema.isBlank()) { + if (isNullOrBlank(schema)) { // TODO externalize? throw new SQLDataException("schema must be non-null and not blank", SQLStateConstants.SQL_STATE_INVALID_USE_NULL); @@ -166,14 +178,30 @@ void setSchema(String schema) throws SQLException { newSearchPath.addAll(originalSearchPath); } - //noinspection SqlSourceToSinkFlow - getStatement().execute("set search_path to " - + SearchPathHelper.toSearchPath(newSearchPath, connection.getQuoteStrategy())); + setSearchPath0(SearchPathHelper.toSearchPath(newSearchPath, connection.getQuoteStrategy())); schemaInfoAfterLastChange = getCurrentSchemaInfo(); lastSearchPath = List.copyOf(newSearchPath); lastSchemaChange = schema; } } + + @Override + void setSearchPath(String searchPath) throws SQLException { + if (isNullOrBlank(searchPath)) { + // TODO externalize? + throw new SQLDataException("search path must have at least one schema", + SQLStateConstants.SQL_STATE_INVALID_USE_NULL); + } + setSearchPath0(searchPath); + schemaInfoAfterLastChange = getCurrentSchemaInfo(); + lastSearchPath = schemaInfoAfterLastChange.toSearchPathList(); + // given this change was no explicit call to setSchema, clear it + lastSchemaChange = null; + } + + private void setSearchPath0(String searchPath) throws SQLException { + getStatement().execute("set search_path to " + searchPath); + } } /** @@ -188,6 +216,11 @@ void setSchema(String schema) { // do nothing (not even validate the name) } + @Override + void setSearchPath(String searchPath) throws SQLException { + throw new FBDriverNotCapableException("Schema support required for setSearchPath"); + } + @Override SchemaInfo getCurrentSchemaInfo() { return SchemaInfo.NULL_INSTANCE; diff --git a/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java b/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java index 36bf8b6c0..8124f0f57 100644 --- a/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java +++ b/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java @@ -4,6 +4,7 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.common.extension.UsesDatabaseExtension.UsesDatabaseForAll; +import org.firebirdsql.common.matchers.SQLExceptionMatchers; import org.firebirdsql.jaybird.props.PropertyNames; import org.firebirdsql.jaybird.util.SearchPathHelper; import org.junit.jupiter.api.Test; @@ -17,15 +18,18 @@ import java.sql.ResultSetMetaData; import java.sql.SQLDataException; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.util.List; import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; import static org.firebirdsql.common.FbAssumptions.assumeNoSchemaSupport; import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; +import static org.firebirdsql.common.matchers.SQLExceptionMatchers.message; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -203,6 +207,97 @@ void connectionSearchPath(String searchPath, String expectedSchema, String expec } } + @Test + void setSearchPath_noSchemaSupport_throwsFBDriverNotCapable() throws Exception { + assumeNoSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + assertThrows(FBDriverNotCapableException.class, () -> connection.setSearchPath("SYSTEM")); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " ", " " }) + void setSearchPath_schemaSupport_nullOrBlank_notAccepted(String searchPath) throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + var exception = assertThrows(SQLDataException.class, () -> connection.setSearchPath(searchPath)); + assertThat(exception, message(startsWith("search path must have at least one schema"))); + } + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + searchPath, expectedSearchPath + PUBLIC, '"PUBLIC", "SYSTEM"' + 'PUBLIC, SYSTEM', '"PUBLIC", "SYSTEM"' + public, '"PUBLIC", "SYSTEM"' + "public", '"public", "SYSTEM"' + SCHEMA_1, '"SCHEMA_1", "SYSTEM"' + "case_sensitive", '"case_sensitive", "SYSTEM"' + # NOTE Unquoted! + case_sensitive, '"CASE_SENSITIVE", "SYSTEM"' + 'SCHEMA_1, "case_sensitive", SYSTEM, PUBLIC', '"SCHEMA_1", "case_sensitive", "SYSTEM", "PUBLIC"' + """) + void setSearchPath_schemaSupport(String searchPath, String expectedSearchPath) throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + connection.setSearchPath(searchPath); + + assertEquals(expectedSearchPath, connection.getSearchPath(), "searchPath"); + } + } + + @Test + void setSearchPathList_stringArr_noSchemaSupport_throwsFBDriverNotCapable() throws Exception { + assumeNoSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + assertThrows(SQLFeatureNotSupportedException.class, () -> connection.setSearchPathList("SYSTEM")); + } + } + + // As setSearchPathList(String...) goes through setSearchPathList(List), we only tests through the latter + + @Test + void setSearchPathList_stringList_noSchemaSupport_throwsFBDriverNotCapable() throws Exception { + assumeNoSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + assertThrows(SQLFeatureNotSupportedException.class, () -> connection.setSearchPathList(List.of("SYSTEM"))); + } + } + + @Test + void setSearchPathList_stringList_schemaSupport_empty_notAccepted() throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + var exception = assertThrows(SQLDataException.class, () -> connection.setSearchPathList(List.of())); + assertThat(exception, message(startsWith("search path must have at least one schema"))); + } + } + + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + searchPath, expectedSearchPath + PUBLIC, '"PUBLIC", "SYSTEM"' + 'PUBLIC, SYSTEM', '"PUBLIC", "SYSTEM"' + public, '"PUBLIC", "SYSTEM"' + "public", '"public", "SYSTEM"' + SCHEMA_1, '"SCHEMA_1", "SYSTEM"' + "case_sensitive", '"case_sensitive", "SYSTEM"' + # NOTE Unquoted! + case_sensitive, '"CASE_SENSITIVE", "SYSTEM"' + 'SCHEMA_1, "case_sensitive", SYSTEM, PUBLIC', '"SCHEMA_1", "case_sensitive", "SYSTEM", "PUBLIC"' + """) + void setSearchPathList_stringList_schemaSupport(String searchPath, String expectedSearchPath) throws Exception { + assumeSchemaSupport(); + try (var connection = getConnectionViaDriverManager()) { + List searchPathList = SearchPathHelper.parseSearchPath(searchPath); + connection.setSearchPathList(searchPathList); + + assertEquals(expectedSearchPath, connection.getSearchPath(), "searchPath"); + } + } + private static void checkSchemaResolution(Connection connection, String expectedSchema) throws SQLException { try (var pstmt = connection.prepareStatement("select * from TABLE_ONE")) { ResultSetMetaData rsmd = pstmt.getMetaData(); From d9342717d638bb3461938a53f92de224a5cd0bb1 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Sat, 25 Oct 2025 14:20:12 +0200 Subject: [PATCH 56/64] Document compatibility issue with schema(Pattern) in dbmd --- src/docs/asciidoc/release_notes.adoc | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 22b0b738d..e4d0085ea 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -516,6 +516,7 @@ Firebird 6.0 introduces schemas, and Jaybird 7 provides support for schemas as d Changes include: * Connection property `searchPath` sets the initial search path of the connection. ++ The search path is the list of schemas that will be searched for schema-bound objects if they are not explicitly qualified with a schema name. The first _valid_ schema is the current schema of the connection. + @@ -591,6 +592,61 @@ Jaybird 7 introduces some changes in compatibility and announces future breaking *The list might not be complete, if you notice a difference in behavior that is not listed, please {issues}[report it as bug^].* It might have been a change we forgot to document, but it could just as well be an implementation bug. +[#compatibility-changes-schema] +=== Potential compatibility issues due to schema support + +The implementation of schema support may cause compatibility issues. +In general, these changes will only impact Jaybird when connecting to Firebird 6.0 or higher. + +We recommend looking over this section even if you currently do not use Firebird 6.0 or higher, so you can prepare your code to be ready for a future migration to Firebird 6.0. + +[#compatibility-changes-schema-dbmd-params] +==== Parameters `schema` and `schemaPattern` in methods of `DatabaseMetaData` + +In previous versions, Jaybird ignored the `schema` and `schemaPattern` parameters of the `DatabaseMetaData` methods (usually the second parameter of `DatabaseMetaData.getXXX(...)` methods). +In Jaybird 7, it no longer ignores these parameters when querying a Firebird 6.0 or higher database. + +If you currently pass the "`wrong`" value for these methods, especially `""` (empty string, i.e. only return schemaless objects), you may get no or fewer results than expected. + +[float] +===== `schema` + +The `schema` parameter performs an exact, case-sensitive match on the schema name, unless it's `null` (i.e. don't filter by schema). + +If your code currently passes `""` (empty string), you need to either replace it with `null` (don't filter by schema), or the desired schema name. + +.`DatabaseMetaData` methods with `schema` parameter +* `getBestRowIdentifier` +* `getColumnPrivileges` +* `getCrossReference` (parameters `parentSchema` and `foreignSchema`) +* `getExportedKeys` +* `getImportedKeys` +* `getIndexInfo` +* `getPrimaryKeys` +* `getVersionColumns` + +(Unsupported metadata methods are not listed.) + +[float] +===== `schemaPattern` + +The `schemaPattern` parameter performs a case-sensitive `LIKE` match on the schema name, unless it's `null` (i.e. don't filter by schema). + +If your code currently passes `""` (empty string), you need to either replace it with `null` (don't filter by schema), use `"%"` (match all schemas, effectively the same as passing `null`), or an appropriate `LIKE` pattern for the desired schemas. + +.`DatabaseMetaData` methods with `schemaPattern` parameter +* `getColumns` +* `getFunctionColumns` +* `getFunctions` +* `getProcedureColumns` +* `getProcedures` +* `getPseudoColumns` +* `getSchemas` +* `getTablePrivileges` +* `getTables` + +(Unsupported metadata methods are not listed.) + // TODO Document compatibility issues [#removal-of-classes-packages-and-methods-without-deprecation] From a35a3a038d249e30fe10eb57389c4c81d8587f41 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 30 Oct 2025 13:35:04 +0100 Subject: [PATCH 57/64] Correct documentation of getXXXSourceCode methods - Fix typo in table name - Add tests --- src/docs/asciidoc/release_notes.adoc | 2 + .../firebirdsql/jdbc/FBDatabaseMetaData.java | 2 +- .../jdbc/FirebirdDatabaseMetaData.java | 20 +-- .../jdbc/FBDatabaseMetaDataTest.java | 137 +++++++++++++++++- 4 files changed, 149 insertions(+), 12 deletions(-) diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index e4d0085ea..53cf2e1bc 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -582,6 +582,8 @@ The `schema` can be `null` or empty string for schemaless tables (i.e. Firebird [#other-fixes-and-changes] === Other fixes and changes +* Changed the documentation of `FirebirdDatabaseMetaData` methods `getProcedureSourceCode`, `getTriggerSourceCode` and `getViewSourceCode` that the object not being found is reported by a `null` value, not a `SQLException`. +With this change, the documentation reflects the actual behaviour. * ... [#compatibility-changes] diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index d2d54ef49..2ae76bb9e 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1898,7 +1898,7 @@ public String getViewSourceCode(String schema, String viewName) throws SQLExcept private enum SourceObjectType { PROCEDURE("RDB$PROCEDURES", "RDB$PROCEDURE_SOURCE", "RDB$PROCEDURE_NAME"), - TRIGGER("RDB$TRIGGERS", "RDB$TRIGGER_SOUCE", "RDB$TRIGGER_NAME"), + TRIGGER("RDB$TRIGGERS", "RDB$TRIGGER_SOURCE", "RDB$TRIGGER_NAME"), VIEW("RDB$RELATIONS", "RDB$VIEW_SOURCE", "RDB$RELATION_NAME"), ; diff --git a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java index 54be0045e..56164c668 100644 --- a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java @@ -54,9 +54,9 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * * @param procedureName * name of the stored procedure - * @return source of the stored procedure + * @return source of the stored procedure, or {@code null} if not found or if the source column is {@code NULL} * @throws SQLException - * if specified procedure cannot be found + * for database access errors * @deprecated use {@link DatabaseMetaData#getProcedures(String, String, String)}, column * {@code JB_PROCEDURE_SOURCE}; there are currently no plans to remove this method */ @@ -72,9 +72,9 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * * @param triggerName * name of the trigger - * @return source of the trigger + * @return source of the trigger, or {@code null} if not found or if the source column is {@code NULL} * @throws SQLException - * if specified trigger cannot be found + * for database access errors * @see #getTriggerSourceCode(String, String) */ String getTriggerSourceCode(String triggerName) throws SQLException; @@ -86,9 +86,9 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * schema of the trigger ({@code null} drops the schema from the search; ignored on Firebird 5.0 and older) * @param triggerName * name of the trigger - * @return source of the trigger + * @return source of the trigger, or {@code null} if not found or if the source column is {@code NULL} * @throws SQLException - * if specified trigger cannot be found + * for database access errors * @since 7 */ String getTriggerSourceCode(String schema, String triggerName) throws SQLException; @@ -105,9 +105,9 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * * @param viewName * name of the view - * @return source of the view + * @return source of the view, or {@code null} if not found or if the source column is {@code NULL} * @throws SQLException - * if specified view cannot be found + * for database access errors * @see #getViewSourceCode(String, String) */ String getViewSourceCode(String viewName) throws SQLException; @@ -119,9 +119,9 @@ public interface FirebirdDatabaseMetaData extends DatabaseMetaData { * schema of the trigger ({@code null} drops the schema from the search; ignored on Firebird 5.0 and older) * @param viewName * name of the view - * @return source of the view + * @return source of the view, or {@code null} if not found or if the source column is {@code NULL} * @throws SQLException - * if specified view cannot be found + * for database access errors * @since 7 */ String getViewSourceCode(String schema, String viewName) throws SQLException; diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java index cfda01771..92ee19044 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java @@ -27,8 +27,13 @@ import static java.lang.String.format; import static org.firebirdsql.common.FBTestProperties.*; +import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.firebirdsql.common.matchers.RegexMatcher.matchesRegex; +import static org.firebirdsql.gds.ISCConstants.isc_dsql_drop_trigger_failed; +import static org.firebirdsql.gds.ISCConstants.isc_dsql_table_not_found; +import static org.firebirdsql.gds.ISCConstants.isc_dsql_view_not_found; +import static org.firebirdsql.gds.ISCConstants.isc_no_meta_update; import static org.firebirdsql.jdbc.FBDatabaseMetaData.*; import static org.firebirdsql.util.FirebirdSupportInfo.supportInfoFor; import static org.hamcrest.CoreMatchers.anyOf; @@ -44,7 +49,6 @@ * * @author David Jencks * @author Mark Rotteveel - * @version 1.0 */ class FBDatabaseMetaDataTest { @@ -802,6 +806,137 @@ void testSupportsSchemasInXXX() { ); } + @SuppressWarnings("deprecation") + @Test + void testGetProcedureSourceCode_String() throws SQLException { + var dbmd = connection.getMetaData().unwrap(FirebirdDatabaseMetaData.class); + assertNull(dbmd.getProcedureSourceCode("TEST_PROC_1"), "Expected no procedure source"); + + final String procedureBody = """ + begin + /* TEST_PROC_1 body */ + end"""; + try (var stmt = connection.createStatement()) { + stmt.execute("create procedure TEST_PROC_1 (VARIN integer) as " + procedureBody); + } + + assertEquals(procedureBody, dbmd.getProcedureSourceCode("TEST_PROC_1"), "Procedure source"); + } + + @Test + void testGetTriggerSourceCode_String() throws SQLException { + var dbmd = connection.getMetaData().unwrap(FirebirdDatabaseMetaData.class); + assertNull(dbmd.getTriggerSourceCode("TEST_TRIG_1"), "Expected no trigger source"); + + final String triggerBody = """ + as + begin + /* TEST_TRIG_1 body */ + end"""; + + try (var stmt = connection.createStatement()) { + connection.setAutoCommit(false); + try { + stmt.execute("create table TEST_1 (id integer)"); + stmt.execute("create trigger TEST_TRIG_1 before insert on TEST_1 " + triggerBody); + connection.commit(); + + assertEquals(triggerBody, dbmd.getTriggerSourceCode("TEST_TRIG_1"), "Trigger source"); + } finally { + DdlHelper.executeDDL(stmt, List.of("drop table TEST_1"), isc_no_meta_update, isc_dsql_table_not_found, + isc_dsql_view_not_found, isc_dsql_drop_trigger_failed); + } + } finally { + connection.setAutoCommit(false); + } + } + + @Test + void testGetTriggerSourceCode_String_String() throws SQLException { + assumeSchemaSupport(); + var dbmd = connection.getMetaData().unwrap(FirebirdDatabaseMetaData.class); + assertNull(dbmd.getTriggerSourceCode("TEST_SCHEMA_1", "TEST_TRIG_1"), "Expected no trigger source"); + + final String triggerBody = """ + as + begin + /* TEST_SCHEMA_1.TEST_TRIG_1 body */ + end"""; + + try (var stmt = connection.createStatement()) { + connection.setAutoCommit(false); + try { + stmt.execute("create schema TEST_SCHEMA_1"); + stmt.execute("create table TEST_SCHEMA_1.TEST_1 (id integer)"); + stmt.execute("create trigger TEST_TRIG_1 before insert on TEST_SCHEMA_1.TEST_1 " + triggerBody); + connection.commit(); + + assertEquals(triggerBody, dbmd.getTriggerSourceCode("TEST_SCHEMA_1", "TEST_TRIG_1"), "Trigger source"); + assertEquals(triggerBody, dbmd.getTriggerSourceCode("TEST_TRIG_1"), "Trigger source"); + assertNull(dbmd.getTriggerSourceCode("PUBLIC", "TEST_TRIG_1"), "Expected no trigger source"); + } finally { + DdlHelper.executeDDL(stmt, List.of("drop table TEST_SCHEMA_1.TEST_1", "drop schema TEST_SCHEMA_1"), + isc_no_meta_update, isc_dsql_table_not_found, isc_dsql_view_not_found, + isc_dsql_drop_trigger_failed); + // TODO Add schema support: error code for drop schema failure? + } + } finally { + connection.setAutoCommit(true); + } + + + } + + @Test + void testGetViewSourceCode_String() throws SQLException { + var dbmd = connection.getMetaData().unwrap(FirebirdDatabaseMetaData.class); + assertNull(dbmd.getViewSourceCode("TEST_VIEW_1"), "Expected no view source"); + + final String viewBody = """ + select 'TEST_VIEW_1' as VIEW_NAME, 1 as SOME_COLUMN + from RDB$DATABASE"""; + try (var stmt = connection.createStatement()) { + try { + stmt.execute("create view TEST_VIEW_1 as " + viewBody); + + assertEquals(viewBody, dbmd.getViewSourceCode("TEST_VIEW_1"), "View source"); + } finally { + DdlHelper.executeDDL(stmt, List.of("drop view TEST_VIEW_1"), isc_no_meta_update, + isc_dsql_table_not_found, isc_dsql_view_not_found, isc_dsql_drop_trigger_failed); + } + } + } + + @Test + void testGetViewSourceCode_String_String() throws SQLException { + assumeSchemaSupport(); + var dbmd = connection.getMetaData().unwrap(FirebirdDatabaseMetaData.class); + assertNull(dbmd.getViewSourceCode("TEST_SCHEMA_1", "TEST_VIEW_1"), "Expected no view source"); + + final String viewBody = """ + select 'TEST_SCHEMA_1' as VIEW_SCHEMA, 'TEST_VIEW_1' as VIEW_NAME, 1 as SOME_COLUMN + from RDB$DATABASE"""; + try (var stmt = connection.createStatement()) { + connection.setAutoCommit(false); + try { + stmt.execute("create schema TEST_SCHEMA_1"); + stmt.execute("create view TEST_SCHEMA_1.TEST_VIEW_1 as " + viewBody); + connection.commit(); + + assertEquals(viewBody, dbmd.getViewSourceCode("TEST_SCHEMA_1", "TEST_VIEW_1"), "View source"); + assertEquals(viewBody, dbmd.getViewSourceCode("TEST_VIEW_1"), "View source"); + assertNull(dbmd.getViewSourceCode("PUBLIC", "TEST_VIEW_1"), "Expected no view source"); + } finally { + DdlHelper.executeDDL(stmt, List.of("drop view TEST_SCHEMA_1.TEST_VIEW_1", "drop schema TEST_SCHEMA_1"), + isc_no_meta_update, isc_dsql_table_not_found, isc_dsql_view_not_found, + isc_dsql_drop_trigger_failed); + // TODO Add schema support: error code for drop schema failure? + } + } finally { + connection.setAutoCommit(true); + } + } + @SuppressWarnings("SameParameterValue") private void createPackage(String packageName, String procedureName) throws Exception { try (Statement stmt = connection.createStatement()) { From 48514d592127412ccb20ea0e4cad08cc19273ff3 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Thu, 30 Oct 2025 13:39:05 +0100 Subject: [PATCH 58/64] Misc review changes --- src/docs/asciidoc/release_notes.adoc | 2 +- .../org/firebirdsql/gds/AbstractVersion.java | 1 + .../firebirdsql/gds/ServiceRequestBuffer.java | 4 +- .../org/firebirdsql/gds/ng/OdsVersion.java | 3 +- .../gds/ng/fields/FieldDescriptor.java | 3 +- .../parser/ObjectReferenceExtractor.java | 10 +- .../jaybird/parser/SearchPathExtractor.java | 8 +- .../parser/StatementIdentification.java | 14 +- .../parser/UnexpectedTokenException.java | 1 + .../props/DatabaseConnectionProperties.java | 2 + .../jaybird/util/BasicVersion.java | 1 + .../jaybird/util/CollectionUtils.java | 2 + .../firebirdsql/jaybird/util/Identifier.java | 1 + .../jaybird/util/IdentifierChain.java | 1 + .../jaybird/util/ObjectReference.java | 11 +- .../jaybird/util/SearchPathHelper.java | 1 + .../firebirdsql/jaybird/util/StringUtils.java | 2 +- .../firebirdsql/jdbc/ClientInfoProvider.java | 6 +- .../org/firebirdsql/jdbc/FBConnection.java | 2 +- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 6 +- .../org/firebirdsql/jdbc/FBRowUpdater.java | 28 ++-- .../jdbc/GeneratedKeysQueryBuilder.java | 1 + .../jdbc/MetadataStatementHolder.java | 1 + .../org/firebirdsql/jdbc/SchemaChanger.java | 9 +- .../jdbc/StoredProcedureMetaDataFactory.java | 6 +- .../firebirdsql/jdbc/metadata/NameHelper.java | 28 +--- .../management/FBStatisticsManager.java | 4 +- .../management/StatisticsManager.java | 7 +- .../firebirdsql/util/FirebirdSupportInfo.java | 4 +- .../org/firebirdsql/common/DdlHelper.java | 128 ++++++++++++++++-- .../jdbc/FBConnectionSchemaTest.java | 1 - 31 files changed, 203 insertions(+), 95 deletions(-) diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 53cf2e1bc..4540b365b 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -561,7 +561,7 @@ we recommend to always use `null` for `catalog` * `FirebirdConnection`/`FBConnection` ** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported ** Added method `List getSearchPatList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported -** Added methods `setSearchPath(String)` and `setSearchPathList(String...)/(List)` where added to set the search path; +** Added methods `setSearchPath(String)` and `setSearchPathList(String...)/(List)` to set the search path; these methods throw `SQLFeatureNotSupportedException` if schemas are not supported. * `StatisticsManager` ** `getTableStatistics` diff --git a/src/main/org/firebirdsql/gds/AbstractVersion.java b/src/main/org/firebirdsql/gds/AbstractVersion.java index 81d4bc2f8..e42270620 100644 --- a/src/main/org/firebirdsql/gds/AbstractVersion.java +++ b/src/main/org/firebirdsql/gds/AbstractVersion.java @@ -11,6 +11,7 @@ /** * Abstract version for {@code major.minor} version information. * + * @author Mark Rotteveel * @since 7 */ public abstract class AbstractVersion implements Comparable, Serializable { diff --git a/src/main/org/firebirdsql/gds/ServiceRequestBuffer.java b/src/main/org/firebirdsql/gds/ServiceRequestBuffer.java index 280f0ecab..94b446875 100644 --- a/src/main/org/firebirdsql/gds/ServiceRequestBuffer.java +++ b/src/main/org/firebirdsql/gds/ServiceRequestBuffer.java @@ -252,8 +252,8 @@ public interface ServiceRequestBuffer extends ParameterBuffer { /** * Repeatable buffer item for a table name for operation {@link #STATS_DATA_PAGES}. *

    - * For backwards compatibility, it can also be used as a flag, with a space separated list of table names in buffer - * item {@link SpbItems#isc_spb_command_line}. + * For backwards compatibility, it can also be used as an options flag, with a space separated list of table names + * in buffer item {@link SpbItems#isc_spb_command_line}. *

    */ int STATS_TABLE = ISCConstants.isc_spb_sts_table; diff --git a/src/main/org/firebirdsql/gds/ng/OdsVersion.java b/src/main/org/firebirdsql/gds/ng/OdsVersion.java index 78d5954fc..a4646e9dc 100644 --- a/src/main/org/firebirdsql/gds/ng/OdsVersion.java +++ b/src/main/org/firebirdsql/gds/ng/OdsVersion.java @@ -41,7 +41,8 @@ private OdsVersion(int major, int minor) { */ public static OdsVersion of(int major, int minor) { if ((major & 0xFFFF) != major || (minor & 0xFFFF) != minor) { - throw new IllegalArgumentException("Implementation limit for major or minor exceeded"); + throw new IllegalArgumentException( + "Implementation limit for major or minor exceeded (must be between 0 and 65535)"); } return ODS_VERSION_CACHE.computeIfAbsent(key(major, minor), ignored -> new OdsVersion(major, minor)); } diff --git a/src/main/org/firebirdsql/gds/ng/fields/FieldDescriptor.java b/src/main/org/firebirdsql/gds/ng/fields/FieldDescriptor.java index fdbf213ec..cc71dc0d8 100644 --- a/src/main/org/firebirdsql/gds/ng/fields/FieldDescriptor.java +++ b/src/main/org/firebirdsql/gds/ng/fields/FieldDescriptor.java @@ -73,7 +73,8 @@ public final class FieldDescriptor { public FieldDescriptor(int position, DatatypeCoder datatypeCoder, int type, int subType, int scale, int length, String fieldName, String tableAlias, - String originalName, String originalSchema, String originalTableName, String ownerName) { + String originalName, String originalSchema, String originalTableName, + String ownerName) { this.position = position; this.datatypeCoder = datatypeCoderForType(datatypeCoder, type, subType, scale); this.type = type; diff --git a/src/main/org/firebirdsql/jaybird/parser/ObjectReferenceExtractor.java b/src/main/org/firebirdsql/jaybird/parser/ObjectReferenceExtractor.java index c427c2a2b..6642ab8fd 100644 --- a/src/main/org/firebirdsql/jaybird/parser/ObjectReferenceExtractor.java +++ b/src/main/org/firebirdsql/jaybird/parser/ObjectReferenceExtractor.java @@ -21,6 +21,7 @@ * the parser again. *

    * + * @author Mark Rotteveel * @since 7 */ public final class ObjectReferenceExtractor implements TokenVisitor { @@ -62,9 +63,9 @@ public void visitToken(Token token, VisitorRegistrar visitorRegistrar) { identifiers.add(new Identifier(oldIdentifier.name(), identifierScope)); this.previousTokenWasScopeSpecifierValue = true; } else { - // End of identifier chain, we're no longer interested + // End of identifier chain, we're no longer interested. // This is unexpected, but we're assuming future implementation of % as remainder or modular division - // or some other kind of operator which would end the identifier chain + // or some other kind of operator which would end the identifier chain. visitorRegistrar.removeVisitor(this); return; } @@ -98,9 +99,8 @@ private boolean isScopeSpecifier(Token token) { } private boolean isScopeSpecifierValue(Token token) { - // SCHEMA is a reserved word, PACKAGE is not - return token instanceof GenericToken && token.equalsIgnoreCase("PACKAGE") - || token instanceof ReservedToken && token.equalsIgnoreCase("SCHEMA"); + // NOTE: SCHEMA is a reserved word, PACKAGE is not + return token.equalsIgnoreCase("PACKAGE") || token.equalsIgnoreCase("SCHEMA"); } @Override diff --git a/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java b/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java index a6aa10763..a66cd5444 100644 --- a/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java +++ b/src/main/org/firebirdsql/jaybird/parser/SearchPathExtractor.java @@ -15,6 +15,9 @@ * This visitor is written for the needs of {@link SearchPathHelper#parseSearchPath(String)}, and * may not be generally usable. *

    + * + * @author Mark Rotteveel + * @since 7 */ public final class SearchPathExtractor implements TokenVisitor { @@ -37,7 +40,8 @@ private void extractIdentifier(Token token, VisitorRegistrar visitorRegistrar) { if (token instanceof QuotedIdentifierToken quotedIdentifier) { identifiers.add(quotedIdentifier.name()); } else if (token instanceof GenericToken identifier && identifier.isValidIdentifier()) { - // Firebird returns the search path with quoted identifiers, but this offers extra flexibility if needed + // Firebird returns the search path with quoted identifiers, but this way we can also parse unquoted + // values (e.g. user-provided) identifiers.add(identifier.text().toUpperCase(Locale.ROOT)); } else { // Unexpected token, end parsing @@ -68,7 +72,7 @@ public void complete(VisitorRegistrar visitorRegistrar) { } /** - * The extract search path list, or empty if not parsed or if the parsed text was not a valid search path list. + * The extracted search path list, or empty if not parsed or if the parsed text was not a valid search path list. * * @return immutable list of unquoted search path entries */ diff --git a/src/main/org/firebirdsql/jaybird/parser/StatementIdentification.java b/src/main/org/firebirdsql/jaybird/parser/StatementIdentification.java index 4c5d3527d..d9e7588e0 100644 --- a/src/main/org/firebirdsql/jaybird/parser/StatementIdentification.java +++ b/src/main/org/firebirdsql/jaybird/parser/StatementIdentification.java @@ -43,7 +43,8 @@ public LocalStatementType getStatementType() { * It reports the name normalized to its metadata storage representation. *

    * - * @return Schema, {@code null} if the table was not qualified, or for {@code SELECT} and other non-DML statements + * @return schema, {@code null} if the table was not qualified, or for {@code SELECT} and other non-DML statements + * @since 7 */ public @Nullable String getSchema() { return schema; @@ -55,7 +56,7 @@ public LocalStatementType getStatementType() { * It reports the name normalized to its metadata storage representation. *

    * - * @return Table name, {@code null} for {@code SELECT} and other non-DML statements + * @return table name, {@code null} for {@code SELECT} and other non-DML statements */ public @Nullable String getTableName() { return tableName; @@ -78,13 +79,10 @@ public boolean returningClauseDetected() { */ private static @Nullable String normalizeObjectName(@Nullable Token objectToken) { if (objectToken == null) return null; - String objectName = objectToken.text().trim(); - if (objectName.length() > 2 - && objectName.charAt(0) == '"' - && objectName.charAt(objectName.length() - 1) == '"') { - return objectName.substring(1, objectName.length() - 1).replace("\"\"", "\""); + if (objectToken instanceof QuotedIdentifierToken quotedIdentifier) { + return quotedIdentifier.name(); } - return objectName.toUpperCase(Locale.ROOT); + return objectToken.text().toUpperCase(Locale.ROOT); } } diff --git a/src/main/org/firebirdsql/jaybird/parser/UnexpectedTokenException.java b/src/main/org/firebirdsql/jaybird/parser/UnexpectedTokenException.java index 694ecd71b..f19b1b907 100644 --- a/src/main/org/firebirdsql/jaybird/parser/UnexpectedTokenException.java +++ b/src/main/org/firebirdsql/jaybird/parser/UnexpectedTokenException.java @@ -15,6 +15,7 @@ * it from methods that a user calls to obtain the result of a token visitor. *

    * + * @author Mark Rotteveel * @since 7 */ public class UnexpectedTokenException extends IllegalStateException { diff --git a/src/main/org/firebirdsql/jaybird/props/DatabaseConnectionProperties.java b/src/main/org/firebirdsql/jaybird/props/DatabaseConnectionProperties.java index 2ec37f9e5..140d3a61c 100644 --- a/src/main/org/firebirdsql/jaybird/props/DatabaseConnectionProperties.java +++ b/src/main/org/firebirdsql/jaybird/props/DatabaseConnectionProperties.java @@ -827,6 +827,7 @@ default void setMaxBlobCacheSize(int maxBlobCacheSize) { /** * @return the initial search path of the connection, {@code null} if the server default search path is used * @see #setSearchPath(String) + * @since 7 */ default String getSearchPath() { return getProperty(PropertyNames.searchPath); @@ -845,6 +846,7 @@ default String getSearchPath() { * * @param searchPath * list of comma-separated schema names + * @since 7 */ default void setSearchPath(String searchPath) { setProperty(PropertyNames.searchPath, searchPath); diff --git a/src/main/org/firebirdsql/jaybird/util/BasicVersion.java b/src/main/org/firebirdsql/jaybird/util/BasicVersion.java index f85817277..87d5ca361 100644 --- a/src/main/org/firebirdsql/jaybird/util/BasicVersion.java +++ b/src/main/org/firebirdsql/jaybird/util/BasicVersion.java @@ -9,6 +9,7 @@ /** * Value class representing a version with {@code major.minor} version information. * + * @author Mark Rotteveel * @since 7 */ public final class BasicVersion extends AbstractVersion { diff --git a/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java b/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java index e45a1ef48..fdadd7c8b 100644 --- a/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java +++ b/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java @@ -80,6 +80,7 @@ public static void growToSize(final List list, final int size) { * @param * type parameter of {@code list1}, and parent type parameter of {@code list2} * @return concatenation of {@code list1} and {@code list2} + * @since 7 */ public static List concat(List list1, List list2) { var newList = new ArrayList(list1.size() + list2.size()); @@ -102,6 +103,7 @@ public static List concat(List list1, List list2) { * type parameter of {@code list1}, and parent type parameter of lists in {@code otherLists} * @return concatenation of {@code list1} and {@code otherLists} * @see #concat(List, List) + * @since 7 */ @SafeVarargs public static List concat(List list1, List... otherLists) { diff --git a/src/main/org/firebirdsql/jaybird/util/Identifier.java b/src/main/org/firebirdsql/jaybird/util/Identifier.java index e136c436a..473acd7fd 100644 --- a/src/main/org/firebirdsql/jaybird/util/Identifier.java +++ b/src/main/org/firebirdsql/jaybird/util/Identifier.java @@ -14,6 +14,7 @@ /** * An identifier is an object reference consisting of a single name. * + * @author Mark Rotteveel * @since 7 * @see ObjectReference */ diff --git a/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java b/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java index f80fdebcd..f6fdfc6d8 100644 --- a/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java +++ b/src/main/org/firebirdsql/jaybird/util/IdentifierChain.java @@ -17,6 +17,7 @@ * {@link ObjectReference#of(List)}. *

    * + * @author Mark Rotteveel * @since 7 * @see ObjectReference */ diff --git a/src/main/org/firebirdsql/jaybird/util/ObjectReference.java b/src/main/org/firebirdsql/jaybird/util/ObjectReference.java index e9a637432..5e73170a1 100644 --- a/src/main/org/firebirdsql/jaybird/util/ObjectReference.java +++ b/src/main/org/firebirdsql/jaybird/util/ObjectReference.java @@ -19,6 +19,7 @@ * an {@link Identifier}, otherwise it is an identifier chain. *

    * + * @author Mark Rotteveel * @since 7 */ public sealed abstract class ObjectReference permits Identifier, IdentifierChain { @@ -162,7 +163,7 @@ public Identifier last() { } /** - * The name(s), quoted using {@code quoteStrategy}. + * The name(s), quoted using {@code quoteStrategy}, joined using a period ({@code .}). * * @param quoteStrategy * quote strategy @@ -171,7 +172,7 @@ public Identifier last() { public abstract String toString(QuoteStrategy quoteStrategy); /** - * Quoted name(s). + * Quoted name(s), joined using a period ({@code .}). * * @return quoted name(s) equivalent of {@link #toString(QuoteStrategy)} with {@link QuoteStrategy#DIALECT_3} */ @@ -181,7 +182,7 @@ public final String toString() { } /** - * Appends name(s) to {@code sb} using {@code quoteStrategy}. + * Appends name(s), joined using a period ({@code .}), to {@code sb} using {@code quoteStrategy}. * * @param sb * string builder to append to @@ -224,7 +225,7 @@ public ObjectReference resolve(ObjectReference other) { * {@inheritDoc} *

    * Implementation note: Subclasses need to ensure consistency of equals for logically equivalent references in - * different types (e.g. an Identifier and an IdentifierChain with a single Identifier). + * different types (e.g. an {@link Identifier} and an identifier chain with a single {@code Identifier}). *

    */ @Override @@ -236,7 +237,7 @@ public boolean equals(@Nullable Object obj) { * {@inheritDoc} *

    * Implementation note: Subclasses need to ensure consistency of hashCode for logically equivalent references in - * different types (e.g. an Identifier and an IdentifierChain with a single Identifier). + * different types (e.g. an {@link Identifier} and an identifier chain with a single {@code Identifier}). *

    */ @Override diff --git a/src/main/org/firebirdsql/jaybird/util/SearchPathHelper.java b/src/main/org/firebirdsql/jaybird/util/SearchPathHelper.java index a323c454b..52b25f8c5 100644 --- a/src/main/org/firebirdsql/jaybird/util/SearchPathHelper.java +++ b/src/main/org/firebirdsql/jaybird/util/SearchPathHelper.java @@ -13,6 +13,7 @@ /** * Helpers for working with identifiers. * + * @author Mark Rotteveel * @since 7 */ public final class SearchPathHelper { diff --git a/src/main/org/firebirdsql/jaybird/util/StringUtils.java b/src/main/org/firebirdsql/jaybird/util/StringUtils.java index a8bc50a2f..9b6cf3655 100644 --- a/src/main/org/firebirdsql/jaybird/util/StringUtils.java +++ b/src/main/org/firebirdsql/jaybird/util/StringUtils.java @@ -53,7 +53,7 @@ public static boolean isNullOrEmpty(@Nullable String value) { * @param value * value to test * @return {@code true} if {@code value} is {@code null} or blank, {@code false} for non-blank strings - * @since 6 + * @since 7 */ public static boolean isNullOrBlank(@Nullable String value) { return value == null || value.isBlank(); diff --git a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java index 7b3ae5df9..445e9dcb7 100644 --- a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java +++ b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java @@ -170,7 +170,7 @@ public String getClientInfo(String name) throws SQLException { QuoteStrategy quoteStrategy = connection.getQuoteStrategy(); var sb = new StringBuilder("select "); renderGetValue(sb, property, quoteStrategy); - sb.append(" from ").append(hasSystemSchema() ? "SYSTEM.RDB$DATABASE" : "RDB$DATABASE"); + sb.append(" from ").append(supportInfoFor(connection).ifSchemaElse("SYSTEM.RDB$DATABASE", "RDB$DATABASE")); try (var rs = getStatement().executeQuery(sb.toString())) { if (rs.next()) { registerKnownProperty(property); @@ -186,10 +186,6 @@ public String getClientInfo(String name) throws SQLException { } } - private boolean hasSystemSchema() { - return supportInfoFor(connection).supportsSchemas(); - } - private void renderGetValue(StringBuilder sb, ClientInfoProperty property, QuoteStrategy quoteStrategy) { // CLIENT_PROCESS@SYSTEM was introduced in Firebird 2.5.3, so don't fall back for earlier versions if (APPLICATION_NAME_PROP.equals(property) && supportInfoFor(connection).isVersionEqualOrAbove(2, 5, 3)) { diff --git a/src/main/org/firebirdsql/jdbc/FBConnection.java b/src/main/org/firebirdsql/jdbc/FBConnection.java index 3ec0afd17..8aa292197 100644 --- a/src/main/org/firebirdsql/jdbc/FBConnection.java +++ b/src/main/org/firebirdsql/jdbc/FBConnection.java @@ -1107,7 +1107,7 @@ public void setSchema(String schema) throws SQLException { * which reports the first valid schema of the search path. *

    * - * @return the current schema, on Firebird 5.0 and older always {@code null} as schemas ar not supported + * @return the current schema; on Firebird 5.0 and older always {@code null} as schemas ar not supported */ @Override public final String getSchema() throws SQLException { diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 2ae76bb9e..ee7d962c5 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -769,7 +769,7 @@ public boolean supportsLimitedOuterJoins() throws SQLException { */ @Override public String getSchemaTerm() throws SQLException { - return firebirdSupportInfo.supportsSchemas() ? "SCHEMA" : null; + return firebirdSupportInfo.ifSchemaElse("SCHEMA", null); } @Override @@ -1369,7 +1369,7 @@ public ResultSet getColumns(String catalog, String schemaPattern, String tableNa *

    * Jaybird defines these additional columns: *

      - *
    1. JB_GRANTEE_TYPE String => Object type of {@code GRANTEE} (NOTE: Jaybird specific column; + *
    2. JB_GRANTEE_TYPE String => Object type of {@code GRANTEE} (NOTE: Jaybird specific column; * retrieve by name!).
    3. *
    4. JB_GRANTEE_SCHEMA String => Schema of {@code GRANTEE} if it's a schema-bound object (NOTE: * Jaybird specific column; retrieve by name!).
    5. @@ -1402,7 +1402,7 @@ public ResultSet getColumnPrivileges(String catalog, String schema, String table *

      * Jaybird defines these additional columns: *

        - *
      1. JB_GRANTEE_TYPE String => Object type of {@code GRANTEE} (NOTE: Jaybird specific column; + *
      2. JB_GRANTEE_TYPE String => Object type of {@code GRANTEE} (NOTE: Jaybird specific column; * retrieve by name!).
      3. *
      4. JB_GRANTEE_SCHEMA String => Schema of {@code GRANTEE} if it's a schema-bound object (NOTE: * Jaybird specific column; retrieve by name!).
      5. diff --git a/src/main/org/firebirdsql/jdbc/FBRowUpdater.java b/src/main/org/firebirdsql/jdbc/FBRowUpdater.java index 3beae4086..db1a8fdd4 100644 --- a/src/main/org/firebirdsql/jdbc/FBRowUpdater.java +++ b/src/main/org/firebirdsql/jdbc/FBRowUpdater.java @@ -68,7 +68,7 @@ final class FBRowUpdater implements FirebirdRowUpdater { private static final byte[][] EMPTY_2D_BYTES = new byte[0][]; - private final ObjectReference tableName; + private final ObjectReference table; private final FBObjectListener.ResultSetListener rsListener; private final GDSHelper gdsHelper; private final RowDescriptor rowDescriptor; @@ -87,8 +87,8 @@ final class FBRowUpdater implements FirebirdRowUpdater { FBRowUpdater(FBConnection connection, RowDescriptor rowDescriptor, boolean cached, FBObjectListener.ResultSetListener rsListener) throws SQLException { quoteStrategy = connection.getQuoteStrategy(); - tableName = requireSingleTable(rowDescriptor, quoteStrategy); - keyColumns = deriveKeyColumns(tableName, rowDescriptor, connection.getMetaData()); + table = requireSingleTable(rowDescriptor, quoteStrategy); + keyColumns = deriveKeyColumns(table, rowDescriptor, connection.getMetaData()); this.rsListener = rsListener; gdsHelper = connection.getGDSHelper(); @@ -129,8 +129,8 @@ private FBField createFieldUnchecked(FieldDescriptor fieldDescriptor, boolean ca */ private static ObjectReference requireSingleTable(RowDescriptor rowDescriptor, QuoteStrategy quoteStrategy) throws SQLException { - // find the tableName (there can be only one tableName per updatable result set) - ObjectReference tableName = null; + // find the table (there can be only one table per updatable result set) + ObjectReference table = null; for (FieldDescriptor fieldDescriptor : rowDescriptor) { var currentTable = ObjectReference.ofTable(fieldDescriptor).orElse(null); if (currentTable == null) { @@ -138,16 +138,16 @@ private static ObjectReference requireSingleTable(RowDescriptor rowDescriptor, Q throw new FBResultSetNotUpdatableException( "Underlying result set has derived columns (without a relation)"); } - if (tableName == null) { - tableName = currentTable; - } else if (!tableName.equals(currentTable)) { + if (table == null) { + table = currentTable; + } else if (!table.equals(currentTable)) { // Different table => not updatable throw new FBResultSetNotUpdatableException( "Underlying result set references at least two relations: %s and %s." - .formatted(tableName.toString(quoteStrategy), currentTable.toString(quoteStrategy))); + .formatted(table.toString(quoteStrategy), currentTable.toString(quoteStrategy))); } } - return tableName; + return table; } private void notifyExecutionStarted() throws SQLException { @@ -340,7 +340,7 @@ private String buildUpdateStatement() { // TODO raise exception if there are no updated columns, or do nothing? var sb = new StringBuilder(EST_STATEMENT_SIZE + newRow.initializedCount() * EST_COLUMN_SIZE) .append("update "); - tableName.append(sb, quoteStrategy).append(" set "); + table.append(sb, quoteStrategy).append(" set "); boolean first = true; for (FieldDescriptor fieldDescriptor : rowDescriptor) { @@ -363,7 +363,7 @@ private String buildUpdateStatement() { private String buildDeleteStatement() { var sb = new StringBuilder(EST_STATEMENT_SIZE).append("delete from "); - tableName.append(sb, quoteStrategy).append('\n'); + table.append(sb, quoteStrategy).append('\n'); appendWhereClause(sb); return sb.toString(); @@ -393,7 +393,7 @@ private String buildInsertStatement() { // 25 = length of appended literals, 32 = guesstimate for (schema +) table var sb = new StringBuilder(25 + 32 + columns.length() + params.length()) .append("insert into "); - tableName.append(sb, quoteStrategy) + table.append(sb, quoteStrategy) .append(" (").append(columns).append(") values (").append(params).append(')'); return sb.toString(); @@ -421,7 +421,7 @@ private String buildSelectStatement() { // 32 = guesstimate for (schema +) table var sb = new StringBuilder(EST_STATEMENT_SIZE + columns.length() + 32) .append("select ").append(columns).append("\nfrom "); - tableName.append(sb, quoteStrategy).append('\n'); + table.append(sb, quoteStrategy).append('\n'); appendWhereClause(sb); return sb.toString(); } diff --git a/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java b/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java index 02fca5fd0..201f7a49b 100644 --- a/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java +++ b/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java @@ -24,6 +24,7 @@ * @author Mark Rotteveel * @since 4 */ +@SuppressWarnings("ClassCanBeRecord") final class GeneratedKeysQueryBuilder { // TODO Add caching for column info diff --git a/src/main/org/firebirdsql/jdbc/MetadataStatementHolder.java b/src/main/org/firebirdsql/jdbc/MetadataStatementHolder.java index 670675225..78ee72405 100644 --- a/src/main/org/firebirdsql/jdbc/MetadataStatementHolder.java +++ b/src/main/org/firebirdsql/jdbc/MetadataStatementHolder.java @@ -19,6 +19,7 @@ * but does not commit (not even in auto-commit). *

        * + * @author Mark Rotteveel * @since 7 */ @InternalApi diff --git a/src/main/org/firebirdsql/jdbc/SchemaChanger.java b/src/main/org/firebirdsql/jdbc/SchemaChanger.java index e507e1a3b..d5ce9135b 100644 --- a/src/main/org/firebirdsql/jdbc/SchemaChanger.java +++ b/src/main/org/firebirdsql/jdbc/SchemaChanger.java @@ -49,9 +49,12 @@ sealed abstract class SchemaChanger { /** * Sets the search path, overriding any previously set current schema or search path. * - * @param searchPath new search path to set (non-{@code null} and not blank, comma-separate, and quoted if needed) - * @throws java.sql.SQLFeatureNotSupportedException if schemas are not supported (Firebird 5.0 and older) - * @throws SQLException for database access errors, or if {@code searchPath} is {@code null} or blank + * @param searchPath + * new search path to set (non-{@code null} and not blank, comma-separate, and quoted if needed) + * @throws java.sql.SQLFeatureNotSupportedException + * if schemas are not supported (Firebird 5.0 and older) + * @throws SQLException + * for database access errors, or if {@code searchPath} is {@code null} or blank * @see #setSchema(String) */ abstract void setSearchPath(String searchPath) throws SQLException; diff --git a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java index 044d2c3a0..311501db7 100644 --- a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java +++ b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java @@ -103,8 +103,7 @@ select row_number() over() as PRIO, NAME as SCHEMA_NAME on sp.RDB$SCHEMA_NAME = s.SCHEMA_NAME where sp.RDB$PACKAGE_NAME = ? and sp.RDB$PROCEDURE_NAME = ? order by 1 - fetch first row only - """; + fetch first row only"""; /** * Find a procedure on the search path, by package (optional) and name. @@ -125,8 +124,7 @@ select row_number() over() as PRIO, NAME as SCHEMA_NAME on sp.RDB$SCHEMA_NAME = s.SCHEMA_NAME where sp.RDB$PACKAGE_NAME is not distinct from nullif(?, '') and sp.RDB$PROCEDURE_NAME = ? order by 1 - fetch first row only - """; + fetch first row only"""; private static final System.Logger LOG = System.getLogger(SchemaAwareStoredProcedureMetaData.class.getName()); diff --git a/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java b/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java index ef7651e3d..bbedcaba7 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java +++ b/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.jdbc.metadata; -import org.firebirdsql.jdbc.QuoteStrategy; +import org.firebirdsql.jaybird.util.ObjectReference; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -21,12 +21,6 @@ private NameHelper() { // no instances } - // TODO Remove once all metadata methods have been rewritten to support schemas - @Deprecated - static String toSpecificName(@Nullable String catalog, String routineName) { - return toSpecificName(catalog, null, routineName); - } - /** * Generates a name for the {@code SPECIFIC_NAME} column of {@code getFunctions}, {@code getFunctionColumns}, * {@code getProcedures} and {@code getProcedureColumns}. @@ -62,27 +56,17 @@ static String toSpecificName(@Nullable String catalog, String routineName) { * @param routineName * name of the routine (procedure or function) * @return specific name + * @since 7 */ static String toSpecificName(@Nullable String catalog, @Nullable String schema, String routineName) { if (isNullOrEmpty(catalog) && isNullOrEmpty(schema)) { + // TODO Add schema support: consider quoting always for consistency return routineName; - } - var quoteStrategy = QuoteStrategy.DIALECT_3; - // 8: 6 quotes + 2 separators - var sb = new StringBuilder(length(catalog) + length(schema) + routineName.length() + 8); - if (!isNullOrEmpty(schema)) { - quoteStrategy.appendQuoted(schema, sb).append('.'); + } else if (isNullOrEmpty(catalog)) { + return ObjectReference.of(schema, routineName).toString(); } // this order assumes the catalog actually represents the package name - if (!isNullOrEmpty(catalog)) { - quoteStrategy.appendQuoted(catalog, sb).append('.'); - } - quoteStrategy.appendQuoted(routineName, sb); - return sb.toString(); - } - - private static int length(@Nullable String value) { - return value != null ? value.length() : 0; + return ObjectReference.of(schema, catalog, routineName).toString(); } } diff --git a/src/main/org/firebirdsql/management/FBStatisticsManager.java b/src/main/org/firebirdsql/management/FBStatisticsManager.java index cf6c65d47..86451d7c1 100644 --- a/src/main/org/firebirdsql/management/FBStatisticsManager.java +++ b/src/main/org/firebirdsql/management/FBStatisticsManager.java @@ -108,7 +108,7 @@ public void getDatabaseStatistics(int options) throws SQLException { @Override public void getTableStatistics(List schemas, List tableNames) throws SQLException { - try (var service = attachServiceManager()) { + try (FbService service = attachServiceManager()) { ServiceRequestBuffer srb; GDSServerVersion serverVersion = service.getServerVersion(); if (serverVersion.isEqualOrAbove(3)) { @@ -117,6 +117,8 @@ public void getTableStatistics(List schemas, List tableNames) th schemas.forEach(schema -> srb.addArgument(isc_spb_sts_schema, schema)); } tableNames.forEach(tableName -> srb.addArgument(isc_spb_sts_table, tableName)); + } else if (tableNames.isEmpty()) { + srb = createStatsSRB(service, 0); } else { srb = createStatsSRB(service, isc_spb_sts_table); srb.addArgument(SpbItems.isc_spb_command_line, String.join(" ", tableNames)); diff --git a/src/main/org/firebirdsql/management/StatisticsManager.java b/src/main/org/firebirdsql/management/StatisticsManager.java index f6951b422..96b05f9d6 100644 --- a/src/main/org/firebirdsql/management/StatisticsManager.java +++ b/src/main/org/firebirdsql/management/StatisticsManager.java @@ -161,15 +161,14 @@ default void getTableStatistics(List tableNames) throws SQLException { *

        * Invoking this method is equivalent to the behaviour of * {@code gstat -a [ -sch ]... [ -t

    ]...} on the commandline. For — unsupported — - * Firebird 2.5 and older, it's equivalent to {@code gstat -t
    [
    ... ]}. + * Firebird 2.5 and older, it's equivalent to {@code gstat -t
    [
    ... ]} or {@code gstat -a} + * when no tables are specified. *

    * * @param schemaNames * schemas to analyze; if empty, all schemas are analyzed (ignored on Firebird 5.0 or older) * @param tableNames - * table names to analyze; if empty, all tables (restricted by {@code schemaNames}) are analyzed (on — - * unsupported — Firebird 2.5 and older, this will result in an error as it will require at least one - * table name) + * table names to analyze; if empty, all tables (restricted by {@code schemaNames}) are analyzed * @throws SQLException * if something went wrong (in current Firebird versions this includes when any of the tables cannot be * found) diff --git a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java index 602fd3161..779955337 100644 --- a/src/main/org/firebirdsql/util/FirebirdSupportInfo.java +++ b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java @@ -33,9 +33,9 @@ *

    * * @author Mark Rotteveel - * @since 3.0 + * @since 3 */ -@SuppressWarnings("unused") +@SuppressWarnings({ "unused", "ClassCanBeRecord" }) public final class FirebirdSupportInfo { private static final int SUPPORTED_MIN_VERSION = 3; diff --git a/src/test/org/firebirdsql/common/DdlHelper.java b/src/test/org/firebirdsql/common/DdlHelper.java index 8c827682f..d3a0aa4ad 100644 --- a/src/test/org/firebirdsql/common/DdlHelper.java +++ b/src/test/org/firebirdsql/common/DdlHelper.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2012-2022 Mark Rotteveel +// SPDX-FileCopyrightText: Copyright 2012-2025 Mark Rotteveel // SPDX-License-Identifier: LGPL-2.1-or-later package org.firebirdsql.common; @@ -8,6 +8,7 @@ import java.sql.SQLException; import java.sql.Statement; import java.util.Arrays; +import java.util.Collection; import static org.firebirdsql.util.FirebirdSupportInfo.supportInfoFor; @@ -16,6 +17,7 @@ * * @author Mark Rotteveel */ +@SuppressWarnings("SqlSourceToSinkFlow") public final class DdlHelper { private DdlHelper() { @@ -25,22 +27,37 @@ private DdlHelper() { * Helper method for executing CREATE TABLE, ignoring {@code isc_no_meta_update} errors. * * @param connection - * Connection to execute statement + * connection to execute {@code sql} * @param sql - * Create table statement + * create table statement * @throws SQLException * SQLException for executing statement, except if the error is {@code isc_no_meta_update} * @see #executeDDL(java.sql.Connection, String, int...) */ public static void executeCreateTable(final Connection connection, final String sql) throws SQLException { - DdlHelper.executeDDL(connection, sql, ISCConstants.isc_no_meta_update); + executeDDL(connection, sql, ISCConstants.isc_no_meta_update); + } + + /** + * Helper method for executing CREATE TABLE, ignoring {@code isc_no_meta_update} errors. + * + * @param statement + * statement to execute {@code sql} + * @param sql + * create table statement + * @throws SQLException + * SQLException for executing statement, except if the error is {@code isc_no_meta_update} + * @see #executeDDL(java.sql.Connection, String, int...) + */ + public static void executeCreateTable(final Statement statement, final String sql) throws SQLException { + executeDDL(statement, sql, ISCConstants.isc_no_meta_update); } /** * Helper method for executing DDL (or technically: any statement), ignoring the specified list of error codes. * * @param connection - * Connection to execute statement + * connection to execute {@code sql} * @param sql * DDL statement * @param ignoreErrors @@ -52,12 +69,81 @@ public static void executeCreateTable(final Connection connection, final String */ public static void executeDDL(final Connection connection, final String sql, final int... ignoreErrors) throws SQLException { + try (Statement stmt = connection.createStatement()) { + executeDDL(stmt, sql, ignoreErrors); + } + } + + /** + * Helper method for executing DDL (or technically: any statement), ignoring the specified list of error codes. + * + * @param connection + * connection to execute {@code sql} + * @param sql + * DDL statements + * @param ignoreErrors + * Firebird error codes to ignore + * @throws SQLException + * SQLException for executing statement, except for errors with the error code listed in + * {@code ignoreErrors} + * @see org.firebirdsql.gds.ISCConstants + */ + public static void executeDDL(final Connection connection, final Collection sql, final int... ignoreErrors) + throws SQLException { + try (Statement stmt = connection.createStatement()) { + executeDDL(stmt, sql, ignoreErrors); + } + } + + /** + * Helper method for executing DDL (or technically: any statement), ignoring the specified list of error codes. + * + * @param statement + * statement to execute {@code sql} + * @param sql + * DDL statement + * @param ignoreErrors + * Firebird error codes to ignore + * @throws SQLException + * SQLException for executing statement, except for errors with the error code listed in + * {@code ignoreErrors} + * @see org.firebirdsql.gds.ISCConstants + */ + public static void executeDDL(final Statement statement, final String sql, final int... ignoreErrors) + throws SQLException { if (ignoreErrors != null) { Arrays.sort(ignoreErrors); } + executeDDL0(statement, sql, ignoreErrors); + } - try (Statement stmt = connection.createStatement()) { - stmt.execute(sql); + /** + * Helper method for executing DDL (or technically: any statement), ignoring the specified list of error codes. + * + * @param statement + * statement to execute {@code sql} + * @param sql + * DDL statements + * @param ignoreErrors + * Firebird error codes to ignore + * @throws SQLException + * SQLException for executing statement, except for errors with the error code listed in + * {@code ignoreErrors} + * @see org.firebirdsql.gds.ISCConstants + */ + public static void executeDDL(final Statement statement, final Collection sql, final int... ignoreErrors) + throws SQLException { + if (ignoreErrors != null) { + Arrays.sort(ignoreErrors); + } + for (String currentSql : sql) { + executeDDL0(statement, currentSql, ignoreErrors); + } + } + + private static void executeDDL0(Statement statement, String sql, int[] ignoreErrors) throws SQLException { + try { + statement.execute(sql); } catch (SQLException ex) { if (ignoreErrors == null || ignoreErrors.length == 0) throw ex; @@ -86,7 +172,7 @@ public static void executeDDL(final Connection connection, final String sql, fin *

    * * @param connection - * Connection to execute statement + * connection to execute {@code sql} * @param sql * Drop table statement * @throws SQLException @@ -94,7 +180,31 @@ public static void executeDDL(final Connection connection, final String sql, fin * @see #executeDDL(java.sql.Connection, String, int...) */ public static void executeDropTable(final Connection connection, final String sql) throws SQLException { - executeDDL(connection, sql, DdlHelper.getDropIgnoreErrors(connection)); + executeDDL(connection, sql, getDropIgnoreErrors(connection)); + } + + /** + * Helper method for executing DROP TABLE, ignoring errors if the table or view doesn't exist. + *

    + * Ignored errors are: + *

      + *
    • {@code isc_no_meta_update}
    • + *
    • {@code isc_dsql_table_not_found}
    • + *
    • {@code isc_dsql_view_not_found}
    • + *
    • {@code isc_dsql_error} (Firebird 1.5 or earlier only)
    • + *
    + *

    + * + * @param statement + * statement to execute {@code sql} + * @param sql + * drop table statement + * @throws SQLException + * SQLException for executing statement, except for the listed errors. + * @see #executeDDL(java.sql.Statement, String, int...) + */ + public static void executeDropTable(final Statement statement, final String sql) throws SQLException { + executeDDL(statement, sql, getDropIgnoreErrors(statement.getConnection())); } private static int[] getDropIgnoreErrors(final Connection connection) { diff --git a/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java b/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java index 8124f0f57..d2fa3c897 100644 --- a/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java +++ b/src/test/org/firebirdsql/jdbc/FBConnectionSchemaTest.java @@ -4,7 +4,6 @@ import org.firebirdsql.common.extension.UsesDatabaseExtension; import org.firebirdsql.common.extension.UsesDatabaseExtension.UsesDatabaseForAll; -import org.firebirdsql.common.matchers.SQLExceptionMatchers; import org.firebirdsql.jaybird.props.PropertyNames; import org.firebirdsql.jaybird.util.SearchPathHelper; import org.junit.jupiter.api.Test; From 13e38c3488761342cb6226da3c1d8408f59f829f Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 3 Nov 2025 11:01:01 +0100 Subject: [PATCH 59/64] Use Collection instead of List, use common code path --- .../management/FBStatisticsManager.java | 25 ++++++++----------- .../management/StatisticsManager.java | 15 +++++------ 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/main/org/firebirdsql/management/FBStatisticsManager.java b/src/main/org/firebirdsql/management/FBStatisticsManager.java index 86451d7c1..7119fb093 100644 --- a/src/main/org/firebirdsql/management/FBStatisticsManager.java +++ b/src/main/org/firebirdsql/management/FBStatisticsManager.java @@ -20,15 +20,15 @@ import java.sql.Connection; import java.sql.SQLException; -import java.util.List; +import java.util.Collection; import static org.firebirdsql.gds.ISCConstants.*; import static org.firebirdsql.gds.VaxEncoding.iscVaxInteger2; import static org.firebirdsql.gds.VaxEncoding.iscVaxLong; /** - * The FBStatisticsManager class is responsible for replicating the functionality of - * the gstat command-line tool. + * The {@code FBStatisticsManager} class is responsible for replicating the functionality of + * the {@code gstat} command-line tool. *

    * This functionality includes: *

      @@ -49,16 +49,13 @@ public class FBStatisticsManager extends FBServiceManager implements StatisticsM RECORD_VERSION_STATISTICS; /** - * Create a new instance of FBMaintenanceManager based on - * the default GDSType. + * Create a new instance of {@code FBMaintenanceManager} based on the default GDSType. */ public FBStatisticsManager() { - super(); } /** - * Create a new instance of FBMaintenanceManager based on - * a given GDSType. + * Create a new instance of {@code FBMaintenanceManager} based on a given GDSType. * * @param gdsType * type must be PURE_JAVA, EMBEDDED, or NATIVE @@ -69,8 +66,7 @@ public FBStatisticsManager(String gdsType) { } /** - * Create a new instance of FBMaintenanceManager based on - * a given GDSType. + * Create a new instance of {@code FBMaintenanceManager} based on a given GDSType. * * @param gdsType * The GDS implementation type to use @@ -107,19 +103,18 @@ public void getDatabaseStatistics(int options) throws SQLException { } @Override - public void getTableStatistics(List schemas, List tableNames) throws SQLException { + public void getTableStatistics(Collection schemas, Collection tableNames) throws SQLException { try (FbService service = attachServiceManager()) { ServiceRequestBuffer srb; GDSServerVersion serverVersion = service.getServerVersion(); - if (serverVersion.isEqualOrAbove(3)) { - srb = createStatsSRB(service, 0); + if (serverVersion.isEqualOrAbove(3) || tableNames.isEmpty()) { + srb = createDefaultStatsSRB(service); if (serverVersion.isEqualOrAbove(6)) { schemas.forEach(schema -> srb.addArgument(isc_spb_sts_schema, schema)); } tableNames.forEach(tableName -> srb.addArgument(isc_spb_sts_table, tableName)); - } else if (tableNames.isEmpty()) { - srb = createStatsSRB(service, 0); } else { + // Handling of table list is different on older (unsupported) Firebird versions srb = createStatsSRB(service, isc_spb_sts_table); srb.addArgument(SpbItems.isc_spb_command_line, String.join(" ", tableNames)); } diff --git a/src/main/org/firebirdsql/management/StatisticsManager.java b/src/main/org/firebirdsql/management/StatisticsManager.java index 96b05f9d6..11e04b0c0 100644 --- a/src/main/org/firebirdsql/management/StatisticsManager.java +++ b/src/main/org/firebirdsql/management/StatisticsManager.java @@ -8,6 +8,7 @@ import java.sql.SQLException; import java.util.Arrays; +import java.util.Collection; import java.util.List; /** @@ -114,15 +115,15 @@ public interface StatisticsManager extends ServiceManager { /** * Get the table statistics. *

      - * For a more detailed description, see {@link #getTableStatistics(List, List)}. + * For a more detailed description, see {@link #getTableStatistics(Collection, Collection)}. *

      * * @param tableNames * table names to analyze * @throws SQLException * if something went wrong - * @see #getTableStatistics(List) - * @see #getTableStatistics(List, List) + * @see #getTableStatistics(Collection) + * @see #getTableStatistics(Collection, Collection) */ default void getTableStatistics(String... tableNames) throws SQLException { getTableStatistics(Arrays.asList(tableNames)); @@ -131,17 +132,17 @@ default void getTableStatistics(String... tableNames) throws SQLException { /** * Get the table statistics. *

      - * For a more detailed description, see {@link #getTableStatistics(List, List)}. + * For a more detailed description, see {@link #getTableStatistics(Collection, Collection)}. *

      * * @param tableNames * table names to analyze * @throws SQLException * if something went wrong - * @see #getTableStatistics(List, List) + * @see #getTableStatistics(Collection, Collection) * @since 7 */ - default void getTableStatistics(List tableNames) throws SQLException { + default void getTableStatistics(Collection tableNames) throws SQLException { getTableStatistics(List.of(), tableNames); } @@ -174,7 +175,7 @@ default void getTableStatistics(List tableNames) throws SQLException { * found) * @since 7 */ - void getTableStatistics(List schemaNames, List tableNames) throws SQLException; + void getTableStatistics(Collection schemaNames, Collection tableNames) throws SQLException; /** * Get transaction information of the database specified in {@code database}. From 4a83decdf6e248c530a7b1349dd47a89e8fd1edf Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 3 Nov 2025 11:26:17 +0100 Subject: [PATCH 60/64] Simplify version string creation in GDSServerVersion --- .../gds/impl/GDSServerVersion.java | 35 ++++--------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/src/main/org/firebirdsql/gds/impl/GDSServerVersion.java b/src/main/org/firebirdsql/gds/impl/GDSServerVersion.java index b64596fe5..50cf19f1a 100644 --- a/src/main/org/firebirdsql/gds/impl/GDSServerVersion.java +++ b/src/main/org/firebirdsql/gds/impl/GDSServerVersion.java @@ -126,22 +126,11 @@ public List getRawVersions() { } public @Nullable String getExtendedServerName() { - if (rawVersions.length < 2) { - return null; - } else if (rawVersions.length == 2) { - return rawVersions[1]; - } else { - // Reserve additional space for connection information, etc. We could be more precise by summing the length - // of each version string from index 1 in the array, but this is good enough - var sb = new StringBuilder((rawVersions[1].length() + 50) * (rawVersions.length - 1)); - for (int idx = 1; idx < rawVersions.length; idx++) { - if (idx > 1) { - sb.append(','); - } - sb.append(rawVersions[idx]); - } - return sb.toString(); - } + return switch (rawVersions.length) { + case 0, 1 -> null; + case 2 -> rawVersions[1]; + default -> String.join(",", Arrays.asList(rawVersions).subList(1, rawVersions.length)); + }; } public String getFullVersion() { @@ -194,19 +183,7 @@ public boolean equals(Object obj) { } public String toString() { - if (rawVersions.length == 1) { - return rawVersions[0]; - } - // Reserve additional space for connection information, etc. We could be more precise by summing the length of - // each version string in the array, but this is good enough - var sb = new StringBuilder((rawVersions[0].length() + 50) * rawVersions.length); - int idx = 0; - sb.append(rawVersions[idx++]); - do { - sb.append(','); - sb.append(rawVersions[idx++]); - } while (idx < rawVersions.length); - return sb.toString(); + return rawVersions.length == 1 ? rawVersions[0] : String.join(",", rawVersions); } /** From ec1056e480ad5b49990ebdb070d24683860a8dd6 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Mon, 3 Nov 2025 12:59:10 +0100 Subject: [PATCH 61/64] SPECIFIC_NAME should not include the schema --- .../firebirdsql/jdbc/FBDatabaseMetaData.java | 5 +-- .../jdbc/metadata/GetFunctionColumns.java | 2 +- .../jdbc/metadata/GetFunctions.java | 2 +- .../jdbc/metadata/GetProcedureColumns.java | 2 +- .../jdbc/metadata/GetProcedures.java | 2 +- .../firebirdsql/jdbc/metadata/NameHelper.java | 41 ++++++------------- ...FBDatabaseMetaDataFunctionColumnsTest.java | 10 ++--- .../jdbc/FBDatabaseMetaDataFunctionsTest.java | 13 +++--- ...BDatabaseMetaDataProcedureColumnsTest.java | 11 ++--- .../FBDatabaseMetaDataProceduresTest.java | 17 +++----- .../jdbc/metadata/NameHelperTest.java | 19 ++++----- 11 files changed, 44 insertions(+), 80 deletions(-) diff --git a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index ee7d962c5..b10352464 100644 --- a/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java @@ -1211,9 +1211,8 @@ public boolean dataDefinitionIgnoredInTransactions() throws SQLException { *
    • Column {@code PROCEDURE_CAT} for normal procedures is empty string ({@code ""}) instead of {@code null}, * for packaged procedures it is the package name
    • *
    • Column {@code SPECIFIC_NAME} for packaged procedures will report - * {@code [.].} (on Firebird 5.0 and older, normal - * procedures will report the same as column {@code PROCEDURE_NAME}, the unquoted name, on Firebird 6.0 and higher, - * {@code .})
    • + * {@code .} (normal procedures will report the same as column + * {@code PROCEDURE_NAME}, the unquoted name) *
    */ @Override diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java index 88ec1425e..e1b1c055e 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java @@ -114,7 +114,7 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .at(13).setInt(typeMetadata.getCharOctetLength()) .at(14).setInt(ordinalPosition) .at(15).setString(nullable ? "YES" : "NO") - .at(16).setString(toSpecificName(catalog, schema, functionName)) + .at(16).setString(toSpecificName(catalog, functionName)) .toRowValue(false); } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java b/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java index 0d14ef598..8da4cef1d 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java @@ -81,7 +81,7 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .at(2).setString(functionName) .at(3).setString(rs.getString("REMARKS")) .at(4).setShort(functionNoTable) - .at(5).setString(toSpecificName(catalog, schema, functionName)) + .at(5).setString(toSpecificName(catalog, functionName)) .at(6).setString(rs.getString("JB_FUNCTION_SOURCE")) .at(7).setString(rs.getString("JB_FUNCTION_KIND")) .at(8).setString(rs.getString("JB_MODULE_NAME")) diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java index 3567fbf4e..d8bcebdb5 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java @@ -127,7 +127,7 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .at(17).setInt(rs.getInt("PARAMETER_NUMBER")) // TODO: Find out if there is a conceptual difference with NULLABLE (idx 11) .at(18).setString(nullFlag == 1 ? "NO" : "YES") - .at(19).setString(toSpecificName(catalog, schema, procedureName)) + .at(19).setString(toSpecificName(catalog, procedureName)) .toRowValue(false); } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java index 1e47ff49f..f12ac2ae1 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetProcedures.java @@ -81,7 +81,7 @@ final RowValue createMetadataRow(ResultSet rs, RowValueBuilder valueBuilder) thr .at(2).setString(procedureName) .at(6).setString(rs.getString("REMARKS")) .at(7).setShort(rs.getShort("PROCEDURE_TYPE") == 0 ? procedureNoResult : procedureReturnsResult) - .at(8).setString(toSpecificName(catalog, schema, procedureName)) + .at(8).setString(toSpecificName(catalog, procedureName)) .at(9).setShort(rs.getShort("JB_PROCEDURE_TYPE")) .at(10).setString(rs.getString("JB_PROCEDURE_SOURCE")) .toRowValue(true); diff --git a/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java b/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java index bbedcaba7..d2dd82c1a 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java +++ b/src/main/org/firebirdsql/jdbc/metadata/NameHelper.java @@ -25,48 +25,31 @@ private NameHelper() { * Generates a name for the {@code SPECIFIC_NAME} column of {@code getFunctions}, {@code getFunctionColumns}, * {@code getProcedures} and {@code getProcedureColumns}. *

    + * According to the JDBC API documentation, the specific name uniquely identifies this [routine] + * within its schema. In other words, the schema itself is not part of the specific name. Its intention is to + * disambiguate overloads (which Firebird doesn't have). ISO 9075-11 (Schemata) only says it is the qualified + * identifier of the routine. Be aware that the name we generate for a packaged routine does not comply with these + * definitions. + *

    + *

    * The specific name is generated as follows: *

    *
      - *
    • - *

      For Firebird versions without schema support

      - *
        - *
      • for non-packaged routines, the {@code routineName}
      • - *
      • for packaged routines, both {@code catalog} (package name) and {@code routineName} are transformed to - * quoted identifiers and separated by {@code .} (period)
      • - *
      - *
    • - *
    • - *

      For Firebird versions with schema support

      - *
        - *
      • for non-packaged routines, both {@code schema} and {@code routineName} are transformed to - * quoted identifiers and separated by {@code .} (period)
      • - *
      • for packaged routines, {@code catalog} (package name), {@code schema} and {@code routineName} are - * transformed to quoted identifiers and separated by {@code .} (period)
      • - *
      - *
    • + *
    • for non-packaged routines, {@code routineName} is returned as-is
    • + *
    • for packaged routines, {@code catalog} (package name) and {@code routineName} are transformed to quoted + * identifiers and separated by {@code .} (period)
    • *
    * * @param catalog * generally {@code null}, or — when {@code useCatalogAsPackage = true} — an empty string (no * package) or a package name - * @param schema - * schema name, or {@code null} for Firebird versions without schema support, empty string is handled same - * as {@code null} * @param routineName * name of the routine (procedure or function) * @return specific name * @since 7 */ - static String toSpecificName(@Nullable String catalog, @Nullable String schema, String routineName) { - if (isNullOrEmpty(catalog) && isNullOrEmpty(schema)) { - // TODO Add schema support: consider quoting always for consistency - return routineName; - } else if (isNullOrEmpty(catalog)) { - return ObjectReference.of(schema, routineName).toString(); - } - // this order assumes the catalog actually represents the package name - return ObjectReference.of(schema, catalog, routineName).toString(); + static String toSpecificName(@Nullable String catalog, String routineName) { + return isNullOrEmpty(catalog) ? routineName : ObjectReference.of(catalog, routineName).toString(); } } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java index 48e0c6d19..873cb22d4 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java @@ -374,7 +374,7 @@ void testFunctionColumnMetaData_useCatalogAsPackage_specificPackageProcedureColu try (var connection = DriverManager.getConnection(getUrl(), props)) { dbmd = connection.getMetaData(); List> expectedColumns = withCatalog("WITH$FUNCTION", withSpecificName( - ObjectReference.of(ifSchemaElse("PUBLIC", ""), "WITH$FUNCTION", "IN$PACKAGE").toString(), + ObjectReference.of("WITH$FUNCTION", "IN$PACKAGE").toString(), List.of(createNumericalType(Types.INTEGER, "IN$PACKAGE", "PARAM1", 1, 10, 0, true)))); validateExpectedFunctionColumns(catalog, "IN$PACKAGE", "PARAM1", expectedColumns); } @@ -536,7 +536,7 @@ private static List> getUdfExample2Columns() private static List> getWithFunctionInPackageColumns() { return withCatalog("WITH$FUNCTION", - withSpecificName(ifSchemaElse("\"PUBLIC\".", "") + "\"WITH$FUNCTION\".\"IN$PACKAGE\"", + withSpecificName("\"WITH$FUNCTION\".\"IN$PACKAGE\"", List.of( withColumnTypeFunctionReturn( createNumericalType(Types.INTEGER, "IN$PACKAGE", "PARAM_0", 0, 10, 0, true)), @@ -569,8 +569,7 @@ private static List> withSpecificName(String private static List> withSchema(String schema, List> rules) { for (Map rowRule : rules) { - String functionName = (String) rowRule.get(FunctionColumnMetaData.FUNCTION_NAME); - rowRule.put(FunctionColumnMetaData.SPECIFIC_NAME, ObjectReference.of(schema, functionName).toString()); + rowRule.put(FunctionColumnMetaData.SPECIFIC_NAME, rowRule.get(FunctionColumnMetaData.FUNCTION_NAME)); rowRule.put(FunctionColumnMetaData.FUNCTION_SCHEM, schema); } return rules; @@ -580,8 +579,7 @@ private static Map createColumn(String functionN int ordinalPosition, boolean nullable) { Map rules = getDefaultValidationRules(); rules.put(FunctionColumnMetaData.FUNCTION_NAME, functionName); - rules.put(FunctionColumnMetaData.SPECIFIC_NAME, ifSchemaElse( - ObjectReference.of("PUBLIC", functionName).toString(), functionName)); + rules.put(FunctionColumnMetaData.SPECIFIC_NAME, functionName); rules.put(FunctionColumnMetaData.COLUMN_NAME, columnName); rules.put(FunctionColumnMetaData.ORDINAL_POSITION, ordinalPosition); if (nullable) { diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java index a5c197d91..e4ffd7520 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java @@ -340,8 +340,7 @@ private static Map getPsqlExample(boolean useCatalogAs rules.put(FunctionMetaData.FUNCTION_CAT, ""); } rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse( - ObjectReference.of("PUBLIC", "PSQL$EXAMPLE").toString(), "PSQL$EXAMPLE")); + rules.put(FunctionMetaData.SPECIFIC_NAME, "PSQL$EXAMPLE"); if (supportsComments) { rules.put(FunctionMetaData.REMARKS, "Comment on PSQL$EXAMPLE"); } @@ -364,7 +363,7 @@ private static Map getOtherSchemaPsqlExample(boolean u } rules.put(FunctionMetaData.FUNCTION_SCHEM, "OTHER_SCHEMA"); rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, ObjectReference.of("OTHER_SCHEMA", "PSQL$EXAMPLE").toString()); + rules.put(FunctionMetaData.SPECIFIC_NAME, "PSQL$EXAMPLE"); rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, """ begin return cast(x as varchar(50)); @@ -384,7 +383,7 @@ private static Map getOtherSchemaPsqlExample2(boolean } rules.put(FunctionMetaData.FUNCTION_SCHEM, "OTHER_SCHEMA"); rules.put(FunctionMetaData.FUNCTION_NAME, "PSQL$EXAMPLE2"); - rules.put(FunctionMetaData.SPECIFIC_NAME, ObjectReference.of("OTHER_SCHEMA", "PSQL$EXAMPLE2").toString()); + rules.put(FunctionMetaData.SPECIFIC_NAME, "PSQL$EXAMPLE2"); rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, """ begin return X+1; @@ -404,8 +403,7 @@ private static Map getUdfExample(boolean useCatalogAsP rules.put(FunctionMetaData.FUNCTION_CAT, ""); } rules.put(FunctionMetaData.FUNCTION_NAME, "UDF$EXAMPLE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse( - ObjectReference.of("PUBLIC", "UDF$EXAMPLE").toString(), "UDF$EXAMPLE")); + rules.put(FunctionMetaData.SPECIFIC_NAME, "UDF$EXAMPLE"); if (supportsComments) { rules.put(FunctionMetaData.REMARKS, "Comment on UDF$EXAMPLE"); } @@ -419,8 +417,7 @@ private static Map getPackageFunctionExample() { Map rules = getDefaultValidationRules(); rules.put(FunctionMetaData.FUNCTION_CAT, "WITH$FUNCTION"); rules.put(FunctionMetaData.FUNCTION_NAME, "IN$PACKAGE"); - rules.put(FunctionMetaData.SPECIFIC_NAME, - ObjectReference.of(ifSchemaElse("PUBLIC", ""), "WITH$FUNCTION", "IN$PACKAGE").toString()); + rules.put(FunctionMetaData.SPECIFIC_NAME, ObjectReference.of("WITH$FUNCTION", "IN$PACKAGE").toString()); // Stored with package rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, null); rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java index 3d8274491..596126c23 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java @@ -363,7 +363,7 @@ void testProcedureColumns_useCatalogAsPackage_specificPackageProcedureColumn(Str List> expectedColumns = withCatalog("WITH$PROCEDURE", withSpecificName( - ObjectReference.of(ifSchemaElse("PUBLIC", ""), "WITH$PROCEDURE", "IN$PACKAGE").toString(), + ObjectReference.of("WITH$PROCEDURE", "IN$PACKAGE").toString(), List.of(createNumericalType(Types.INTEGER, "IN$PACKAGE", "RETURN1", 1, 10, 0, true, DatabaseMetaData.procedureColumnOut)))); @@ -397,7 +397,7 @@ void testProcedureColumns_useCatalogAsPackage_nonPackagedOnly() throws Exception private static List> getInPackage_allColumns() { return withCatalog("WITH$PROCEDURE", withSpecificName( - ObjectReference.of(ifSchemaElse("PUBLIC", ""), "WITH$PROCEDURE", "IN$PACKAGE").toString(), + ObjectReference.of("WITH$PROCEDURE", "IN$PACKAGE").toString(), // TODO Having result columns first might be against JDBC spec // TODO Describing result columns as procedureColumnOut might be against JDBC spec List.of( @@ -465,7 +465,7 @@ private static Map createColumn(String schema, Map rules = getDefaultValueValidationRules(); rules.put(ProcedureColumnMetaData.PROCEDURE_SCHEM, schema); rules.put(ProcedureColumnMetaData.PROCEDURE_NAME, procedureName); - rules.put(ProcedureColumnMetaData.SPECIFIC_NAME, getProcedureSpecificName(schema, procedureName)); + rules.put(ProcedureColumnMetaData.SPECIFIC_NAME, procedureName); rules.put(ProcedureColumnMetaData.COLUMN_NAME, columnName); rules.put(ProcedureColumnMetaData.ORDINAL_POSITION, ordinalPosition); rules.put(ProcedureColumnMetaData.COLUMN_TYPE, columnType); @@ -476,11 +476,6 @@ private static Map createColumn(String schema, return rules; } - private static String getProcedureSpecificName(String schema, String procedureName) { - if (schema == null || schema.isEmpty()) return procedureName; - return ObjectReference.of(schema, procedureName).toString(); - } - @SuppressWarnings("SameParameterValue") private static Map createStringType(int jdbcType, String procedureName, String columnName, int ordinalPosition, int length, boolean nullable, int columnType) { diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java index b812147eb..29f57efad 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java @@ -345,8 +345,7 @@ static boolean isIgnoredProcedure(String specificName) { final class Ignored { // Skipping procedures from system packages (when testing with useCatalogAsPackage=true) private static final List PREFIXES_TO_IGNORE = - List.of("\"SYSTEM\".\"RDB$", "\"RDB$BLOB_UTIL\".", "\"RDB$PROFILER\".", "\"RDB$TIME_ZONE_UTIL\".", - "\"RDB$SQL\"."); + List.of("\"RDB$BLOB_UTIL\".", "\"RDB$PROFILER\".", "\"RDB$TIME_ZONE_UTIL\".", "\"RDB$SQL\"."); } return Ignored.PREFIXES_TO_IGNORE.stream().anyMatch(specificName::startsWith); } @@ -439,8 +438,7 @@ Map getSpecificValidationRules(Map getSpecificValidationRules(Map getSpecificValidationRules(Map getSpecificValidationRules(Map getSpecificValidationRules(Map, , ROUTINE, ROUTINE - , PUBLIC, ROUTINE, "PUBLIC"."ROUTINE" - PACKAGE, , ROUTINE, "PACKAGE"."ROUTINE" - PACKAGE, PUBLIC, ROUTINE, "PUBLIC"."PACKAGE"."ROUTINE" - WITH"DOUBLE, , DOUBLE"QUOTE, "WITH""DOUBLE"."DOUBLE""QUOTE" - WITH"DOUBLE, PUBLIC, DOUBLE"QUOTE, "PUBLIC"."WITH""DOUBLE"."DOUBLE""QUOTE" - """, nullValues = "") - void testToSpecificName(String catalog, String schema, String routineName, String expectedResult) { - assertEquals(expectedResult, NameHelper.toSpecificName(catalog, schema, routineName)); + @CsvSource(useHeadersInDisplayName = true, nullValues = "", textBlock = """ + catalog, routineName, expectedSpecificName + , ROUTINE, ROUTINE + PACKAGE, ROUTINE, "PACKAGE"."ROUTINE" + WITH"DOUBLE, DOUBLE"QUOTE, "WITH""DOUBLE"."DOUBLE""QUOTE" + """) + void testToSpecificName(String catalog, String routineName, String expectedResult) { + assertEquals(expectedResult, NameHelper.toSpecificName(catalog, routineName)); } } \ No newline at end of file From 0c9efec7fd4e1f0fd8507c96a06e8ed14aa3da10 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 4 Nov 2025 10:47:17 +0100 Subject: [PATCH 62/64] Update error codes and messages from 6.0.0.1338 --- .../org/firebirdsql/gds/ISCConstants.java | 53 ++++++++++++++++++- .../firebird_0_error_msg.properties | 48 +++++++++++++---- .../firebird_0_sql_states.properties | 42 ++++++++++++--- .../firebird_12_error_msg.properties | 14 ++++- .../firebird_13_error_msg.properties | 8 ++- .../firebird_13_sql_states.properties | 8 ++- .../firebird_17_error_msg.properties | 11 +++- .../firebird_21_error_msg.properties | 13 +++-- .../firebird_25_error_msg.properties | 6 ++- .../firebird_7_error_msg.properties | 4 +- .../firebird_8_error_msg.properties | 15 +++++- .../firebird_8_sql_states.properties | 9 +++- .../jdbc/FBDatabaseMetaDataTest.java | 7 ++- 13 files changed, 203 insertions(+), 35 deletions(-) diff --git a/src/main/org/firebirdsql/gds/ISCConstants.java b/src/main/org/firebirdsql/gds/ISCConstants.java index 0ce4c1c17..319e2ce24 100644 --- a/src/main/org/firebirdsql/gds/ISCConstants.java +++ b/src/main/org/firebirdsql/gds/ISCConstants.java @@ -1675,6 +1675,36 @@ public interface ISCConstants { int isc_bad_par_workers = 335545286; int isc_idx_expr_not_found = 335545287; int isc_idx_cond_not_found = 335545288; + int isc_uninitialized_var = 335545289; + int isc_param_not_exist = 335545290; + int isc_param_no_default_not_specified = 335545291; + int isc_param_multiple_assignments = 335545292; + int isc_invalid_date_format = 335545293; + int isc_invalid_raw_string_in_date_format = 335545294; + int isc_invalid_data_type_for_date_format = 335545295; + int isc_incompatible_date_format_with_current_date_type = 335545296; + int isc_value_for_pattern_is_out_of_range = 335545297; + int isc_month_name_mismatch = 335545298; + int isc_incorrect_hours_period = 335545299; + int isc_data_for_format_is_exhausted = 335545300; + int isc_trailing_part_of_string = 335545301; + int isc_pattern_cant_be_used_without_other_pattern = 335545302; + int isc_pattern_cant_be_used_without_other_pattern_and_vice_versa = 335545303; + int isc_incompatible_format_patterns = 335545304; + int isc_only_one_pattern_can_be_used = 335545305; + int isc_can_not_use_same_pattern_twice = 335545306; + int isc_sysf_invalid_gen_uuid_version = 335545307; + int isc_sweep_unable_to_run = 335545308; + int isc_sweep_concurrent_instance = 335545309; + int isc_sweep_read_only = 335545310; + int isc_sweep_attach_no_cleanup = 335545311; + int isc_invalid_timezone_region_or_displacement = 335545312; + int isc_argmustbe_exact_range_for = 335545313; + int isc_range_for_by_should_be_positive = 335545314; + int isc_missing_value_for_format_pattern = 335545315; + int isc_invalid_name = 335545316; + int isc_invalid_unqualified_name_list = 335545317; + int isc_no_user_att_while_restore = 335545318; int isc_gfix_db_name = 335740929; int isc_gfix_invalid_sw = 335740930; int isc_gfix_version = 335740931; @@ -1946,6 +1976,19 @@ public interface ISCConstants { int isc_dyn_exc_not_exist = 336068915; int isc_dyn_gen_not_exist = 336068916; int isc_dyn_fld_not_exist = 336068917; + int isc_dyn_dup_trigger = 336068918; + int isc_dyn_dup_domain = 336068919; + int isc_dyn_dup_collation = 336068920; + int isc_dyn_dup_package = 336068921; + int isc_dyn_index_schema_must_match_table = 336068922; + int isc_dyn_trig_schema_must_match_table = 336068923; + int isc_dyn_dup_schema = 336068924; + int isc_dyn_cannot_mod_system_schema = 336068925; + int isc_dyn_cannot_drop_non_emptyschema = 336068926; + int isc_dyn_cannot_mod_obj_sys_schema = 336068927; + int isc_dyn_cannot_create_reserved_schema = 336068928; + int isc_dyn_cannot_infer_schema = 336068929; + int isc_dyn_dup_blob_filter = 336068930; int isc_gbak_unknown_switch = 336330753; int isc_gbak_page_size_missing = 336330754; int isc_gbak_page_size_toobig = 336330755; @@ -2071,6 +2114,8 @@ public interface ISCConstants { int isc_gbak_replica_req = 336331156; int isc_gbak_missing_prl_wrks = 336331159; int isc_gbak_inv_prl_wrks = 336331160; + int isc_gbak_plugin_schema_migration = 336331173; + int isc_gbak_plugin_schema_migration_err = 336331174; int isc_dsql_too_old_ods = 336397205; int isc_dsql_table_not_found = 336397206; int isc_dsql_view_not_found = 336397207; @@ -2201,10 +2246,16 @@ public interface ISCConstants { int isc_dsql_string_char_length = 336397332; int isc_dsql_max_nesting = 336397333; int isc_dsql_recreate_user_failed = 336397334; + int isc_dsql_table_value_many_columns = 336397335; + int isc_dsql_create_schema_failed = 336397336; + int isc_dsql_drop_schema_failed = 336397337; + int isc_dsql_recreate_schema_failed = 336397338; + int isc_dsql_alter_schema_failed = 336397339; + int isc_dsql_create_alter_schema_failed = 336397340; int isc_savepoint_error = 336527650; int isc_cache_non_zero_use_count = 336527661; int isc_unexpected_page_change = 336527662; - int isc_rdb$triggers_rdb$flags_corrupt = 336527664; + int isc_rdb_triggers_rdb_flags_corrupt = 336527664; int isc_gsec_cant_open_db = 336723983; int isc_gsec_switches_error = 336723984; int isc_gsec_no_op_spec = 336723985; diff --git a/src/resources/org/firebirdsql/firebird_0_error_msg.properties b/src/resources/org/firebirdsql/firebird_0_error_msg.properties index 7393593a3..2df6f9f7d 100644 --- a/src/resources/org/firebirdsql/firebird_0_error_msg.properties +++ b/src/resources/org/firebirdsql/firebird_0_error_msg.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 335544320= @@ -30,7 +30,7 @@ 335544346=corrupt system table 335544347=validation error for column {0}, value "{1}" 335544348=no current record for fetch operation -335544349=attempt to store duplicate value (visible to active transactions) in unique index "{0}" +335544349=attempt to store duplicate value (visible to active transactions) in unique index {0} 335544350=program attempted to exit without finishing database 335544351=unsuccessful metadata update 335544352=no permission for {0} access to {1} {2} @@ -115,7 +115,7 @@ 335544431=blocking signal has been received 335544432=lock manager error 335544433=communication error with journal "{0}" -335544434=key size exceeds implementation restriction for index "{0}" +335544434=key size exceeds implementation restriction for index {0} 335544435=null segment of UNIQUE KEY 335544436=SQL error code \= {0} 335544437=wrong DYN version @@ -147,7 +147,7 @@ 335544463=generator {0} is not defined 335544464=secondary server attachments cannot start logging 335544465=invalid BLOB type for operation -335544466=violation of FOREIGN KEY constraint "{0}" on table "{1}" +335544466=violation of FOREIGN KEY constraint {0} on table {1} 335544467=minor version too high found {0} expected {1} 335544468=transaction {0} is {1} 335544469=transaction marked invalid and cannot be committed @@ -193,7 +193,7 @@ 335544509=CHARACTER SET {0} is not defined 335544510=lock time-out on wait transaction 335544511=procedure {0} is not defined -335544512=Input parameter mismatch for procedure {0} +335544512=Parameter mismatch for procedure {0} 335544513=Database {0}\: WAL subsystem bug for pid {1}\n{2} 335544514=Could not expand the WAL segment for database {0} 335544515=status code {0} unknown @@ -278,7 +278,7 @@ 335544594=Illegal array dimension range 335544595=Trigger unknown 335544596=Subselect illegal in this context -335544597=Cannot prepare a CREATE DATABASE/SCHEMA statement +335544597=Cannot prepare a CREATE DATABASE statement 335544598=must specify column name for view select expression 335544599=number of columns does not match select list 335544600=Only simple column names permitted for VIEW WITH CHECK OPTION @@ -346,7 +346,7 @@ 335544662=BLOB SUB_TYPE {0} is not defined 335544663=Too many concurrent executions of the same request 335544664=duplicate specification of {0} - not supported -335544665=violation of PRIMARY or UNIQUE KEY constraint "{0}" on table "{1}" +335544665=violation of PRIMARY or UNIQUE KEY constraint {0} on table {1} 335544666=server version too old to support all CREATE DATABASE options 335544667=drop database completed with errors 335544668=procedure {0} does not return any values @@ -782,7 +782,7 @@ 335545098=CREATE DATABASE grants check is not possible when database {0} is not present 335545099=CREATE DATABASE grants check is not possible when table RDB$DB_CREATORS is not present in database {0} 335545100=Interface {2} version too old\: expected {0}, found {1} -335545101=Input parameter mismatch for function {0} +335545101=Parameter mismatch for function {0} 335545102=Error during savepoint backout - transaction invalidated 335545103=Domain used in the PRIMARY KEY constraint of table {0} must be NOT NULL 335545104=CHARACTER SET {0} cannot be used as a attachment character set @@ -834,7 +834,7 @@ 335545150=Sub-function {0} was declared but not implemented 335545151=Sub-procedure {0} was declared but not implemented 335545152=Invalid HASH algorithm {0} -335545153=Expression evaluation error for index "{0}" on table "{1}" +335545153=Expression evaluation error for index {0} on table {1} 335545154=Invalid decfloat trap state {0} 335545155=Invalid decfloat rounding mode {0} 335545156=Invalid part {0} to calculate the {0} of a DATE/TIMESTAMP @@ -970,3 +970,33 @@ 335545286=Wrong parallel workers value {0}, valid range are from 1 to {1} 335545287=Definition of index expression is not found for index {0} 335545288=Definition of index condition is not found for index {0} +335545289=Variable {0} is not initialized +335545290=Parameter {0} does not exist +335545291=Parameter {0} has no default value and was not specified or was specified with DEFAULT +335545292=Parameter {0} has multiple assignments +335545293=Cannot recognize "{0}" part of date format +335545294=Cannot find closing " for raw text in date format +335545295=It is not possible to use this data type for date formatting +335545296=Cannot use "{0}" format with current date type +335545297=Value for {0} pattern is out of range [{1}, {2}] +335545298={0} is not MONTH +335545299={0} is incorrect period for 12H, it should be A.M. or P.M. +335545300=All data has been read, but format pattern wants more. Unfilled patterns\: "{0}" +335545301=There is a trailing part of input string that does not fit into FORMAT\: "{0}" +335545302={0} can't be used without {1} +335545303={0} can't be used without {1} and vice versa +335545304={0} incompatible with {1} +335545305=Can use only one of these patterns {0} +335545306=Cannot use the same pattern twice\: {0} +335545307=Invalid GEN_UUID version ({0}). Must be 4 or 7 +335545308=Unable to run sweep +335545309=Another instance of sweep is already running +335545310=Database in read only state +335545311=Attachment has no cleanup flag set +335545312=Invalid time zone region or displacement\: {0} +335545313=Arguments for range-based FOR must be exact numeric types +335545314=Range-based FOR BY argument must be positive +335545315=Cannot find value in input string for "{0}" pattern +335545316=Invalid name\: {0} +335545317=Invalid list of unqualified names\: {0} +335545318=User attachments are not allowed for the database being restored diff --git a/src/resources/org/firebirdsql/firebird_0_sql_states.properties b/src/resources/org/firebirdsql/firebird_0_sql_states.properties index b9da749d5..09eae27df 100644 --- a/src/resources/org/firebirdsql/firebird_0_sql_states.properties +++ b/src/resources/org/firebirdsql/firebird_0_sql_states.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 335544321=22000 @@ -12,7 +12,7 @@ 335544329=42000 335544330=HY000 335544331=HY000 -335544332=08003 +335544332=HY000 335544333=XX000 335544334=22018 335544335=XX001 @@ -300,7 +300,7 @@ 335544617=42000 335544618=HY000 335544619=38000 -335544620=08001 +335544620=42000 335544621=42000 335544622=42000 335544623=42000 @@ -701,7 +701,7 @@ 335545018=42000 335545019=42000 335545020=24000 -335545021=08003 +335545021=HY000 335545022=XX000 335545023=22000 335545024=42000 @@ -839,7 +839,7 @@ 335545156=42000 335545157=42000 335545158=HY104 -335545159=08003 +335545159=HY000 335545160=22021 335545161=22000 335545162=22000 @@ -864,7 +864,7 @@ 335545181=22000 335545182=22000 335545183=54000 -335545184=08002 +335545184=HY010 335545185=07003 335545186=07001 335545187=07001 @@ -969,3 +969,33 @@ 335545286=HY000 335545287=42000 335545288=42000 +335545289=42000 +335545290=07001 +335545291=07001 +335545292=07001 +335545293=HY000 +335545294=HY000 +335545295=HY000 +335545296=HY000 +335545297=HY000 +335545298=HY000 +335545299=HY000 +335545300=HY000 +335545301=HY000 +335545302=HY000 +335545303=HY000 +335545304=HY000 +335545305=HY000 +335545306=HY000 +335545307=42000 +335545308=42000 +335545309=42000 +335545310=42000 +335545311=42000 +335545312=HY000 +335545313=42000 +335545314=42000 +335545315=HY000 +335545316=HY000 +335545317=HY000 +335545318=HY000 diff --git a/src/resources/org/firebirdsql/firebird_12_error_msg.properties b/src/resources/org/firebirdsql/firebird_12_error_msg.properties index a62f3edc0..336374454 100644 --- a/src/resources/org/firebirdsql/firebird_12_error_msg.properties +++ b/src/resources/org/firebirdsql/firebird_12_error_msg.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 336330752=could not locate appropriate error message @@ -409,3 +409,15 @@ 336331160=expected parallel workers, encountered "{0}" 336331161=\ {0}D(IRECT_IO) direct IO for backup file(s) 336331162=use up to {0} parallel workers +336331163=writing schema {0} +336331164=writing schemas +336331165=restoring schema {0} +336331166=schema +336331167=\ {0}SKIP_SCHEMA_D(ATA) skip data for schema +336331168=\ {0}INCLUDE_SCHEMA_D(ATA) backup data of schema(s) +336331169=missing regular expression to skip schemas +336331170=missing regular expression to include schemas +336331171=regular expression to skip schemas was already set +336331172=regular expression to include schemas was already set +336331173=migrating {0} plugin objects to schema {1} +336331174=error migrating {0} plugin objects to schema {1}. Plugin objects will be in inconsistent state\: diff --git a/src/resources/org/firebirdsql/firebird_13_error_msg.properties b/src/resources/org/firebirdsql/firebird_13_error_msg.properties index 55b35016c..32542fdbe 100644 --- a/src/resources/org/firebirdsql/firebird_13_error_msg.properties +++ b/src/resources/org/firebirdsql/firebird_13_error_msg.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 336396289=Firebird error @@ -286,3 +286,9 @@ 336397332=String literal with {0} characters exceeds the maximum length of {1} characters for the {2} character set 336397333=Too many BEGIN...END nesting. Maximum level is {0} 336397334=RECREATE USER {0} failed +336397335=the number of fields exceeds the limit for the {0} operator. Expected {1}, received {2} +336397336=CREATE SCHEMA {0} failed +336397337=DROP SCHEMA {0} failed +336397338=RECREATE SCHEMA {0} failed +336397339=ALTER SCHEMA {0} failed +336397340=CREATE OR ALTER SCHEMA {0} failed diff --git a/src/resources/org/firebirdsql/firebird_13_sql_states.properties b/src/resources/org/firebirdsql/firebird_13_sql_states.properties index c2d93d39a..dfa6c752a 100644 --- a/src/resources/org/firebirdsql/firebird_13_sql_states.properties +++ b/src/resources/org/firebirdsql/firebird_13_sql_states.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 336397205=HY000 @@ -131,3 +131,9 @@ 336397332=42000 336397333=07002 336397334=42000 +336397335=54001 +336397336=42000 +336397337=42000 +336397338=42000 +336397339=42000 +336397340=42000 diff --git a/src/resources/org/firebirdsql/firebird_17_error_msg.properties b/src/resources/org/firebirdsql/firebird_17_error_msg.properties index e157e6731..2027e7151 100644 --- a/src/resources/org/firebirdsql/firebird_17_error_msg.properties +++ b/src/resources/org/firebirdsql/firebird_17_error_msg.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 336658432=Statement failed, SQLSTATE \= {0} @@ -101,7 +101,7 @@ 336658529=There is no membership privilege granted on {0} in this database 336658530=Expected end of statement, encountered EOF 336658533=Bad TIME\: {0} -336658534=\ SYSTEM, TABLE, TRIGGER, VERSION, USERS, VIEW +336658534=\ SYSTEM, TABLE, TRIGGER, VERSION, USERS, VIEW, WIRE_STATISTICS 336658535=There is no role {0} in this database 336658536=\t-b(ail) bail on errors (set bail on) 336658537=Incomplete string in {0} @@ -204,3 +204,10 @@ 336658634=There is no publications in this database 336658635=Publications\: 336658636=Procedures\: +336658637=EXPLAIN -- explain a query access plan +336658638=\t-autot(erm) use auto statement terminator (set autoterm on) +336658639=SET AUTOTERM ON is not supported in engine/server and has been disabled +336658640=\ SET AUTOTERM -- toggle auto statement terminator +336658641=\ SET WIRE_stats -- toggle display of wire (network) statistics +336658642=\t-(se)arch_path set schema search path +336658643=Schemas\: diff --git a/src/resources/org/firebirdsql/firebird_21_error_msg.properties b/src/resources/org/firebirdsql/firebird_21_error_msg.properties index 29b760c99..ef1d0c5d7 100644 --- a/src/resources/org/firebirdsql/firebird_21_error_msg.properties +++ b/src/resources/org/firebirdsql/firebird_21_error_msg.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 336920577=found unknown switch @@ -38,19 +38,19 @@ 336920611=\ -t tablename (case sensitive) 336920612=\ -tr use trusted authentication 336920613=\ -fetch fetch password from file -336920614=option -h is incompatible with options -a, -d, -i, -r, -s and -t +336920614=option -h is incompatible with options -a, -d, -i, -r, -schema, -s and -t 336920615=usage\: gstat [options] or gstat [options] 336920616=database name was already specified 336920617=option -t needs a table name 336920618=option -t got a too long table name {0} 336920619=option -t accepts several table names only if used after -336920620=table "{0}" not found +336920620=table {0} not found 336920621=use gstat -? to get help 336920622=\ Primary pages\: {0}, secondary pages\: {1}, swept pages\: {2} 336920623=\ Big record pages\: {0} 336920624=\ Blobs\: {0}, total length\: {1}, blob pages\: {2} 336920625=\ Level 0\: {0}, Level 1\: {1}, Level 2\: {2} -336920626=option -e is incompatible with options -a, -d, -h, -i, -r, -s and -t +336920626=option -e is incompatible with options -a, -d, -h, -i, -r, -schema, -s and -t 336920627=\ -e analyze database encryption 336920628=Data pages\: total {0}, encrypted {1}, non-crypted {2} 336920629=Index pages\: total {0}, encrypted {1}, non-crypted {2} @@ -63,3 +63,8 @@ 336920636=Gstat completion time {0} 336920637=\ Expected page inventory page {0} 336920638=Generator pages\: total {0}, encrypted {1}, non-crypted {2} +336920639=\ Table size\: {0} bytes +336920640=\ Level {0}\: {1}, total length\: {2}, blob pages\: {3} +336920641=\ -sch schemaname (case sensitive) +336920642=option -sch needs a schema name +336920643=option -sch got a too long schema name {0} diff --git a/src/resources/org/firebirdsql/firebird_25_error_msg.properties b/src/resources/org/firebirdsql/firebird_25_error_msg.properties index 0f5433c7f..ad8252990 100644 --- a/src/resources/org/firebirdsql/firebird_25_error_msg.properties +++ b/src/resources/org/firebirdsql/firebird_25_error_msg.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 337182721=Firebird Trace Manager version {0} @@ -21,7 +21,7 @@ 337182738=\ -U[SER] User name 337182739=\ -P[ASSWORD] Password 337182740=\ -FE[TCH] Fetch password from file -337182741=\ -T[RUSTED] Force trusted authentication +337182741=\ -T[RUSTED] Force trusted authentication 337182742=Examples\: 337182743=\ fbtracemgr -SE remote_host\:service_mgr -USER SYSDBA -PASS masterkey -LIST 337182744=\ fbtracemgr -SE service_mgr -START -NAME my_trace -CONFIG my_cfg.txt @@ -41,3 +41,5 @@ 337182758=mandatory parameter "{0}" for switch "{1}" is missing 337182759=parameter "{0}" is incompatible with action "{1}" 337182760=mandatory switch "{0}" is missing +337182761=\ -P[LUGINS] Plugins list for use with trace session; valid list separators\: , , +337182762=\ fbtracemgr -SE service_mgr -START -NAME my_trace -CONFIG my_cfg.txt -PLUGINS fbtrace,custom_plugin diff --git a/src/resources/org/firebirdsql/firebird_7_error_msg.properties b/src/resources/org/firebirdsql/firebird_7_error_msg.properties index f1a428b87..f8d6da064 100644 --- a/src/resources/org/firebirdsql/firebird_7_error_msg.properties +++ b/src/resources/org/firebirdsql/firebird_7_error_msg.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 336003074=Cannot SELECT RDB$DB_KEY from a stored procedure. @@ -23,7 +23,7 @@ 336003093=Relation {0} is ambiguous in cursor {1} 336003094=Relation {0} is not found in cursor {1} 336003095=Cursor is not open -336003096=Data type {0} is not supported for EXTERNAL TABLES. Relation '{1}', field '{2}' +336003096=Data type {0} is not supported for EXTERNAL TABLES. Relation {1}, field {2} 336003097=Feature not supported on ODS version older than {0}.{1} 336003098=Primary key required on table {0} 336003099=UPDATE OR INSERT field list does not match primary key of table {0} diff --git a/src/resources/org/firebirdsql/firebird_8_error_msg.properties b/src/resources/org/firebirdsql/firebird_8_error_msg.properties index 3bf720924..8eced62b7 100644 --- a/src/resources/org/firebirdsql/firebird_8_error_msg.properties +++ b/src/resources/org/firebirdsql/firebird_8_error_msg.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 336068609=ODS version not supported by DYN @@ -302,3 +302,16 @@ 336068915=Exception {0} does not exist 336068916=Generator/Sequence {0} does not exist 336068917=Field {0} of table {1} does not exist +336068918=Trigger {0} already exists +336068919=Domain {0} already exists +336068920=Collation {0} already exists +336068921=Package {0} already exists +336068922=Index schema ({0}) must match table schema ({1}) +336068923=Trigger schema ({0}) must match table schema ({1}) +336068924=Schema {0} already exists +336068925=Cannot ALTER or DROP SYSTEM schema +336068926=Cannot DROP schema {0} because it has objects +336068927=Cannot CREATE/ALTER/DROP {0} in SYSTEM schema +336068928=Schema name {0} is reserved and cannot be created +336068929=Cannot infer schema name as there is no valid schema in the search path +336068930=Blob filter {0} already exists diff --git a/src/resources/org/firebirdsql/firebird_8_sql_states.properties b/src/resources/org/firebirdsql/firebird_8_sql_states.properties index f986cf237..6a617da16 100644 --- a/src/resources/org/firebirdsql/firebird_8_sql_states.properties +++ b/src/resources/org/firebirdsql/firebird_8_sql_states.properties @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2000-2024 Firebird development team and individual contributors +# SPDX-FileCopyrightText: 2000-2025 Firebird development team and individual contributors # SPDX-License-Identifier: LGPL-2.1-or-later # SPDX-FileComment: The keys and values listed here were obtained from the Firebird sources, which are licensed under the IPL (InterBase Public License) and/or IDPL (Initial Developer Public License), both are variants of the Mozilla Public License version 1.1 336068645=42000 @@ -90,3 +90,10 @@ 336068915=42000 336068916=42000 336068917=42000 +336068922=42000 +336068923=42000 +336068925=28000 +336068926=HY000 +336068927=28000 +336068928=HY000 +336068929=42000 diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java index 92ee19044..e263df414 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java @@ -30,6 +30,7 @@ import static org.firebirdsql.common.FbAssumptions.assumeSchemaSupport; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.firebirdsql.common.matchers.RegexMatcher.matchesRegex; +import static org.firebirdsql.gds.ISCConstants.isc_dsql_drop_schema_failed; import static org.firebirdsql.gds.ISCConstants.isc_dsql_drop_trigger_failed; import static org.firebirdsql.gds.ISCConstants.isc_dsql_table_not_found; import static org.firebirdsql.gds.ISCConstants.isc_dsql_view_not_found; @@ -877,8 +878,7 @@ void testGetTriggerSourceCode_String_String() throws SQLException { } finally { DdlHelper.executeDDL(stmt, List.of("drop table TEST_SCHEMA_1.TEST_1", "drop schema TEST_SCHEMA_1"), isc_no_meta_update, isc_dsql_table_not_found, isc_dsql_view_not_found, - isc_dsql_drop_trigger_failed); - // TODO Add schema support: error code for drop schema failure? + isc_dsql_drop_trigger_failed, isc_dsql_drop_schema_failed); } } finally { connection.setAutoCommit(true); @@ -929,8 +929,7 @@ void testGetViewSourceCode_String_String() throws SQLException { } finally { DdlHelper.executeDDL(stmt, List.of("drop view TEST_SCHEMA_1.TEST_VIEW_1", "drop schema TEST_SCHEMA_1"), isc_no_meta_update, isc_dsql_table_not_found, isc_dsql_view_not_found, - isc_dsql_drop_trigger_failed); - // TODO Add schema support: error code for drop schema failure? + isc_dsql_drop_trigger_failed, isc_dsql_drop_schema_failed); } } finally { connection.setAutoCommit(true); From e37afa1737c89879f94f6bae0a6cbb860b5898e3 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 4 Nov 2025 11:17:02 +0100 Subject: [PATCH 63/64] Ignore drop schema failure, refactor to single code path --- .../org/firebirdsql/common/DdlHelper.java | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/test/org/firebirdsql/common/DdlHelper.java b/src/test/org/firebirdsql/common/DdlHelper.java index d3a0aa4ad..ef745140b 100644 --- a/src/test/org/firebirdsql/common/DdlHelper.java +++ b/src/test/org/firebirdsql/common/DdlHelper.java @@ -9,6 +9,7 @@ import java.sql.Statement; import java.util.Arrays; import java.util.Collection; +import java.util.List; import static org.firebirdsql.util.FirebirdSupportInfo.supportInfoFor; @@ -17,7 +18,6 @@ * * @author Mark Rotteveel */ -@SuppressWarnings("SqlSourceToSinkFlow") public final class DdlHelper { private DdlHelper() { @@ -69,9 +69,7 @@ public static void executeCreateTable(final Statement statement, final String sq */ public static void executeDDL(final Connection connection, final String sql, final int... ignoreErrors) throws SQLException { - try (Statement stmt = connection.createStatement()) { - executeDDL(stmt, sql, ignoreErrors); - } + executeDDL(connection, List.of(sql), ignoreErrors); } /** @@ -111,10 +109,7 @@ public static void executeDDL(final Connection connection, final Collection 1) { + connection.setAutoCommit(false); + } + try { + for (String currentSql : sql) { + executeDDL0(statement, currentSql, ignoreErrors); + } + if (!autoCommitAtStart) { + connection.commit(); + } + } finally { + // if we were not in auto commit at start and an exception occurred, the transaction will still be pending + if (autoCommitAtStart) { + connection.setAutoCommit(true); + } } } private static void executeDDL0(Statement statement, String sql, int[] ignoreErrors) throws SQLException { try { statement.execute(sql); - } catch (SQLException ex) { + } catch (SQLException e) { if (ignoreErrors == null || ignoreErrors.length == 0) - throw ex; + throw e; - for (Throwable current : ex) { - if (current instanceof SQLException - && Arrays.binarySearch(ignoreErrors, ((SQLException) current).getErrorCode()) >= 0) { + for (Throwable current : e) { + if (current instanceof SQLException currentSqle + && Arrays.binarySearch(ignoreErrors, currentSqle.getErrorCode()) >= 0) { return; } } - throw ex; + throw e; } } From 588a017c3a2e031086e92d4e0b428f9f063e5d6c Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Tue, 4 Nov 2025 11:42:05 +0100 Subject: [PATCH 64/64] Finalize jdp-2025-06 --- devdoc/jdp/jdp-2025-06-schema-support.adoc | 23 ++++++++++------------ src/docs/asciidoc/release_notes.adoc | 12 ++++++++--- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/devdoc/jdp/jdp-2025-06-schema-support.adoc b/devdoc/jdp/jdp-2025-06-schema-support.adoc index 608de6f23..32d652408 100644 --- a/devdoc/jdp/jdp-2025-06-schema-support.adoc +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -5,8 +5,8 @@ == Status -* Draft -* Proposed for: Jaybird 7, potential partial backport to Jaybird 5 +* Published: 2025-11-04 +* Implemented in: Jaybird 7 == Type @@ -52,11 +52,6 @@ JDBC defines various methods, parameters, and return values or result set column Jaybird 5 is the "`long-term support`" version for Java 8. -[NOTE] -==== -This document is in flux, and will be updated during implementation of the feature. -==== - == Decision Jaybird 7 will implement schema support for Firebird 6.0. @@ -67,8 +62,8 @@ Further details can be found in <>. Schema support will _not_ be backported to Jaybird 6 as the required changes are simply too large. It would mean that Jaybird 6 would contain most of Jaybird 7, and upgrading to a -- theoretical -- Jaybird 6.1 would have similar risks and compatibility issues as upgrading to Jaybird 7. -Decision on a (partial) backport to Jaybird 5 -- as the "`long-term support`" version for Java 8 -- is still pending (e.g. as a Jaybird 5.1.x), and may be the subject of a separate JDP. -We might only do that on demand and/or when someone is willing to sponsor the work. +Decision on a (partial) backport to Jaybird 5 -- as the "`long-term support`" version for Java 8 -- is pending (e.g. as a Jaybird 5.1.x), and if so, will be the subject of a separate JDP. +We will only do that on demand and if someone is willing to sponsor the work. [#consequences] == Consequences @@ -97,9 +92,11 @@ Jaybird cannot honour this requirement for plain `Statement`, as schema resoluti ** Include schema names in joins to ensure matching the right objects ** Allow searching for schema or schema pattern as specified in JDBC, or were needed for internal metadata queries ** `getCatalogs`; -it is not possible to identify the schema(s) within the confines of JDBC. +we currently use this to report package names if `useCatalogAsPackage=true`. +It is not possible to identify the schema(s) of a package within the confines of JDBC. + We considered adding a column that lists the schema(s) that contain the package name, but we don't think it will see use in practice. +If there is demand, this decision may be revisited. * `FirebirdConnection` ** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported ** Added method `List getSearchPathList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported @@ -111,10 +108,10 @@ We considered adding a column that lists the schema(s) that contain the package this fulfills the JDBC requirements that a `CallableStatement` is not sensitive to current schema changes, but only *if* Jaybird is able to identify the procedure, behaviour is undefined if the procedure was not found. ** The API of `StoredProcedureMetaData` (internal API) is changed to not report selectability, but to update the `FBProcedureCall` instance with selectability and other information, like identified schema and/or package. ** For qualified *and* unambiguous procedure reference, the selectability is cached *per connection*, for unqualified or ambiguous procedure reference, the lookup is performed on each `Connection.prepareCall`, to account for search path changes -** Support for packages was missing in the handling of callable statements, and is added, also for older versions +** Support for packages was missing in the handling of callable statements, and is added, also for older Firebird versions * Effects for management API ** `StatisticsManager` -*** `getTableStatistics` received an overload to also accept a list of schemas (`sts_schema`) +*** `getTableStatistics` received an overload to also accept a collection of schemas (`sts_schema`) ** `FBTableStatisticsManager`/`TableStatistics` *** Internally `ObjectReference` is used for the table instead of a String *** The key of the map returned by `getTableStatistics()` is a qualified table reference (i.e. `{ | .}`. @@ -124,7 +121,7 @@ For schemaless tables, the unquoted table name is used as the key for backwards **** `tableReference()` with the qualified table reference (i.e. `[.]` (contrary to the key of getTableStatistics, it's always quoted!)) * TODO: Add information to Jaybird manual -Note to self: use `// TODO Add schema support` in places that you identify need to get/improve schema support, while working on schema support elsewhere +// Note to self: use `// TODO Add schema support` in places that you identify need to get/improve schema support, while working on schema support elsewhere [appendix] == License Notice diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 4540b365b..400ddecb9 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -529,7 +529,6 @@ If `SYSTEM` is not included, the server will automatically add it as the last sc use `null` or -- `schemaPattern` only -- `"%"` to match all schemas ** `getCatalogs` -- when `useCatalogAsPackage=true` -- returns all (distinct) package names over all schemas. Within the limitations and specification of the JDBC API, this method cannot be used to find out which schema(s) contain a specific package name. -// TODO Maybe add a custom column with a list of schema names? ** `getColumnPrivileges` and `getTablePrivileges` received an additional column, `JB_GRANTEE_SCHEMA`, which is non-``null`` for grantees that are schema-bound (e.g. procedures). + As this is a non-standard column, we recommend to always retrieve it by name. @@ -559,6 +558,12 @@ we recommend to always use `null` for `catalog` * `ResultSetMetaData` ** `getSchemaName` reports the schema if the column is backed by a table, otherwise empty string (`""`) * `FirebirdConnection`/`FBConnection` +** `setSchema` will modify the search path by prepending the specified schema, or -- if `setSchema` was called previously -- replacing the previous value set. ++ +The schema is not checked for existence, and setting one will not result in an exception, see also note about `getSchema` below. +** `getSchema()` reports the value of `CURRENT_SCHEMA`, which is the first valid (i.e. existing) schema on the search path. ++ +The value reported can change if a previously missing schema is created in the database. ** Added method `String getSearchPath()` to obtain the search path as reported by `RBB$GET_CONTEXT('SYSTEM', 'SEARCH_PATH')`, or `null` if schemas are not supported ** Added method `List getSearchPatList()` to obtain the search path as a list of unquoted object names, or empty list if schemas are not supported ** Added methods `setSearchPath(String)` and `setSearchPathList(String...)/(List)` to set the search path; @@ -566,8 +571,9 @@ these methods throw `SQLFeatureNotSupportedException` if schemas are not support * `StatisticsManager` ** `getTableStatistics` *** `getTableStatistics(String[] tableNames)` was changed to accept varargs (`getTableStatistics(String... tableNames)`) -*** Added overload `getTableStatistics(List tableNames)` with same behaviour as `getTableStatistics(String... tableNames)` -*** Added overload `getTableStatistics(List schemas, List tableNames)` -- if `schemas` is non-empty, on Firebird 6.0 and higher, it will restrict the search for tables to the specified schemas +*** Added overload `getTableStatistics(Collection tableNames)` with same behaviour as `getTableStatistics(String... tableNames)` +*** Added overload `getTableStatistics(Collection schemas, Collection tableNames)` -- if `schemas` is non-empty, on Firebird 6.0 and higher, it will restrict the search for tables to the specified schemas; +if `tableNames` is empty, and `schemas` is not, only the tables of the specified schemas will be reported * `FBTableStatisticsManager` (experimental feature) ** For schema-bound tables, the key of the map returned by `getTableStatistics()` is a fully qualified and quoted table reference (i.e. `.`). For schemaless tables (Firebird 5.0 and older, or tables that were not found), the key is still the unquoted ``.