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..9c42211d5 --- /dev/null +++ b/devdoc/jdp/jdp-2025-06-schema-support.adoc @@ -0,0 +1,120 @@ += 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. +* If `SYSTEM` is not on the search path, it is automatically 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 +* `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. +* 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 related to schemas. + +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 the 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 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`!) ++ +** 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). +* 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 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 + +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 + +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 diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 14633f919..32d54392c 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -508,6 +508,43 @@ 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: + +* 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 +** `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. +** `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 -- 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 [#other-fixes-and-changes] @@ -648,13 +685,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/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/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..b06d66952 100644 --- a/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java +++ b/src/main/org/firebirdsql/gds/ng/StatementInfoProcessor.java @@ -1,12 +1,14 @@ -// 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; 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 @@ -166,6 +168,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; @@ -208,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/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/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/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/parser/StatementDetector.java b/src/main/org/firebirdsql/jaybird/parser/StatementDetector.java index 8bf545596..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. @@ -53,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; @@ -103,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); @@ -122,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() { @@ -137,6 +140,10 @@ public LocalStatementType getStatementType() { return statementType; } + Token getSchemaToken() { + return schemaToken; + } + Token getTableNameToken() { return tableNameToken; } @@ -149,6 +156,10 @@ private void updateStatementType(LocalStatementType statementType) { } } + private void setSchemaToken(Token schemaToken) { + this.schemaToken = schemaToken; + } + private void setTableNameToken(Token tableNameToken) { this.tableNameToken = tableNameToken; } @@ -211,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); } }, @@ -261,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); @@ -277,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/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/main/org/firebirdsql/jaybird/util/CollectionUtils.java b/src/main/org/firebirdsql/jaybird/util/CollectionUtils.java index 115a9f760..e45a1ef48 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; @@ -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 @@ -69,4 +70,48 @@ 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; + } + + /** + * 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/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/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/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/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/AbstractFieldMetaData.java b/src/main/org/firebirdsql/jdbc/AbstractFieldMetaData.java index 1aab3d20f..14661dd26 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,33 @@ 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 ""} 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 relationName, String fieldName) { + protected record FieldKey(String schema, String relationName, String fieldName) { + + protected FieldKey { + if (schema == null) { + schema = ""; + } + } + public FieldKey(FieldDescriptor fieldDescriptor) { - this(fieldDescriptor.getOriginalTableName(), fieldDescriptor.getOriginalName()); + this(fieldDescriptor.getOriginalSchema(), fieldDescriptor.getOriginalTableName(), + fieldDescriptor.getOriginalName()); } } } diff --git a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java index f5f2ad649..7b3ae5df9 100644 --- a/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java +++ b/src/main/org/firebirdsql/jdbc/ClientInfoProvider.java @@ -1,13 +1,11 @@ -// 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; 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(); } /** @@ -179,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 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 +186,10 @@ 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 e5c91d4e5..efdfad17c 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) { @@ -1373,7 +1418,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/FBDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FBDatabaseMetaData.java index 40d560f8a..4f1c8ca48 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; @@ -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; } /** @@ -1204,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); } /** @@ -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"; @@ -1251,23 +1251,52 @@ 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() { 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); } + @Override + public ResultSet getSchemas(String catalog, String schemaPattern) throws SQLException { + return GetSchemas.create(getDbMetadataMediator()).getSchemas(catalog, schemaPattern); + } + /** * {@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 @@ -1314,17 +1343,20 @@ 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); } /** * {@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. *
*

*

@@ -1332,24 +1364,32 @@ 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); } /** * {@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. *
*

*

@@ -1361,20 +1401,25 @@ 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); } /** * {@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) @@ -1411,7 +1456,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); } /** @@ -1426,7 +1471,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); } /** @@ -1441,7 +1486,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); } /** @@ -1456,9 +1501,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 @@ -1479,7 +1525,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 @@ -1724,7 +1770,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); } /** @@ -1762,12 +1808,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); - } - - @Override - public ResultSet getSchemas(String catalog, String schemaPattern) throws SQLException { - return GetSchemas.create(getDbMetadataMediator()).getSchemas(); + return GetFunctions.create(getDbMetadataMediator()).getFunctions(catalog, schemaPattern, functionNamePattern); } @Override @@ -1804,7 +1845,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 @@ -1815,42 +1857,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; } - return sResult; + 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); + } + + 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/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/main/org/firebirdsql/jdbc/FBRowUpdater.java b/src/main/org/firebirdsql/jdbc/FBRowUpdater.java index 37060e979..3beae4086 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; @@ -10,6 +10,7 @@ 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.UncheckedSQLException; import org.firebirdsql.jdbc.field.FBField; @@ -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 ObjectReference 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 ObjectReference requireSingleTable(RowDescriptor rowDescriptor, QuoteStrategy quoteStrategy) + throws SQLException { + // find the tableName (there can be only one tableName per updatable result set) + ObjectReference tableName = null; for (FieldDescriptor fieldDescriptor : rowDescriptor) { - // TODO This will not detect derived columns in the prefix of the select list + var currentTable = ObjectReference.ofTable(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(ObjectReference 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,10 +251,22 @@ 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 { - try (ResultSet bestRowIdentifier = dbmd - .getBestRowIdentifier("", "", tableName, DatabaseMetaData.bestRowTransaction, true)) { + 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( + "", tableRef.schema(), tableRef.tableName(), DatabaseMetaData.bestRowTransaction, true)) { int bestRowIdentifierColumnCount = 0; List keyColumns = new ArrayList<>(); while (bestRowIdentifier.next()) { @@ -325,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 "); - quoteStrategy.appendQuoted(tableName, sb).append(" set "); + tableName.append(sb, quoteStrategy).append(" set "); boolean first = true; for (FieldDescriptor fieldDescriptor : rowDescriptor) { @@ -348,7 +363,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(); @@ -375,9 +390,10 @@ private String buildInsertStatement() { params.append('?'); } - // 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) + // 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(')'); return sb.toString(); @@ -402,9 +418,10 @@ private String buildSelectStatement() { } } - var sb = new StringBuilder(EST_STATEMENT_SIZE + columns.length()) + // 32 = guesstimate for (schema +) table + var sb = new StringBuilder(EST_STATEMENT_SIZE + columns.length() + 32) .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/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/FirebirdDatabaseMetaData.java b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java index fb0c07381..a231a640a 100644 --- a/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java +++ b/src/main/org/firebirdsql/jdbc/FirebirdDatabaseMetaData.java @@ -1,68 +1,126 @@ // 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") 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. - * + * * @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; @@ -70,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; @@ -82,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; @@ -102,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; @@ -117,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 370a643d0..02fca5fd0 100644 --- a/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java +++ b/src/main/org/firebirdsql/jdbc/GeneratedKeysQueryBuilder.java @@ -1,25 +1,28 @@ -// 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 { @@ -124,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); } /** @@ -144,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(); } @@ -159,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 @@ -182,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); } @@ -212,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 @@ -263,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) @@ -276,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 { @@ -292,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); @@ -310,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(); } @@ -333,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/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/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 633ef17f8..eab78c4c8 100644 --- a/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java +++ b/src/main/org/firebirdsql/jdbc/StoredProcedureMetaDataFactory.java @@ -1,22 +1,23 @@ // 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; -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() { @@ -44,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()); @@ -59,20 +60,27 @@ 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 + // 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) + 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 = "SELECT RDB$PROCEDURE_NAME FROM RDB$PROCEDURES WHERE RDB$PROCEDURE_TYPE = 1"; - try (ResultSet resultSet = stmt.executeQuery(sql)) { + 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)); } @@ -80,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)); } @@ -92,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); 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/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)); } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java b/src/main/org/firebirdsql/jdbc/metadata/GetBestRowIdentifier.java index d631431df..a2cf72228 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; @@ -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; @@ -41,7 +42,7 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetBestRowIdentifier extends AbstractMetadataMethod { +public abstract sealed class GetBestRowIdentifier extends AbstractMetadataMethod { private static final String ROWIDENTIFIER = "ROWIDENTIFIER"; @@ -56,44 +57,19 @@ 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); } @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 (table == null || table.isEmpty()) { + if (isNullOrEmpty(table)) { return createEmpty(); } 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 +81,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 +94,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 +108,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 +119,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 +131,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 +152,118 @@ 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 + 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" + + " 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) { + 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_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/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); } + } + } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java b/src/main/org/firebirdsql/jdbc/metadata/GetColumnPrivileges.java index f57bac6fd..01858fc43 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,10 +30,10 @@ * @author Mark Rotteveel * @since 5 */ -public final 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(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() @@ -40,35 +43,9 @@ public final 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(); - //@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 +53,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 final 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")) @@ -103,10 +75,123 @@ 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); } 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, + 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 + 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, + 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 + 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_SCHEMA_NAME nulls first, 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/GetColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetColumns.java index 39ee3d221..6afb758cc 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; @@ -35,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"; @@ -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); @@ -151,31 +156,31 @@ 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 class FB2_5 extends GetColumns { + + 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 class FB3 extends GetColumns { + + 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 final 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/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java b/src/main/org/firebirdsql/jdbc/metadata/GetCrossReference.java index 7c2a8dd4f..36f9f0500 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 sealed 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..2459ea103 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 sealed 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/GetFunctionColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetFunctionColumns.java index 1b4860dc5..88ec1425e 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; @@ -40,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"; @@ -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/main/org/firebirdsql/jdbc/metadata/GetFunctions.java b/src/main/org/firebirdsql/jdbc/metadata/GetFunctions.java index ea9243cf3..0d14ef598 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; @@ -27,11 +28,13 @@ * @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"; + 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/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java b/src/main/org/firebirdsql/jdbc/metadata/GetImportedKeys.java index fd3343a3e..ad0fc6bad 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 sealed 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/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/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java b/src/main/org/firebirdsql/jdbc/metadata/GetPrimaryKeys.java index 777638300..9ef6a16da 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 sealed 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/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetProcedureColumns.java index 83fca3254..3567fbf4e 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; @@ -39,9 +39,11 @@ * @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"; + 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); } @@ -144,30 +153,33 @@ 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 = - "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 { + private static final 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 final 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 22d0f7b5e..65a089d1a 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; @@ -29,14 +29,16 @@ * @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"; + 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); } @@ -97,7 +107,8 @@ private static final class FB2_5 extends GetProcedures { private static final String GET_PROCEDURES_FRAGMENT_2_5 = """ select - null as PROCEDURE_CAT, + 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 @@ -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,87 @@ 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)); + String sql = GET_PROCEDURES_FRAGMENT_6_W_PKG + + (Clause.anyCondition(clauses) ? "\nwhere " + Clause.conjunction(clauses) : "") + + GET_PROCEDURES_ORDER_BY_6_W_PKG; + return new MetadataQuery(sql, Clause.parameters(clauses)); + } + } } diff --git a/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java b/src/main/org/firebirdsql/jdbc/metadata/GetPseudoColumns.java index 0e1812c22..af25c3620 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; @@ -29,9 +29,10 @@ * @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"; 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/main/org/firebirdsql/jdbc/metadata/GetSchemas.java b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java index a4206c29e..fd9d152a3 100644 --- a/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java +++ b/src/main/org/firebirdsql/jdbc/metadata/GetSchemas.java @@ -1,17 +1,18 @@ -// 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; 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 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.jaybird.util.StringUtils.isNullOrEmpty; import static org.firebirdsql.jdbc.metadata.FbMetadataConstants.OBJECT_NAME_LENGTH; /** @@ -20,23 +21,99 @@ * @author Mark Rotteveel * @since 5 */ -public final class GetSchemas { +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() .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 (!isNullOrEmpty(catalog) || "".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(); + // 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 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/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/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/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"; 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/main/org/firebirdsql/util/FirebirdSupportInfo.java b/src/main/org/firebirdsql/util/FirebirdSupportInfo.java index c93a6da1e..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) { @@ -517,8 +519,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,11 +786,33 @@ 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); + } + + /** + * 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 */ public boolean isSupportedVersion() { - return isVersionEqualOrAbove(3); + return isVersionEqualOrAbove(SUPPORTED_MIN_VERSION); } /** @@ -823,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 { @@ -846,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); + } + } + } diff --git a/src/test/org/firebirdsql/common/FBTestProperties.java b/src/test/org/firebirdsql/common/FBTestProperties.java index f6d8077b3..68a396c47 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; } @@ -366,6 +368,36 @@ 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} + * @see FirebirdSupportInfo#ifSchemaElse(Object, Object) + */ + public static T ifSchemaElse(T forSchema, T withoutSchema) { + return getDefaultSupportInfo().ifSchemaElse(forSchema, withoutSchema); + } + + /** + * 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"} or {@code "SYSTEM"} and schemas + * are not supported — {@code ""} + */ + public static String resolveSchema(String schemaName) { + if (!getDefaultSupportInfo().supportsSchemas() + && ("PUBLIC".equals(schemaName) || "SYSTEM".equals(schemaName))) { + return ""; + } + return schemaName; + } + private FBTestProperties() { // No instantiation } 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/gds/ng/AbstractStatementTest.java b/src/test/org/firebirdsql/gds/ng/AbstractStatementTest.java index eb738ac96..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"); } @@ -385,7 +377,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 +424,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 @@ -742,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/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/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/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/jaybird/util/CollectionUtilsTest.java b/src/test/org/firebirdsql/jaybird/util/CollectionUtilsTest.java index 28ff16654..78ba8a674 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; @@ -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; @@ -89,6 +90,33 @@ void getLast_multipleItems() { assertEquals(item2, CollectionUtils.getLast(List.of(item1, item2))); } + @Test + 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/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/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 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/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()); } 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"); + } +} diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataAbstractKeysTest.java index a1714d1df..c265878d2 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; @@ -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,8 +20,11 @@ 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; +import static org.firebirdsql.jaybird.util.StringUtils.trimToNull; /** * Base test class for subclasses of {@code org.firebirdsql.jdbc.metadata.AbstractKeysMethod}. @@ -33,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); @@ -93,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(); @@ -161,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) { @@ -180,9 +217,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 +253,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/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/FBDatabaseMetaDataColumnPrivilegesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java index 616f6a9f0..b5039e1e0 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnPrivilegesTest.java @@ -1,21 +1,23 @@ -// 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; 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; @@ -24,6 +26,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.FbAssumptions.assumeFeature; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -41,14 +45,8 @@ class FBDatabaseMetaDataColumnPrivilegesTest { 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); @@ -56,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 @@ -85,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"), @@ -125,11 +150,13 @@ 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(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"), @@ -155,11 +182,12 @@ void testColumnPrivileges_TBL1_COL_wildcard() throws Exception { createRule("TBL1", "COL2", SYSDBA, true, "UPDATE"), createRule("TBL1", "COL2", USER1, false, "UPDATE")); - validateExpectedColumnPrivileges("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"), @@ -181,13 +209,57 @@ void testColumnPrivileges_tbl2_all() throws Exception { createRule("tbl2", "val3", user2, true, "SELECT"), createRule("tbl2", "val3", SYSDBA, true, "UPDATE")); - validateExpectedColumnPrivileges("tbl2", "%", rules); + validateExpectedColumnPrivileges(schemaNull ? null : ifSchemaElse("PUBLIC", ""), "tbl2", "%", rules); } - private Map createRule(String tableName, String columnName, String grantee, + @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, 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 +267,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,9 +287,10 @@ 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"); + defaults.put(ColumnPrivilegesMetadata.JB_GRANTEE_SCHEMA, null); DEFAULT_COLUMN_PRIVILEGES_VALUES = Collections.unmodifiableMap(defaults); } @@ -235,7 +308,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/FBDatabaseMetaDataColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataColumnsTest.java index 5482ecae6..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; @@ -17,13 +17,15 @@ import static org.firebirdsql.common.FBTestProperties.getConnectionViaDriverManager; import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +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.assertEquals; import static org.junit.jupiter.api.Assumptions.assumeTrue; /** * Tests for {@link FBDatabaseMetaData} for column related metadata. - * + * * @author Mark Rotteveel */ class FBDatabaseMetaDataColumnsTest { @@ -33,59 +35,69 @@ class FBDatabaseMetaDataColumnsTest { 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); @@ -141,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(). */ @@ -175,7 +192,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 +210,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 +227,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 +246,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 +264,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 +281,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 +298,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 +315,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 +343,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 +371,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 +387,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 +402,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 +417,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 +433,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 +449,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 +465,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 +481,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 +497,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 +513,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 +535,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 +551,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 +569,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 +584,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 +599,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 +614,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 +631,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 +648,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 +661,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 +673,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 +685,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 +703,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 +721,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 +734,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 +747,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,28 +761,79 @@ 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); + } + + @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 /** * 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)) { - 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"); } } @@ -773,7 +841,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); @@ -799,34 +867,34 @@ private void validate(String tableName, String columnName, 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 8320c5ce2..4e015b49b 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataCrossReferenceTest.java @@ -1,7 +1,8 @@ -// 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; +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,9 @@ import java.util.Map; import java.util.stream.Stream; +import static org.firebirdsql.common.FBTestProperties.getDefaultSupportInfo; +import static org.firebirdsql.common.FBTestProperties.ifSchemaElse; + /** * Tests for {@link FBDatabaseMetaData#getCrossReference(String, String, String, String, String, String)}. * @@ -21,37 +25,61 @@ class FBDatabaseMetaDataCrossReferenceTest extends FBDatabaseMetaDataAbstractKey @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); } } - @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, null, parentTable, null, null, 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 e1f0f3ebd..16632bcbf 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; @@ -8,12 +8,15 @@ 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; + /** * Tests for {@link java.sql.DatabaseMetaData#getExportedKeys(String, String, String)}. * @@ -28,33 +31,49 @@ void testExportedKeysMetaDataColumns() throws Exception { } } - @ParameterizedTest + @ParameterizedTest(name = "({0}, {1})") @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); } } static Stream 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) { - 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") @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/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/FBDatabaseMetaDataFunctionColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java index d0d175490..ad54514f5 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionColumnsTest.java @@ -1,9 +1,11 @@ -// 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.jaybird.props.PropertyNames; +import org.firebirdsql.jaybird.util.CollectionUtils; +import org.firebirdsql.jaybird.util.ObjectReference; import org.firebirdsql.util.FirebirdSupportInfo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -22,9 +24,11 @@ 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.FbAssumptions.assumeFeature; +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; @@ -37,6 +41,7 @@ class FBDatabaseMetaDataFunctionColumnsTest { 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"; @@ -47,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 @@ -174,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); } @@ -188,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; } @@ -201,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 @@ -241,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"); @@ -300,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); } @@ -352,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("\"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); } } @@ -367,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); } } @@ -386,7 +401,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 +418,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"))); } @@ -409,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)), @@ -447,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)), @@ -477,7 +536,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)), @@ -506,11 +565,23 @@ 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, ObjectReference.of(schema, functionName).toString()); + 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, functionName); + rules.put(FunctionColumnMetaData.SPECIFIC_NAME, ifSchemaElse( + ObjectReference.of("PUBLIC", functionName).toString(), functionName)); rules.put(FunctionColumnMetaData.COLUMN_NAME, columnName); rules.put(FunctionColumnMetaData.ORDINAL_POSITION, ordinalPosition); if (nullable) { @@ -674,7 +745,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..c4818399f 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataFunctionsTest.java @@ -1,29 +1,36 @@ -// 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.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; +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.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; @@ -52,6 +59,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'"; @@ -73,6 +95,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()); @@ -81,12 +105,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 @@ -95,7 +126,7 @@ static void tearDownAll() throws SQLException { con.close(); } finally { con = null; - dbmd = null; + originalDbmd = null; } } @@ -117,6 +148,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; } @@ -132,40 +169,64 @@ 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 { + assumeFeature(FirebirdSupportInfo::supportsSchemas, "Test requires schema support"); + 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 @@ -188,37 +249,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())); } } @@ -227,34 +272,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 { @@ -263,18 +309,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 void validatePsqlExample(ResultSet functions, boolean useCatalogAsPackage) throws SQLException { + 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 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, "PSQL$EXAMPLE"); + rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse( + ObjectReference.of("PUBLIC", "PSQL$EXAMPLE").toString(), "PSQL$EXAMPLE")); if (supportsComments) { rules.put(FunctionMetaData.REMARKS, "Comment on PSQL$EXAMPLE"); } @@ -283,40 +350,82 @@ private void validatePsqlExample(ResultSet functions, boolean useCatalogAsPackag return X+1; end"""); rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); + return rules; + } + + private static Map getOtherSchemaPsqlExample() { + return getOtherSchemaPsqlExample(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, ObjectReference.of("OTHER_SCHEMA", "PSQL$EXAMPLE").toString()); + rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, """ + begin + return cast(x as varchar(50)); + end"""); + rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); + return rules; + } - getFunctionsDefinition.validateRowValues(functions, rules); + private static Map getOtherSchemaPsqlExample2() { + return getOtherSchemaPsqlExample2(false); } - private void validateUdfExample(ResultSet functions) throws SQLException { - validateUdfExample(functions, 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, ObjectReference.of("OTHER_SCHEMA", "PSQL$EXAMPLE2").toString()); + rules.put(FunctionMetaData.JB_FUNCTION_SOURCE, """ + begin + return X+1; + end"""); + rules.put(FunctionMetaData.JB_FUNCTION_KIND, "PSQL"); + return rules; } - private void validateUdfExample(ResultSet functions, boolean useCatalogAsPackage) throws SQLException { + 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, "UDF$EXAMPLE"); + rules.put(FunctionMetaData.SPECIFIC_NAME, ifSchemaElse( + ObjectReference.of("PUBLIC", "UDF$EXAMPLE").toString(), "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"); - rules.put(FunctionMetaData.SPECIFIC_NAME, "\"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"); - getFunctionsDefinition.validateRowValues(functions, rules); + + return rules; } private static void expectNextFunction(ResultSet rs) throws SQLException { @@ -338,9 +447,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); @@ -350,7 +460,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); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataImportedKeysTest.java index ad64ea532..cafe2fa7e 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; @@ -8,10 +8,14 @@ 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; + /** * Tests for {@link java.sql.DatabaseMetaData#getImportedKeys(String, String, String)}. * @@ -26,28 +30,54 @@ void testExportedKeysMetaDataColumns() throws Exception { } } - @ParameterizedTest + @ParameterizedTest(name = "({0}, {1})") @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); } } static Stream 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) { - 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); + } + + @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); } } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java index 99cb63d1b..0b7b79497 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataIndexInfoTest.java @@ -1,14 +1,17 @@ -// 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; 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; @@ -26,6 +29,8 @@ 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.FbAssumptions.assumeFeature; import static org.firebirdsql.common.JdbcResourceHelper.closeQuietly; import static org.firebirdsql.common.matchers.MatcherAssume.assumeThat; import static org.hamcrest.Matchers.equalTo; @@ -82,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); @@ -119,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; } @@ -139,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)); @@ -153,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 { @@ -178,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 { @@ -207,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 { @@ -230,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 @@ -241,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); } @@ -262,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 @@ -283,9 +315,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); @@ -304,6 +343,7 @@ private Map createRule(String tableName, boolean nonU * filter condition value * @return {@code rule} */ + @SuppressWarnings("SameParameterValue") private Map withFilterCondition(Map rule, String filterCondition) { rule.put(IndexInfoMetaData.FILTER_CONDITION, filterCondition); @@ -314,7 +354,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); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java index 85b661c73..836b443ec 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPrimaryKeysTest.java @@ -1,18 +1,22 @@ -// 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; 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,6 +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; @@ -33,39 +40,9 @@ class FBDatabaseMetaDataPrimaryKeysTest { 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); @@ -79,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 { @@ -99,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))); } @@ -141,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); @@ -156,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); @@ -170,6 +209,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); } diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java index 628ae9a5f..afb3ac5f7 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProcedureColumnsTest.java @@ -1,9 +1,10 @@ -// 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; 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; @@ -23,6 +25,9 @@ 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.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.*; @@ -36,8 +41,8 @@ * @author Mark Rotteveel */ class FBDatabaseMetaDataProcedureColumnsTest { - - // 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 = """ @@ -102,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); @@ -145,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; } @@ -172,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); } @@ -271,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); @@ -328,10 +362,10 @@ void testProcedureColumns_useCatalogAsPackage_specificPackageProcedureColumn(Str dbmd = connection.getMetaData(); List> expectedColumns = - withCatalog("WITH$PROCEDURE", - withSpecificName("\"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); @@ -347,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); @@ -358,15 +396,47 @@ void testProcedureColumns_useCatalogAsPackage_nonPackagedOnly() throws Exception } private static List> getInPackage_allColumns() { - return withCatalog("WITH$PROCEDURE", - withSpecificName("\"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 @@ -390,11 +460,12 @@ private void validate(ResultSet procedureColumns, List createColumn(String procedureName, String columnName, - int ordinalPosition, boolean nullable, int 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,11 +476,23 @@ private static Map createColumn(String procedur 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) { + 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"; @@ -426,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; @@ -464,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; @@ -507,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()) { @@ -528,6 +631,7 @@ private static Map withRemark(Map withDefault(String defaultDefinition, Map rules) { rules.put(ProcedureColumnMetaData.COLUMN_DEF, defaultDefinition); @@ -554,7 +658,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); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java index ad4e593bd..10c519bbf 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataProceduresTest.java @@ -1,9 +1,10 @@ -// 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; 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; @@ -26,6 +29,8 @@ 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.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; @@ -86,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); @@ -118,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()); @@ -137,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); } @@ -250,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 /** @@ -302,10 +342,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 +363,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); @@ -370,12 +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, "NORMAL_PROC_NO_RETURN"); + rules.put(ProcedureMetaData.SPECIFIC_NAME, ifSchemaElse( + ObjectReference.of("PUBLIC", "NORMAL_PROC_NO_RETURN").toString(), "NORMAL_PROC_NO_RETURN")); return rules; } }, @@ -386,7 +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, "quoted_proc_no_return"); + rules.put(ProcedureMetaData.SPECIFIC_NAME, ifSchemaElse( + ObjectReference.of("PUBLIC", "quoted_proc_no_return").toString(), "quoted_proc_no_return")); return rules; } }, @@ -407,7 +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; diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java index 9e11dc329..679dbdb8e 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataPseudoColumnsTest.java @@ -1,69 +1,75 @@ -// 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; 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.*; 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 { - //@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(); @@ -77,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 { @@ -97,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); } @@ -136,7 +162,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); @@ -249,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"); } } @@ -263,18 +291,36 @@ 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); + } + + 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 +347,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 +363,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); @@ -327,8 +379,15 @@ private Map createDbkeyValidationRules(String tabl return rules; } + @SuppressWarnings("SameParameterValue") 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); 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; + } + } +} diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java index 77c021aed..ec4762d7e 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablePrivilegesTest.java @@ -1,19 +1,22 @@ -// 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; 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; @@ -21,6 +24,9 @@ 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; @@ -38,14 +44,8 @@ class FBDatabaseMetaDataTablePrivilegesTest { 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); @@ -61,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 { @@ -81,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"), @@ -96,13 +130,25 @@ void testTablePrivileges_TBL1_all() throws Exception { createRule("TBL1", SYSDBA, true, "UPDATE"), createRule("TBL1", USER1, false, "UPDATE"), createRule("TBL1", user2, false, "UPDATE")); + } + + @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("TBL1", rules); + 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"), @@ -110,13 +156,97 @@ 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, 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); @@ -124,11 +254,16 @@ private Map createRule(String tableName, String return rules; } - private void validateExpectedColumnPrivileges(String tableNamePattern, + private void validateExpectedColumnPrivileges(String schemaPattern, String tableNamePattern, List> expectedTablePrivileges) throws SQLException { - try (ResultSet tablePrivileges = dbmd.getTablePrivileges(null, null, 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); @@ -140,13 +275,21 @@ private void validateExpectedColumnPrivileges(String tableNamePattern, } } + 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); 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 +306,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; diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java index dd0984f46..fa2d0766b 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTablesTest.java @@ -1,12 +1,17 @@ -// 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; 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; @@ -17,6 +22,9 @@ 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.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; @@ -85,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); @@ -118,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; } @@ -136,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(); @@ -199,42 +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. - *

- * 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. + * 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_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())); @@ -251,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. - *

