From e77f295cbb847ea79a42845785c4133c4c602ae0 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:04:44 -0600 Subject: [PATCH 1/7] Preserve declared numeric string storage --- .../user/storage/sql/MysqlUserBackend.java | 19 +++++++++++++++++++ .../MysqlUserBackendSchemaExpansionTest.java | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) 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..196458d91 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,6 +353,11 @@ private void renamePostgresColumn(String storedName, String requestedName) throw private void migrateRetainedColumnToString(String storedName, SqlUserSchema.ColumnDefinition definition) throws SQLException { + // 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. + if (declaredTypeIsNumericOrBoolean(definition.sqlType())) return; int jdbcType = registeredColumnType(storedName); if (!isNumericOrBoolean(jdbcType)) return; String column = quote(storedName); @@ -438,6 +443,20 @@ 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("BOOLEAN") + || baseType.equals("BOOL") || baseType.equals("BIT"); + } + private MysqlColumnAttributes mysqlColumnAttributes(String name) throws SQLException { String sql = "SELECT IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT " + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?"; 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..c5eba1b2d 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 @@ -134,6 +134,25 @@ 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.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( + "SELECT IS_NULLABLE, COLUMN_DEFAULT"))); + assertFalse(fixture.sql.stream().anyMatch(sql -> sql.startsWith( + "ALTER TABLE `Users` MODIFY COLUMN `VoteRemindersLast`"))); + } + fixture.assertClosed(); + } + @Test void mariaDbAcceptsPeerMigrationAfterConflictingAlter() throws Exception { Fixture fixture = new Fixture(DbType.MARIADB); fixture.existing.add("VoteRemindersLast"); From dcaea5719d4a65691cd08a698fe5a90b8fba9038 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:50:09 -0600 Subject: [PATCH 2/7] Bind retained PostgreSQL values by target type --- .../core/user/storage/sql/JdbcSqlUserStorage.java | 9 ++++++++- .../sql/JdbcSqlUserStorageDialectTest.java | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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/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<>(); From 55c758039ec2d68065ce19674dad4fe99f284ef3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:20:17 -0600 Subject: [PATCH 3/7] Reconcile retained numeric SQL column types --- .../user/storage/sql/MysqlUserBackend.java | 102 +++++++++++++++--- .../MysqlUserBackendSchemaExpansionTest.java | 52 ++++++++- 2 files changed, 137 insertions(+), 17 deletions(-) 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 196458d91..53e3ff5f9 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 @@ -357,20 +357,21 @@ private void migrateRetainedColumnToString(String storedName, // 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. - if (declaredTypeIsNumericOrBoolean(definition.sqlType())) return; - int jdbcType = registeredColumnType(storedName); - if (!isNumericOrBoolean(jdbcType)) return; + RegisteredColumnType registeredType = registeredColumnType(storedName); + boolean declaredStorageType = declaredTypeIsNumericOrBoolean(definition.sqlType()); + if (declaredStorageType && declaredTypeMatches(definition.sqlType(), registeredType)) return; + if (!isNumericOrBoolean(registeredType.jdbcType())) return; String column = quote(storedName); if (getDbType() != DbType.POSTGRESQL) { MysqlColumnAttributes attributes; try { attributes = mysqlColumnAttributes(storedName); } 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); @@ -387,7 +388,7 @@ 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; } @@ -398,23 +399,26 @@ private void migrateRetainedColumnToString(String storedName, // 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(normaliseTypeForDb(definition.sqlType())).append(" USING ").append(column).append("::text") + .append(declaredStorageType ? "::" + normaliseTypeForDb(definition.sqlType()) : ""); 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 ? "::" + normaliseTypeForDb(definition.sqlType()) : ""); 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); } @@ -430,8 +434,13 @@ 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())) { + return !declaredTypeMatches(definition.sqlType(), registered); + } + return isNumericOrBoolean(registered.jdbcType()); } private static boolean isNumericOrBoolean(int jdbcType) { @@ -457,6 +466,66 @@ private static boolean declaredTypeIsNumericOrBoolean(String sqlType) { || 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 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]; + int open = sqlType.indexOf('('); + int close = open < 0 ? -1 : sqlType.indexOf(')', open + 1); + if (open < 0 || close < 0) return new int[0]; + String[] values = sqlType.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 " + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?"; @@ -510,19 +579,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/MysqlUserBackendSchemaExpansionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.java index c5eba1b2d..a9bb1931b 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 @@ -138,6 +138,7 @@ private void checkTypes(DbType type) 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(); @@ -153,6 +154,40 @@ private void checkTypes(DbType type) throws Exception { 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 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 mariaDbAcceptsPeerMigrationAfterConflictingAlter() throws Exception { Fixture fixture = new Fixture(DbType.MARIADB); fixture.existing.add("VoteRemindersLast"); @@ -225,6 +260,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<>(); @@ -327,9 +365,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; @@ -339,6 +381,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(); From c74e766de0908e4ffbdfc9e3aa0ecf2be13661e9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:35:11 -0600 Subject: [PATCH 4/7] Preserve numeric column modifiers during reconciliation --- .../user/storage/sql/MysqlUserBackend.java | 77 ++++++++++++++----- .../MysqlUserBackendSchemaExpansionTest.java | 21 ++++- 2 files changed, 76 insertions(+), 22 deletions(-) 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 53e3ff5f9..e426da0ca 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 @@ -359,12 +359,18 @@ private void migrateRetainedColumnToString(String storedName, // schema and must not be reconciled as a legacy numeric-to-text column. RegisteredColumnType registeredType = registeredColumnType(storedName); boolean declaredStorageType = declaredTypeIsNumericOrBoolean(definition.sqlType()); - if (declaredStorageType && declaredTypeMatches(definition.sqlType(), registeredType)) return; + 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, definition, inspectionFailure)) return; throw inspectionFailure; @@ -377,7 +383,7 @@ private void migrateRetainedColumnToString(String storedName, + 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()) + "'") @@ -393,17 +399,18 @@ private void migrateRetainedColumnToString(String storedName, 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(declaredStorageType ? "::" + normaliseTypeForDb(definition.sqlType()) : ""); + .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(declaredStorageType ? "::" + normaliseTypeForDb(definition.sqlType()) : ""); + .append(declaredStorageType ? "::" + targetType : ""); sql.append(';'); try (Connection connection = getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement(sql.toString())) { @@ -438,6 +445,10 @@ 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()); @@ -491,6 +502,24 @@ private static boolean declaredTypeMatches(String sqlType, RegisteredColumnType return parameters.length < 2 || registered.scale() < 0 || parameters[1] == registered.scale(); } + private static boolean mysqlDeclaredTypeMatches(String sqlType, RegisteredColumnType registered, + MysqlColumnAttributes attributes) { + if (!declaredTypeMatches(sqlType, new RegisteredColumnType(registered.jdbcType(), + attributes.columnType(), registered.precision(), registered.scale()))) return false; + String declared = declaredPhysicalType(sqlType).toUpperCase(java.util.Locale.ROOT); + String actual = attributes.columnType() == null ? "" + : attributes.columnType().toUpperCase(java.util.Locale.ROOT); + return declared.matches(".*\\bUNSIGNED\\b.*") == actual.matches(".*\\bUNSIGNED\\b.*") + && declared.matches(".*\\bZEROFILL\\b.*") == actual.matches(".*\\bZEROFILL\\b.*"); + } + + 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(' '); @@ -527,7 +556,7 @@ private static int[] declaredTypeParameters(String sqlType) { } 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)) { @@ -537,29 +566,37 @@ 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 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 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("\"", "\"\"") + '"'; 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 a9bb1931b..be8340522 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 @@ -146,8 +146,6 @@ private void checkTypes(DbType type) throws Exception { 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( - "SELECT IS_NULLABLE, COLUMN_DEFAULT"))); assertFalse(fixture.sql.stream().anyMatch(sql -> sql.startsWith( "ALTER TABLE `Users` MODIFY COLUMN `VoteRemindersLast`"))); } @@ -188,6 +186,23 @@ private void checkTypes(DbType type) throws Exception { 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 mariaDbAcceptsPeerMigrationAfterConflictingAlter() throws Exception { Fixture fixture = new Fixture(DbType.MARIADB); fixture.existing.add("VoteRemindersLast"); @@ -354,6 +369,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); From 910a19f25a459f8a2a0f32ca50fbd694ecca965f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:41:30 -0600 Subject: [PATCH 5/7] Normalize retained numeric aliases --- .../user/storage/sql/MysqlUserBackend.java | 18 ++++++---- .../MysqlUserBackendSchemaExpansionTest.java | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) 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 e426da0ca..c16cfc711 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 @@ -473,7 +473,8 @@ private static boolean declaredTypeIsNumericOrBoolean(String sqlType) { 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("BOOLEAN") + || baseType.equals("NUMERIC") || baseType.equals("DECIMAL") || baseType.equals("DEC") + || baseType.equals("BOOLEAN") || baseType.equals("BOOL") || baseType.equals("BIT"); } @@ -504,11 +505,15 @@ private static boolean declaredTypeMatches(String sqlType, RegisteredColumnType private static boolean mysqlDeclaredTypeMatches(String sqlType, RegisteredColumnType registered, MysqlColumnAttributes attributes) { - if (!declaredTypeMatches(sqlType, new RegisteredColumnType(registered.jdbcType(), - attributes.columnType(), registered.precision(), registered.scale()))) return false; 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 }); + if (!mysqlBooleanAlias && !declaredTypeMatches(declared, actualType)) return false; return declared.matches(".*\\bUNSIGNED\\b.*") == actual.matches(".*\\bUNSIGNED\\b.*") && declared.matches(".*\\bZEROFILL\\b.*") == actual.matches(".*\\bZEROFILL\\b.*"); } @@ -544,10 +549,11 @@ private static String canonicalType(String type) { private static int[] declaredTypeParameters(String sqlType) { if (sqlType == null) return new int[0]; - int open = sqlType.indexOf('('); - int close = open < 0 ? -1 : sqlType.indexOf(')', open + 1); + 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 = sqlType.substring(open + 1, close).split(","); + 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()); 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 be8340522..90fbc8d51 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 @@ -203,6 +203,41 @@ private void checkTypes(DbType type) throws Exception { 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 mariaDbAcceptsPeerMigrationAfterConflictingAlter() throws Exception { Fixture fixture = new Fixture(DbType.MARIADB); fixture.existing.add("VoteRemindersLast"); From 8d8b282a35317b4b4a7d99bb02701d5c28c8fc4f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:07:31 -0600 Subject: [PATCH 6/7] Normalize MariaDB migration metadata --- .../user/storage/sql/MysqlUserBackend.java | 28 ++++++++++- .../MysqlUserBackendSchemaExpansionTest.java | 50 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) 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 c16cfc711..488479378 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 @@ -513,11 +513,25 @@ private static boolean mysqlDeclaredTypeMatches(String sqlType, RegisteredColumn boolean mysqlBooleanAlias = "BOOLEAN".equals(canonicalType(normalizedBaseType(declared))) && "TINYINT".equals(normalizedBaseType(actual)) && java.util.Arrays.equals(declaredTypeParameters(actual), new int[] { 1 }); - if (!mysqlBooleanAlias && !declaredTypeMatches(declared, actualType)) return false; + String declaredBase = canonicalType(normalizedBaseType(declared)); + if (!mysqlBooleanAlias && !declaredTypeMatches(declaredBase, actualType)) return false; + int[] declaredParameters = declaredTypeParameters(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 String declaredPhysicalType(String sqlType) { if (sqlType == null) return ""; return sqlType.strip().replaceFirst( @@ -572,7 +586,7 @@ 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); - String defaultValue = result.getString(2); + String defaultValue = normalizeMysqlDefault(result.getString(2)); String comment = result.getString(4); return new MysqlColumnAttributes(nullable, defaultValue, result.getString(3), comment, result.getString(5)); @@ -580,6 +594,16 @@ private MysqlColumnAttributes mysqlColumnAttributes(String name) throws SQLExcep } } + 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()) 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 90fbc8d51..14c661176 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(); @@ -168,6 +168,38 @@ private void checkTypes(DbType type) throws Exception { 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"); @@ -238,6 +270,22 @@ private void checkTypes(DbType type) throws Exception { 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"); From 96167c35a8135c9a68ae176220111dc8d4903d86 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:24:26 +0000 Subject: [PATCH 7/7] Normalize MySQL decimal defaults during schema reconciliation --- .../user/storage/sql/MysqlUserBackend.java | 9 ++++- .../MysqlUserBackendSchemaExpansionTest.java | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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 488479378..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 @@ -515,7 +515,7 @@ private static boolean mysqlDeclaredTypeMatches(String sqlType, RegisteredColumn && java.util.Arrays.equals(declaredTypeParameters(actual), new int[] { 1 }); String declaredBase = canonicalType(normalizedBaseType(declared)); if (!mysqlBooleanAlias && !declaredTypeMatches(declaredBase, actualType)) return false; - int[] declaredParameters = declaredTypeParameters(declared); + int[] declaredParameters = comparableDeclaredMysqlTypeParameters(declared); if (!mysqlBooleanAlias && declaredParameters.length > 0 && !mysqlIntegerType(declaredBase)) { int[] actualParameters = declaredTypeParameters(actual); if (actualParameters.length > 0) { @@ -532,6 +532,13 @@ private static boolean mysqlIntegerType(String type) { || 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( 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 14c661176..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 @@ -270,6 +270,39 @@ private void checkTypes(DbType type) throws Exception { 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");