diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java index e472ff238..2b71a6a76 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java @@ -302,7 +302,14 @@ private DataValue readValue(ResultSet result, int index, DataType type) throws S private void bind(PreparedStatement statement, int index, DataValue value, SqlUserSchema.ColumnDefinition definition) throws SQLException { if (value == null) statement.setObject(index, null); - else if (value.isString()) statement.setString(index, value.getString()); + else if (value.isString()) { + // PostgreSQL's unknown parameter type lets the destination column + // parse the stored representation. This supports both ordinary text + // columns and deliberately retained numeric/boolean SQL columns whose + // public AdvancedCore value contract remains STRING. + if (dialect == Dialect.POSTGRESQL) statement.setObject(index, value.getString(), Types.OTHER); + else statement.setString(index, value.getString()); + } else if (value.isInt()) { // PostgreSQL's unspecified parameter type is inferred from the target // column. This preserves writes to both current integer columns and diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java index c29c3f733..e94cef04f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java @@ -353,25 +353,37 @@ private void renamePostgresColumn(String storedName, String requestedName) throw private void migrateRetainedColumnToString(String storedName, SqlUserSchema.ColumnDefinition definition) throws SQLException { - int jdbcType = registeredColumnType(storedName); - if (!isNumericOrBoolean(jdbcType)) return; + // DataType.STRING describes the Java value API, but plugins may deliberately + // retain a numeric SQL representation (for example an epoch millisecond). + // In that case the existing numeric column already matches the requested + // schema and must not be reconciled as a legacy numeric-to-text column. + RegisteredColumnType registeredType = registeredColumnType(storedName); + boolean declaredStorageType = declaredTypeIsNumericOrBoolean(definition.sqlType()); + if (declaredStorageType && getDbType() == DbType.POSTGRESQL + && declaredTypeMatches(definition.sqlType(), registeredType)) return; + if (!isNumericOrBoolean(registeredType.jdbcType())) return; String column = quote(storedName); if (getDbType() != DbType.POSTGRESQL) { MysqlColumnAttributes attributes; - try { attributes = mysqlColumnAttributes(storedName); } + try { + attributes = mysqlColumnAttributes(storedName); + if (declaredStorageType + && mysqlDeclaredTypeMatches(definition.sqlType(), registeredType, attributes)) return; + validateMysqlMigrationAttributes(storedName, attributes); + } catch (SQLException inspectionFailure) { - if (migrationCompletedByPeer(storedName, inspectionFailure)) return; + if (migrationCompletedByPeer(storedName, definition, inspectionFailure)) return; throw inspectionFailure; } // Database metadata and information_schema are read through separate // connections. A peer can finish the ALTER between those reads. - if (!retainedColumnNeedsStringMigration(storedName)) return; + if (!retainedColumnNeedsMigration(storedName, definition)) return; if (attributes.extra() != null && !attributes.extra().isBlank()) { throw new SQLException("Cannot safely migrate SQL column with generated or automatic attributes: " + storedName); } String sql = "ALTER TABLE " + quote(tableName) + " MODIFY COLUMN " + column + " " - + normaliseTypeForDb(definition.sqlType()) + + normaliseTypeForDb(declaredPhysicalType(definition.sqlType())) + (attributes.nullable() ? " NULL" : " NOT NULL") + (attributes.defaultValue() == null ? "" : " DEFAULT '" + quoteMysqlLiteral(attributes.defaultValue()) + "'") @@ -382,34 +394,38 @@ private void migrateRetainedColumnToString(String storedName, PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); } catch (SQLException ddlFailure) { - if (!migrationCompletedByPeer(storedName, ddlFailure)) throw ddlFailure; + if (!migrationCompletedByPeer(storedName, definition, ddlFailure)) throw ddlFailure; } return; } String defaultExpression = postgresColumnDefault(storedName); + String targetType = normaliseTypeForDb(declaredPhysicalType(definition.sqlType())); StringBuilder sql = new StringBuilder("ALTER TABLE ").append(quote(tableName)); // PostgreSQL does not apply TYPE ... USING to a column default. Drop and // recreate it in the same transactional ALTER TABLE so a legacy numeric // DEFAULT does not make an otherwise-safe value conversion fail. if (defaultExpression != null) sql.append(" ALTER COLUMN ").append(column).append(" DROP DEFAULT,"); sql.append(" ALTER COLUMN ").append(column).append(" TYPE ") - .append(normaliseTypeForDb(definition.sqlType())).append(" USING ").append(column).append("::text"); + .append(targetType).append(" USING ").append(column).append("::text") + .append(declaredStorageType ? "::" + targetType : ""); if (defaultExpression != null) sql.append(", ALTER COLUMN ").append(column) - .append(" SET DEFAULT (").append(defaultExpression).append(")::text"); + .append(" SET DEFAULT (").append(defaultExpression).append(")::text") + .append(declaredStorageType ? "::" + targetType : ""); sql.append(';'); try (Connection connection = getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement(sql.toString())) { statement.executeUpdate(); } catch (SQLException ddlFailure) { - if (!migrationCompletedByPeer(storedName, ddlFailure)) throw ddlFailure; + if (!migrationCompletedByPeer(storedName, definition, ddlFailure)) throw ddlFailure; } } - private boolean migrationCompletedByPeer(String storedName, SQLException failure) throws SQLException { + private boolean migrationCompletedByPeer(String storedName, SqlUserSchema.ColumnDefinition definition, + SQLException failure) throws SQLException { long deadline = System.nanoTime() + PEER_MIGRATION_GRACE_NANOS; do { try { - if (!retainedColumnNeedsStringMigration(storedName)) return true; + if (!retainedColumnNeedsMigration(storedName, definition)) return true; } catch (SQLException inspectionFailure) { if (inspectionFailure != failure) failure.addSuppressed(inspectionFailure); } @@ -425,8 +441,17 @@ private boolean migrationCompletedByPeer(String storedName, SQLException failure } while (true); } - private boolean retainedColumnNeedsStringMigration(String storedName) throws SQLException { - return isNumericOrBoolean(registeredColumnType(storedName)); + private boolean retainedColumnNeedsMigration(String storedName, + SqlUserSchema.ColumnDefinition definition) throws SQLException { + RegisteredColumnType registered = registeredColumnType(storedName); + if (declaredTypeIsNumericOrBoolean(definition.sqlType())) { + if (getDbType() != DbType.POSTGRESQL) { + return !mysqlDeclaredTypeMatches(definition.sqlType(), registered, + mysqlColumnAttributes(storedName)); + } + return !declaredTypeMatches(definition.sqlType(), registered); + } + return isNumericOrBoolean(registered.jdbcType()); } private static boolean isNumericOrBoolean(int jdbcType) { @@ -438,8 +463,127 @@ private static boolean isNumericOrBoolean(int jdbcType) { || jdbcType == java.sql.Types.BIT; } + private static boolean declaredTypeIsNumericOrBoolean(String sqlType) { + String normalized = sqlType == null ? "" : sqlType.stripLeading().toUpperCase(java.util.Locale.ROOT); + int separator = normalized.indexOf(' '); + int parameters = normalized.indexOf('('); + int end = separator < 0 ? normalized.length() : separator; + if (parameters >= 0 && parameters < end) end = parameters; + String baseType = normalized.substring(0, end); + return baseType.equals("TINYINT") || baseType.equals("SMALLINT") || baseType.equals("MEDIUMINT") + || baseType.equals("INT") || baseType.equals("INTEGER") || baseType.equals("BIGINT") + || baseType.equals("REAL") || baseType.equals("FLOAT") || baseType.equals("DOUBLE") + || baseType.equals("NUMERIC") || baseType.equals("DECIMAL") || baseType.equals("DEC") + || baseType.equals("BOOLEAN") + || baseType.equals("BOOL") || baseType.equals("BIT"); + } + + private static boolean declaredTypeMatches(String sqlType, RegisteredColumnType registered) { + String declared = normalizedBaseType(sqlType); + String actual = normalizedBaseType(registered.typeName()); + if (actual.isEmpty()) actual = switch (registered.jdbcType()) { + case java.sql.Types.TINYINT -> "TINYINT"; + case java.sql.Types.SMALLINT -> "SMALLINT"; + case java.sql.Types.INTEGER -> "INTEGER"; + case java.sql.Types.BIGINT -> "BIGINT"; + case java.sql.Types.REAL -> "REAL"; + case java.sql.Types.FLOAT -> "FLOAT"; + case java.sql.Types.DOUBLE -> "DOUBLE"; + case java.sql.Types.NUMERIC -> "NUMERIC"; + case java.sql.Types.DECIMAL -> "DECIMAL"; + case java.sql.Types.BOOLEAN -> "BOOLEAN"; + case java.sql.Types.BIT -> "BIT"; + default -> ""; + }; + declared = canonicalType(declared); + actual = canonicalType(actual); + if (!declared.equals(actual)) return false; + int[] parameters = declaredTypeParameters(sqlType); + if (parameters.length > 0 && registered.precision() > 0 && parameters[0] != registered.precision()) return false; + return parameters.length < 2 || registered.scale() < 0 || parameters[1] == registered.scale(); + } + + private static boolean mysqlDeclaredTypeMatches(String sqlType, RegisteredColumnType registered, + MysqlColumnAttributes attributes) { + String declared = declaredPhysicalType(sqlType).toUpperCase(java.util.Locale.ROOT); + String actual = attributes.columnType() == null ? "" + : attributes.columnType().toUpperCase(java.util.Locale.ROOT); + RegisteredColumnType actualType = new RegisteredColumnType(registered.jdbcType(), + attributes.columnType(), registered.precision(), registered.scale()); + boolean mysqlBooleanAlias = "BOOLEAN".equals(canonicalType(normalizedBaseType(declared))) + && "TINYINT".equals(normalizedBaseType(actual)) + && java.util.Arrays.equals(declaredTypeParameters(actual), new int[] { 1 }); + String declaredBase = canonicalType(normalizedBaseType(declared)); + if (!mysqlBooleanAlias && !declaredTypeMatches(declaredBase, actualType)) return false; + int[] declaredParameters = comparableDeclaredMysqlTypeParameters(declared); + if (!mysqlBooleanAlias && declaredParameters.length > 0 && !mysqlIntegerType(declaredBase)) { + int[] actualParameters = declaredTypeParameters(actual); + if (actualParameters.length > 0) { + if (!java.util.Arrays.equals(declaredParameters, actualParameters)) return false; + } else if (declaredParameters[0] != registered.precision() + || declaredParameters.length > 1 && declaredParameters[1] != registered.scale()) return false; + } + return declared.matches(".*\\bUNSIGNED\\b.*") == actual.matches(".*\\bUNSIGNED\\b.*") + && declared.matches(".*\\bZEROFILL\\b.*") == actual.matches(".*\\bZEROFILL\\b.*"); + } + + private static boolean mysqlIntegerType(String type) { + return type.equals("TINYINT") || type.equals("SMALLINT") || type.equals("MEDIUMINT") + || type.equals("INTEGER") || type.equals("BIGINT"); + } + + private static int[] comparableDeclaredMysqlTypeParameters(String sqlType) { + int[] parameters = declaredTypeParameters(sqlType); + if (!"DECIMAL".equals(canonicalType(normalizedBaseType(sqlType)))) return parameters; + return new int[] { parameters.length > 0 ? parameters[0] : 10, + parameters.length > 1 ? parameters[1] : 0 }; + } + + private static String declaredPhysicalType(String sqlType) { + if (sqlType == null) return ""; + return sqlType.strip().replaceFirst( + "(?i)\\s+(?=DEFAULT\\b|NOT\\s+NULL\\b|NULL\\b|PRIMARY\\s+KEY\\b|UNIQUE\\b|COMMENT\\b|REFERENCES\\b|CHECK\\b).*$", + ""); + } + + private static String normalizedBaseType(String sqlType) { + String normalized = sqlType == null ? "" : sqlType.stripLeading().toUpperCase(java.util.Locale.ROOT); + int space = normalized.indexOf(' '); + int parenthesis = normalized.indexOf('('); + int end = space < 0 ? normalized.length() : space; + if (parenthesis >= 0 && parenthesis < end) end = parenthesis; + return normalized.substring(0, end); + } + + private static String canonicalType(String type) { + return switch (type) { + case "INT", "INT4" -> "INTEGER"; + case "INT2" -> "SMALLINT"; + case "INT8" -> "BIGINT"; + case "BOOL" -> "BOOLEAN"; + case "DEC", "NUMERIC" -> "DECIMAL"; + case "FLOAT4" -> "REAL"; + case "FLOAT8" -> "DOUBLE"; + default -> type; + }; + } + + private static int[] declaredTypeParameters(String sqlType) { + if (sqlType == null) return new int[0]; + String physicalType = declaredPhysicalType(sqlType); + int open = physicalType.indexOf('('); + int close = open < 0 ? -1 : physicalType.indexOf(')', open + 1); + if (open < 0 || close < 0) return new int[0]; + String[] values = physicalType.substring(open + 1, close).split(","); + try { + int[] parsed = new int[values.length]; + for (int i = 0; i < values.length; i++) parsed[i] = Integer.parseInt(values[i].trim()); + return parsed; + } catch (NumberFormatException ignored) { return new int[0]; } + } + private MysqlColumnAttributes mysqlColumnAttributes(String name) throws SQLException { - String sql = "SELECT IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT " + String sql = "SELECT IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT, COLUMN_TYPE " + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?"; try (Connection connection = getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { @@ -449,29 +593,47 @@ private MysqlColumnAttributes mysqlColumnAttributes(String name) throws SQLExcep if (!result.next()) throw new SQLException( "Registered SQL column disappeared during attribute inspection: " + name); String nullable = result.getString(1); - if (!"YES".equalsIgnoreCase(nullable) && !"NO".equalsIgnoreCase(nullable)) { - throw new SQLException("Cannot determine SQL column nullability during migration: " + name); - } - String defaultValue = result.getString(2); - if (defaultValue != null && !defaultValue.matches( - "(?i)(?:true|false|[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?)")) { - throw new SQLException("Cannot safely preserve SQL column default during migration: " + name); - } + String defaultValue = normalizeMysqlDefault(result.getString(2)); String comment = result.getString(4); - if (comment != null && comment.indexOf('\\') >= 0) { - throw new SQLException("Cannot safely preserve SQL column comment during migration: " + name); - } - return new MysqlColumnAttributes("YES".equalsIgnoreCase(nullable), - defaultValue, result.getString(3), comment); + return new MysqlColumnAttributes(nullable, + defaultValue, result.getString(3), comment, result.getString(5)); } } } + private static String normalizeMysqlDefault(String raw) { + if (raw == null) return null; + String value = raw.strip(); + if ("NULL".equalsIgnoreCase(value)) return null; + if (value.length() >= 2 && value.charAt(0) == '\'' && value.charAt(value.length() - 1) == '\'') { + return value.substring(1, value.length() - 1).replace("''", "'"); + } + return value; + } + + private static void validateMysqlMigrationAttributes(String name, MysqlColumnAttributes attributes) + throws SQLException { + if (!"YES".equalsIgnoreCase(attributes.nullableValue()) + && !"NO".equalsIgnoreCase(attributes.nullableValue())) { + throw new SQLException("Cannot determine SQL column nullability during migration: " + name); + } + if (attributes.defaultValue() != null && !attributes.defaultValue().matches( + "(?i)(?:true|false|[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?)")) { + throw new SQLException("Cannot safely preserve SQL column default during migration: " + name); + } + if (attributes.comment() != null && attributes.comment().indexOf('\\') >= 0) { + throw new SQLException("Cannot safely preserve SQL column comment during migration: " + name); + } + } + private static String quoteMysqlLiteral(String value) { return value.replace("'", "''"); } - private record MysqlColumnAttributes(boolean nullable, String defaultValue, String extra, String comment) { } + private record MysqlColumnAttributes(String nullableValue, String defaultValue, String extra, String comment, + String columnType) { + boolean nullable() { return "YES".equalsIgnoreCase(nullableValue); } + } private String postgresColumnDefault(String name) throws SQLException { String regclass = '"' + tableName.replace("\"", "\"\"") + '"'; @@ -491,19 +653,22 @@ private String postgresColumnDefault(String name) throws SQLException { } } - private int registeredColumnType(String name) throws SQLException { + private RegisteredColumnType registeredColumnType(String name) throws SQLException { try (Connection connection = getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement( "SELECT * FROM " + quote(tableName) + " WHERE 1=0"); ResultSet result = statement.executeQuery()) { ResultSetMetaData metadata = result.getMetaData(); for (int i = 1; i <= metadata.getColumnCount(); i++) { - if (name.equals(metadata.getColumnName(i))) return metadata.getColumnType(i); + if (name.equals(metadata.getColumnName(i))) return new RegisteredColumnType(metadata.getColumnType(i), + metadata.getColumnTypeName(i), metadata.getPrecision(i), metadata.getScale(i)); } throw new SQLException("Registered SQL column disappeared during type inspection: " + name); } } + private record RegisteredColumnType(int jdbcType, String typeName, int precision, int scale) { } + private void rememberColumn(SqlUserSchema.ColumnDefinition column) { columns.removeIf(existing -> existing.equalsIgnoreCase(column.name())); columns.add(column.name()); intColumns.removeIf(existing -> existing.equalsIgnoreCase(column.name())); if (column.dataType() == DataType.INTEGER) intColumns.add(column.name()); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java index 5a5cf6a04..e2f864404 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java @@ -19,6 +19,7 @@ import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; +import java.sql.Types; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -30,6 +31,7 @@ import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; import com.bencodez.simpleapi.sql.DataType; import com.bencodez.simpleapi.sql.data.DataValueBoolean; +import com.bencodez.simpleapi.sql.data.DataValueString; import com.bencodez.simpleapi.sql.mysql.ConnectionManager; import com.bencodez.simpleapi.sql.mysql.DbType; import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; @@ -205,6 +207,19 @@ class JdbcSqlUserStorageDialectTest { } } + @Test void postgresqlStringValuesUseTargetInferredTypeForRetainedNumericColumns() throws Exception { + RecordingJdbc jdbc = new RecordingJdbc(); + SqlUserSchema schema = SqlUserSchema.builder() + .column("VoteRemindersLast", "BIGINT", DataType.STRING).build(); + SqlUserStorage user = new JdbcSqlUserStorage(UserStorage.MYSQL, UUID_VALUE, "Users", schema, + () -> jdbc.connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP); + + user.write(UserStorage.MYSQL, "VoteRemindersLast", new DataValueString("1727136000000")); + + verify(jdbc.statements.get(1)).setObject(2, "1727136000000", Types.OTHER); + verify(jdbc.statements.get(1), never()).setString(2, "1727136000000"); + } + private static final class RecordingJdbc { final Connection connection = mock(Connection.class); final List sql = new ArrayList<>(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.java index 6d8d225df..a3d067629 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.java @@ -120,7 +120,7 @@ private void checkTypes(DbType type) throws Exception { Fixture fixture = new Fixture(DbType.MARIADB); fixture.existing.add("VoteRemindersLast"); fixture.numericColumns.add("VoteRemindersLast"); - fixture.columnDefaults.put("VoteRemindersLast", "'0'"); + fixture.columnDefaults.put("VoteRemindersLast", "CURRENT_TIMESTAMP"); fixture.peerMigrationAfterAttributeFailure = true; SqlUserSchema schema = SqlUserSchema.builder() .column("VoteRemindersLast", "TEXT", DataType.STRING).build(); @@ -134,6 +134,191 @@ private void checkTypes(DbType type) throws Exception { fixture.assertClosed(); } + @Test void mariaDbRetainsDeclaredNumericStorageForStringValues() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("VoteRemindersLast"); + fixture.numericColumns.add("VoteRemindersLast"); + fixture.columnTypeNames.put("VoteRemindersLast", "BIGINT"); + fixture.columnDefaults.put("VoteRemindersLast", "'0'"); + SqlUserSchema schema = SqlUserSchema.builder() + .column("VoteRemindersLast", "BIGINT", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertTrue(fixture.numericColumns.contains("VoteRemindersLast")); + assertFalse(fixture.sql.stream().anyMatch(sql -> sql.startsWith( + "ALTER TABLE `Users` MODIFY COLUMN `VoteRemindersLast`"))); + } + fixture.assertClosed(); + } + + @Test void mariaDbWidensRetainedNumericStorageToTheDeclaredType() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("VoteRemindersLast"); + fixture.numericColumns.add("VoteRemindersLast"); + fixture.columnDefaults.put("VoteRemindersLast", "0"); + SqlUserSchema schema = SqlUserSchema.builder() + .column("VoteRemindersLast", "BIGINT", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertTrue(fixture.sql.contains( + "ALTER TABLE `Users` MODIFY COLUMN `VoteRemindersLast` BIGINT NULL DEFAULT '0';")); + } + fixture.assertClosed(); + } + + @Test void mariaDbNormalizesQuotedNumericDefaultWhileWidening() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("VoteRemindersLast"); + fixture.numericColumns.add("VoteRemindersLast"); + fixture.columnDefaults.put("VoteRemindersLast", "'0'"); + SqlUserSchema schema = SqlUserSchema.builder() + .column("VoteRemindersLast", "BIGINT", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertTrue(fixture.sql.contains( + "ALTER TABLE `Users` MODIFY COLUMN `VoteRemindersLast` BIGINT NULL DEFAULT '0';")); + } + fixture.assertClosed(); + } + + @Test void mariaDbTreatsBareNullDefaultAsNoDefaultWhileWidening() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("VoteRemindersLast"); + fixture.numericColumns.add("VoteRemindersLast"); + fixture.columnDefaults.put("VoteRemindersLast", "NULL"); + SqlUserSchema schema = SqlUserSchema.builder() + .column("VoteRemindersLast", "BIGINT", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertTrue(fixture.sql.contains( + "ALTER TABLE `Users` MODIFY COLUMN `VoteRemindersLast` BIGINT NULL;")); + } + fixture.assertClosed(); + } + + @Test void mariaDbReconcilesRetainedDecimalPrecision() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("Balance"); + fixture.numericColumns.add("Balance"); + fixture.columnTypeNames.put("Balance", "DECIMAL"); + fixture.columnPrecisions.put("Balance", 8); + fixture.columnScales.put("Balance", 2); + SqlUserSchema schema = SqlUserSchema.builder() + .column("Balance", "DECIMAL(12,2)", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertTrue(fixture.sql.contains( + "ALTER TABLE `Users` MODIFY COLUMN `Balance` DECIMAL(12,2) NULL;")); + } + fixture.assertClosed(); + } + + @Test void mariaDbReconcilesSignedBigintWithUnsignedDeclarationWithoutDuplicatingDefault() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("Balance"); + fixture.numericColumns.add("Balance"); + fixture.columnTypeNames.put("Balance", "BIGINT"); + fixture.columnDefaults.put("Balance", "0"); + SqlUserSchema schema = SqlUserSchema.builder() + .column("Balance", "BIGINT UNSIGNED DEFAULT '0'", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertTrue(fixture.sql.contains( + "ALTER TABLE `Users` MODIFY COLUMN `Balance` BIGINT UNSIGNED NULL DEFAULT '0';")); + } + fixture.assertClosed(); + } + + @Test void mariaDbRetainsBooleanAliasAndIgnoresDefaultExpressionParentheses() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.addAll(List.of("Flag", "Sequence")); + fixture.numericColumns.addAll(List.of("Flag", "Sequence")); + fixture.columnTypeNames.put("Flag", "TINYINT(1)"); + fixture.columnTypeNames.put("Sequence", "BIGINT"); + fixture.columnDefaults.put("Sequence", "(0)"); + SqlUserSchema schema = SqlUserSchema.builder() + .column("Flag", "BOOLEAN", DataType.STRING) + .column("Sequence", "BIGINT DEFAULT (0)", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertFalse(fixture.sql.stream().anyMatch(sql -> sql.startsWith("ALTER TABLE"))); + } + fixture.assertClosed(); + } + + @Test void mariaDbTreatsDecAsDecimalAlias() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("Balance"); + fixture.numericColumns.add("Balance"); + fixture.columnTypeNames.put("Balance", "DECIMAL"); + fixture.columnPrecisions.put("Balance", 12); + fixture.columnScales.put("Balance", 2); + SqlUserSchema schema = SqlUserSchema.builder() + .column("Balance", "DEC(12,2)", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertFalse(fixture.sql.stream().anyMatch(sql -> sql.startsWith("ALTER TABLE"))); + } + fixture.assertClosed(); + } + + @Test void mariaDbTreatsOmittedDecimalScaleAsZero() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("Balance"); + fixture.numericColumns.add("Balance"); + fixture.columnTypeNames.put("Balance", "DECIMAL(12,0)"); + fixture.columnPrecisions.put("Balance", 12); + fixture.columnScales.put("Balance", 0); + SqlUserSchema schema = SqlUserSchema.builder() + .column("Balance", "DECIMAL(12)", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertFalse(fixture.sql.stream().anyMatch(sql -> sql.startsWith("ALTER TABLE"))); + } + fixture.assertClosed(); + } + + @Test void mariaDbTreatsBareDecimalAsDecimalTenZero() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("Balance"); + fixture.numericColumns.add("Balance"); + fixture.columnTypeNames.put("Balance", "DECIMAL(10,0)"); + fixture.columnPrecisions.put("Balance", 10); + fixture.columnScales.put("Balance", 0); + SqlUserSchema schema = SqlUserSchema.builder().column("Balance", "DECIMAL", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertFalse(fixture.sql.stream().anyMatch(sql -> sql.startsWith("ALTER TABLE"))); + } + fixture.assertClosed(); + } + + @Test void mariaDbIgnoresIntegerDisplayWidthDuringReconciliation() throws Exception { + Fixture fixture = new Fixture(DbType.MARIADB); + fixture.existing.add("Counter"); + fixture.numericColumns.add("Counter"); + fixture.columnTypeNames.put("Counter", "INT(11)"); + fixture.columnPrecisions.put("Counter", 10); + SqlUserSchema schema = SqlUserSchema.builder() + .column("Counter", "INT(11)", DataType.STRING).build(); + + try (var managers = fixture.managers(); var backend = fixture.open(schema)) { + assertTrue(backend.isOpen()); + assertFalse(fixture.sql.stream().anyMatch(sql -> sql.startsWith("ALTER TABLE"))); + } + fixture.assertClosed(); + } + @Test void mariaDbAcceptsPeerMigrationAfterConflictingAlter() throws Exception { Fixture fixture = new Fixture(DbType.MARIADB); fixture.existing.add("VoteRemindersLast"); @@ -206,6 +391,9 @@ private static final class Fixture { final List booleanColumns = new ArrayList<>(); final List nonNullableColumns = new ArrayList<>(); final java.util.Map columnDefaults = new java.util.HashMap<>(); + final java.util.Map columnTypeNames = new java.util.HashMap<>(); + final java.util.Map columnPrecisions = new java.util.HashMap<>(); + final java.util.Map columnScales = new java.util.HashMap<>(); final List connections = new ArrayList<>(); final List statements = new ArrayList<>(); final List results = new ArrayList<>(); @@ -297,6 +485,8 @@ Connection connection() throws SQLException { when(result.getString(2)).thenReturn(columnDefaults.get(column)); when(result.getString(3)).thenReturn(""); when(result.getString(4)).thenReturn(""); + when(result.getString(5)).thenReturn(columnTypeNames.getOrDefault(column, + numericColumns.contains(column) ? "INT" : "VARCHAR")); } else if (query.startsWith("SELECT pg_catalog.pg_get_expr")) { String column = stringParameters[2]; String defaultValue = columnDefaults.get(column); @@ -308,9 +498,13 @@ Connection connection() throws SQLException { when(metadata.getColumnCount()).thenReturn(existing.size()); for (int i = 0; i < existing.size(); i++) { when(metadata.getColumnName(i + 1)).thenReturn(existing.get(i)); - when(metadata.getColumnType(i + 1)).thenReturn(booleanColumns.contains(existing.get(i)) + String existingColumn = existing.get(i); + when(metadata.getColumnType(i + 1)).thenReturn(booleanColumns.contains(existingColumn) ? java.sql.Types.BOOLEAN : numericColumns.contains(existing.get(i)) - ? java.sql.Types.INTEGER : java.sql.Types.VARCHAR); + ? jdbcType(columnTypeNames.get(existingColumn)) : java.sql.Types.VARCHAR); + when(metadata.getColumnTypeName(i + 1)).thenReturn(columnTypeNames.get(existingColumn)); + when(metadata.getPrecision(i + 1)).thenReturn(columnPrecisions.getOrDefault(existingColumn, 0)); + when(metadata.getScale(i + 1)).thenReturn(columnScales.getOrDefault(existingColumn, 0)); } } return result; @@ -320,6 +514,12 @@ Connection connection() throws SQLException { return connection; } + private static int jdbcType(String typeName) { + if ("BIGINT".equals(typeName)) return java.sql.Types.BIGINT; + if ("DECIMAL".equals(typeName)) return java.sql.Types.DECIMAL; + return java.sql.Types.INTEGER; + } + void assertClosed() throws SQLException { for (Connection connection : connections) verify(connection).close(); for (PreparedStatement statement : statements) verify(statement, atLeastOnce()).close();