- * 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. + * 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()); @@ -294,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); } @@ -303,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(); @@ -434,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"); @@ -510,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"); @@ -550,6 +538,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")) { @@ -558,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); @@ -570,7 +562,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); diff --git a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java index 25032199e..cfda01771 100644 --- a/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java +++ b/src/test/org/firebirdsql/jdbc/FBDatabaseMetaDataTest.java @@ -2,13 +2,14 @@ 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; 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; @@ -611,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); @@ -809,6 +776,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()) { diff --git a/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBPreparedStatementGeneratedKeysTest.java index 99e8dab98..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 *

@@ -261,7 +274,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\"")))); } /** @@ -270,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); @@ -302,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); @@ -330,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 /** @@ -338,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); @@ -370,14 +429,17 @@ 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")), - 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/FBResultSetMetaDataParametrizedTest.java b/src/test/org/firebirdsql/jdbc/FBResultSetMetaDataParametrizedTest.java index beaa7f5ef..ae65179d3 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; @@ -38,54 +38,62 @@ * * @author Mark Rotteveel * @since 3.0 + * @see FBResultSetMetaDataTest */ class FBResultSetMetaDataParametrizedTest { 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 */ + , 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("", 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 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"); } } diff --git a/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java b/src/test/org/firebirdsql/jdbc/FBStatementGeneratedKeysTest.java index 1baf8526f..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 *

@@ -302,7 +317,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\"")))); } } @@ -312,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"); @@ -342,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()) { @@ -369,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"); @@ -402,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()) { @@ -424,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 /** @@ -432,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"); @@ -462,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()) { @@ -489,15 +554,18 @@ 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")), - 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"))))); } 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 diff --git a/src/test/org/firebirdsql/jdbc/metadata/NameHelperTest.java b/src/test/org/firebirdsql/jdbc/metadata/NameHelperTest.java index c93f2f878..737421fbd 100644 --- a/src/test/org/firebirdsql/jdbc/metadata/NameHelperTest.java +++ b/src/test/org/firebirdsql/jdbc/metadata/NameHelperTest.java @@ -1,4 +1,4 @@ -// 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; @@ -10,13 +10,17 @@ class NameHelperTest { @ParameterizedTest - @CsvSource(textBlock = """ - , 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