Skip to content

Commit 96ef5e0

Browse files
committed
Harden retained SQL schema compatibility
1 parent f63dbd4 commit 96ef5e0

5 files changed

Lines changed: 226 additions & 11 deletions

File tree

‎AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java‎

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,12 @@ private void committedCleanupFailure(String operation, Exception error) {
169169
/** @return true when the row already existed and still needs the batch UPDATE. */
170170
private boolean ensureRow(Connection connection, Map<String, DataValue> updates) throws SQLException {
171171
if (dialect != Dialect.SQLITE && rowExists(connection)) return true;
172+
Map<String, SqlUserSchema.ColumnDefinition> definitions = retainedDefinitions(connection, updates);
172173
StringBuilder names = new StringBuilder(quote(SqlUserSchema.UUID_COLUMN));
173174
StringBuilder parameters = new StringBuilder("?");
174175
for (String key : updates.keySet()) {
175176
names.append(", ").append(quote(key));
176-
parameters.append(", ").append(parameterExpression(schema.column(key)));
177+
parameters.append(", ").append(parameterExpression(definitions.get(key)));
177178
}
178179
String prefix = dialect == Dialect.SQLITE ? "INSERT OR IGNORE INTO " : "INSERT INTO ";
179180
String sql = prefix + quote(tableName) + " (" + names + ") VALUES (" + parameters + ")";
@@ -182,7 +183,7 @@ private boolean ensureRow(Connection connection, Map<String, DataValue> updates)
182183
try (PreparedStatement statement = connection.prepareStatement(sql)) {
183184
dialect.bindUuid(statement, 1, uuid);
184185
int index = 2;
185-
for (Map.Entry<String, DataValue> entry : updates.entrySet()) bind(statement, index++, entry.getValue(), schema.column(entry.getKey()));
186+
for (Map.Entry<String, DataValue> entry : updates.entrySet()) bind(statement, index++, entry.getValue(), definitions.get(entry.getKey()));
186187
inserted = statement.executeUpdate();
187188
} catch (SQLException insertFailure) {
188189
if (dialect == Dialect.MYSQL && isDuplicateKey(insertFailure) && rowExists(connection)) return true;
@@ -204,18 +205,18 @@ private boolean rowExists(Connection connection) throws SQLException {
204205
}
205206

206207
private void updateValues(Connection connection, Map<String, DataValue> updates) throws SQLException {
208+
Map<String, SqlUserSchema.ColumnDefinition> definitions = retainedDefinitions(connection, updates);
207209
StringBuilder sql = new StringBuilder("UPDATE ").append(quote(tableName)).append(" SET ");
208210
boolean first = true;
209211
for (String key : updates.keySet()) {
210212
if (!first) sql.append(", ");
211213
first = false;
212-
SqlUserSchema.ColumnDefinition definition = schema.column(key);
213-
sql.append(quote(key)).append('=').append(parameterExpression(definition));
214+
sql.append(quote(key)).append('=').append(parameterExpression(definitions.get(key)));
214215
}
215216
sql.append(" WHERE ").append(quote(SqlUserSchema.UUID_COLUMN)).append("=?");
216217
try (PreparedStatement statement = connection.prepareStatement(sql.toString())) {
217218
int index = 1;
218-
for (Map.Entry<String, DataValue> entry : updates.entrySet()) bind(statement, index++, entry.getValue(), schema.column(entry.getKey()));
219+
for (Map.Entry<String, DataValue> entry : updates.entrySet()) bind(statement, index++, entry.getValue(), definitions.get(entry.getKey()));
219220
dialect.bindUuid(statement, index, uuid); statement.executeUpdate();
220221
}
221222
}
@@ -270,26 +271,92 @@ else if (value.isBoolean()) {
270271
} else statement.setObject(index, value.toString());
271272
}
272273

274+
private BooleanStorage booleanStorage(String sqlType) {
275+
String normalized = sqlType.strip().toUpperCase(Locale.ROOT);
276+
if (startsType(normalized, "BOOLEAN") || startsType(normalized, "BOOL")) return BooleanStorage.NATIVE;
277+
if (startsType(normalized, "BIT") || startsType(normalized, "VARBIT")) return dialect == Dialect.POSTGRESQL ? BooleanStorage.POSTGRES_BIT : BooleanStorage.NUMERIC;
278+
if (startsType(normalized, "TINYINT") || startsType(normalized, "SMALLINT") || startsType(normalized, "MEDIUMINT") || startsType(normalized, "INT") || startsType(normalized, "INTEGER") || startsType(normalized, "BIGINT")) return BooleanStorage.NUMERIC;
279+
return BooleanStorage.TEXT;
280+
}
281+
273282
private String parameterExpression(SqlUserSchema.ColumnDefinition definition) {
274-
if (booleanStorage(definition) != BooleanStorage.POSTGRES_BIT) return "?";
283+
BooleanStorage storage = booleanStorage(definition);
284+
if (storage != BooleanStorage.POSTGRES_BIT) return "?";
275285
return "CAST(? AS " + postgresBitType(definition) + ")";
276286
}
277287

288+
private Map<String, SqlUserSchema.ColumnDefinition> retainedDefinitions(Connection connection, Map<String, DataValue> updates) throws SQLException {
289+
Map<String, SqlUserSchema.ColumnDefinition> definitions = new HashMap<>();
290+
for (String key : updates.keySet()) {
291+
SqlUserSchema.ColumnDefinition definition = schema.column(key);
292+
definitions.put(key, retainedDefinition(connection, definition));
293+
}
294+
return definitions;
295+
}
296+
297+
private SqlUserSchema.ColumnDefinition retainedDefinition(Connection connection, SqlUserSchema.ColumnDefinition definition) throws SQLException {
298+
if (definition == null || definition.dataType() != DataType.BOOLEAN || dialect != Dialect.POSTGRESQL) return definition;
299+
java.sql.DatabaseMetaData metadata = connection.getMetaData();
300+
if (metadata == null) return definition;
301+
try (ResultSet columns = metadata.getColumns(null, metadataSchema(connection), tableName, definition.name())) {
302+
if (columns.next()) {
303+
String type = columns.getString("TYPE_NAME");
304+
if (type != null && !type.isBlank()) {
305+
String normalized = type.strip().toUpperCase(Locale.ROOT);
306+
if (startsType(normalized, "BIT") || startsType(normalized, "VARBIT")) {
307+
int width = columns.getInt("COLUMN_SIZE");
308+
if (columns.wasNull() || width <= 0) width = 1;
309+
// PgJDBC exposes this as VARBIT on supported versions, but
310+
// accept the SQL spelling too so preserving a legacy column
311+
// does not accidentally turn BIT VARYING(n) into BIT(n).
312+
String retainedType = postgresVaryingBit(normalized)
313+
? "BIT VARYING(" + width + ")" : "BIT(" + width + ")";
314+
return new SqlUserSchema.ColumnDefinition(definition.name(), retainedType, DataType.BOOLEAN);
315+
}
316+
// The current logical schema may say BOOLEAN while an existing
317+
// server still has a VARCHAR or numeric column. Bind according
318+
// to the retained physical type instead of sending a typed
319+
// boolean that PostgreSQL cannot assign to that column.
320+
return new SqlUserSchema.ColumnDefinition(definition.name(), type, DataType.BOOLEAN);
321+
}
322+
}
323+
}
324+
return definition;
325+
}
326+
327+
private boolean postgresVaryingBit(String normalizedType) {
328+
return startsType(normalizedType, "VARBIT") || normalizedType.matches("^BIT\\s+VARYING(?:\\(\\d+\\))?(?:\\s+.*)?$");
329+
}
330+
331+
private String metadataSchema(Connection connection) throws SQLException {
332+
if (dialect != Dialect.POSTGRESQL) return null;
333+
String regclass = '"' + tableName.replace("\"", "\"\"") + '"';
334+
String sql = "SELECT n.nspname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n "
335+
+ "ON n.oid=c.relnamespace WHERE c.oid=pg_catalog.to_regclass(?)";
336+
try (PreparedStatement statement = connection.prepareStatement(sql)) {
337+
statement.setString(1, regclass);
338+
try (ResultSet result = statement.executeQuery()) {
339+
return result.next() ? result.getString(1) : null;
340+
}
341+
}
342+
}
343+
278344
private String postgresBitType(SqlUserSchema.ColumnDefinition definition) {
279345
String sqlType = definition.sqlType().strip();
280-
int separator = sqlType.indexOf(' ');
281-
String token = separator < 0 ? sqlType : sqlType.substring(0, separator);
282-
if (!token.matches("(?i)BIT(?:\\(\\d+\\))?")) {
346+
java.util.regex.Matcher type = java.util.regex.Pattern
347+
.compile("(?i)^(BIT(?:\\(\\d+\\)|\\s+VARYING(?:\\(\\d+\\))?)?)(?:\\s+.*)?$")
348+
.matcher(sqlType);
349+
if (!type.matches()) {
283350
throw new IllegalArgumentException("Unsupported PostgreSQL bit type: " + definition.sqlType());
284351
}
285-
return token.toUpperCase(Locale.ROOT);
352+
return type.group(1).toUpperCase(Locale.ROOT);
286353
}
287354

288355
private BooleanStorage booleanStorage(SqlUserSchema.ColumnDefinition definition) {
289356
if (definition == null || definition.dataType() != DataType.BOOLEAN) return BooleanStorage.TEXT;
290357
String sqlType = definition.sqlType().strip().toUpperCase(Locale.ROOT);
291358
if (startsType(sqlType, "BOOLEAN") || startsType(sqlType, "BOOL")) return BooleanStorage.NATIVE;
292-
if (startsType(sqlType, "BIT")) return dialect == Dialect.POSTGRESQL ? BooleanStorage.POSTGRES_BIT : BooleanStorage.NUMERIC;
359+
if (startsType(sqlType, "BIT") || startsType(sqlType, "VARBIT")) return dialect == Dialect.POSTGRESQL ? BooleanStorage.POSTGRES_BIT : BooleanStorage.NUMERIC;
293360
if (startsType(sqlType, "TINYINT") || startsType(sqlType, "SMALLINT") || startsType(sqlType, "MEDIUMINT") || startsType(sqlType, "INT") || startsType(sqlType, "INTEGER") || startsType(sqlType, "BIGINT")) return BooleanStorage.NUMERIC;
294361
return BooleanStorage.TEXT;
295362
}

‎AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.ArrayList;
99
import java.util.HashMap;
1010
import java.util.List;
11+
import java.util.Map;
1112
import java.util.Objects;
1213
import java.util.UUID;
1314
import java.util.concurrent.atomic.AtomicBoolean;
@@ -146,6 +147,7 @@ private <T> T withOperation(Supplier<T> operation) {
146147

147148
private void ensureRegisteredColumns() {
148149
table.ensureUuidType();
150+
table.ensureUuidUnique();
149151
for (SqlUserSchema.ColumnDefinition column : schema.columns()) if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) table.ensureColumn(column);
150152
}
151153

@@ -208,6 +210,71 @@ void ensureUuidType() {
208210
} catch (SQLException failure) { throw new IllegalStateException("Failed to initialize SQL UUID column", failure); }
209211
}
210212

213+
void ensureUuidUnique() {
214+
try (Connection connection = getMysql().getConnectionManager().getConnection()) {
215+
java.sql.DatabaseMetaData metadata = connection.getMetaData();
216+
if (metadata == null || hasUniqueUuidConstraint(connection, metadata)) return;
217+
String indexName = tableName + "_uuid_unique";
218+
String sql = "ALTER TABLE " + quote(tableName) + " ADD CONSTRAINT " + quote(indexName)
219+
+ " UNIQUE (" + quote(SqlUserSchema.UUID_COLUMN) + ")";
220+
try (PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); }
221+
catch (SQLException ddlFailure) {
222+
// Another opener may have created the constraint after our metadata
223+
// check. Re-inspect the database before treating that race as fatal.
224+
if (!hasUniqueUuidConstraint(connection, connection.getMetaData())) throw ddlFailure;
225+
}
226+
} catch (SQLException failure) {
227+
throw new IllegalStateException("Failed to initialize SQL UUID uniqueness", failure);
228+
}
229+
}
230+
231+
private boolean hasUniqueUuidConstraint(Connection connection, java.sql.DatabaseMetaData metadata) throws SQLException {
232+
String catalog = metadata.getConnection() == null ? null : metadata.getConnection().getCatalog();
233+
String schema = resolvedMetadataSchema(connection, metadata);
234+
try (ResultSet keys = metadata.getPrimaryKeys(catalog, schema, tableName)) {
235+
String keyName = null; int count = 0; boolean uuid = false;
236+
while (keys.next()) { keyName = keys.getString("PK_NAME"); count++; uuid |= SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(keys.getString("COLUMN_NAME")); }
237+
if (count == 1 && uuid) return true;
238+
}
239+
try (ResultSet indexes = metadata.getIndexInfo(catalog, schema, tableName, true, false)) {
240+
Map<String, Integer> counts = new HashMap<>(); Map<String, Boolean> uuids = new HashMap<>();
241+
while (indexes.next()) {
242+
String name = indexes.getString("INDEX_NAME");
243+
if (name == null) continue;
244+
// PostgreSQL exposes partial indexes through FILTER_CONDITION. They
245+
// only constrain a subset of rows and cannot protect user identity.
246+
if (indexes.getString("FILTER_CONDITION") != null) continue;
247+
String column = indexes.getString("COLUMN_NAME");
248+
counts.merge(name, 1, Integer::sum);
249+
uuids.merge(name, column != null && SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column), Boolean::logicalOr);
250+
}
251+
for (String name : counts.keySet()) if (counts.get(name) == 1 && uuids.getOrDefault(name, false)) return true;
252+
}
253+
return false;
254+
}
255+
256+
/**
257+
* PostgreSQL treats a null schema in DatabaseMetaData calls as a wildcard.
258+
* Resolve the table selected by search_path first, otherwise a same-named
259+
* table in another schema can make us accept the wrong uniqueness metadata.
260+
*/
261+
private String resolvedMetadataSchema(Connection connection, java.sql.DatabaseMetaData metadata) throws SQLException {
262+
if (getDbType() != DbType.POSTGRESQL) return null;
263+
try (PreparedStatement statement = connection.prepareStatement(
264+
"SELECT table_schema FROM information_schema.tables "
265+
+ "WHERE table_name=? AND table_schema=ANY(current_schemas(false)) "
266+
+ "ORDER BY array_position(current_schemas(false), table_schema) LIMIT 1")) {
267+
statement.setString(1, tableName);
268+
try (ResultSet result = statement.executeQuery()) {
269+
if (result.next()) return result.getString(1);
270+
}
271+
}
272+
// Views and unusual metadata implementations may not appear in
273+
// information_schema.tables; getSchema is still narrower than null.
274+
String current = connection.getSchema();
275+
return current == null || current.isBlank() ? metadata.getUserName() : current;
276+
}
277+
211278
void ensureColumn(SqlUserSchema.ColumnDefinition column) {
212279
synchronized (checkColumnLock) {
213280
try {

‎AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ private void ensureRegisteredColumns() throws SQLException {
174174
if (!hasColumn(SqlUserSchema.UUID_COLUMN)) {
175175
throw new SQLException("SQLite user table is missing required UUID column");
176176
}
177+
if (!hasCompatibleUuidType()) {
178+
throw new SQLException("SQLite user table UUID column must use a text-compatible type");
179+
}
177180
if (!hasUniqueUuidConstraint()) {
178181
throw new SQLException("SQLite user table UUID column must be PRIMARY KEY or UNIQUE");
179182
}
@@ -198,6 +201,21 @@ private boolean hasColumn(String name) throws SQLException {
198201
}
199202
}
200203

204+
private boolean hasCompatibleUuidType() throws SQLException {
205+
String sql = "PRAGMA table_info(" + quote(tableName) + ")";
206+
try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql); ResultSet result = statement.executeQuery()) {
207+
while (result.next()) {
208+
if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(result.getString("name"))) continue;
209+
String type = result.getString("type");
210+
if (type == null || type.isBlank()) return true;
211+
String normalized = type.strip().toUpperCase(Locale.ROOT);
212+
return normalized.contains("CHAR") || normalized.contains("CLOB")
213+
|| normalized.contains("TEXT") || normalized.contains("STRING");
214+
}
215+
return false;
216+
}
217+
}
218+
201219
private boolean hasUniqueUuidConstraint() throws SQLException {
202220
String uuid = SqlUserSchema.UUID_COLUMN;
203221
String tableInfo = "PRAGMA table_info(" + quote(tableName) + ")";

‎AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcPostgresBitBooleanTest.java‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import static org.mockito.Mockito.when;
1010

1111
import java.sql.Connection;
12+
import java.sql.DatabaseMetaData;
1213
import java.sql.PreparedStatement;
1314
import java.sql.ResultSet;
1415
import java.sql.ResultSetMetaData;
@@ -95,6 +96,44 @@ void postgresBitUsesExplicitBitCastAndStringBindingOnInsert() throws Exception {
9596
verify(statements.get(1), never()).setInt(2, 1);
9697
}
9798

99+
@Test
100+
void postgresRetainsResolvedSchemaVarbitWidthForBooleanWrites() throws Exception {
101+
Connection connection = mock(Connection.class);
102+
DatabaseMetaData metadata = mock(DatabaseMetaData.class);
103+
PreparedStatement exists = mock(PreparedStatement.class);
104+
PreparedStatement schemaLookup = mock(PreparedStatement.class);
105+
PreparedStatement insert = mock(PreparedStatement.class);
106+
ResultSet missing = mock(ResultSet.class);
107+
ResultSet resolvedSchema = mock(ResultSet.class);
108+
ResultSet retainedColumn = mock(ResultSet.class);
109+
when(connection.getAutoCommit()).thenReturn(true);
110+
when(connection.getMetaData()).thenReturn(metadata);
111+
when(exists.executeQuery()).thenReturn(missing);
112+
when(schemaLookup.executeQuery()).thenReturn(resolvedSchema);
113+
when(resolvedSchema.next()).thenReturn(true);
114+
when(resolvedSchema.getString(1)).thenReturn("tenant");
115+
when(retainedColumn.next()).thenReturn(true);
116+
when(retainedColumn.getString("TYPE_NAME")).thenReturn("bit varying");
117+
when(retainedColumn.getInt("COLUMN_SIZE")).thenReturn(5);
118+
when(retainedColumn.wasNull()).thenReturn(false);
119+
when(metadata.getColumns(null, "tenant", "Users", "Flag")).thenReturn(retainedColumn);
120+
when(connection.prepareStatement("SELECT 1 FROM \"Users\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE"))
121+
.thenReturn(exists);
122+
when(connection.prepareStatement("SELECT n.nspname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n "
123+
+ "ON n.oid=c.relnamespace WHERE c.oid=pg_catalog.to_regclass(?)")).thenReturn(schemaLookup);
124+
when(connection.prepareStatement("INSERT INTO \"Users\" (\"uuid\", \"Flag\") VALUES (?, CAST(? AS BIT VARYING(5))) ON CONFLICT (\"uuid\") DO NOTHING"))
125+
.thenReturn(insert);
126+
when(insert.executeUpdate()).thenReturn(1);
127+
SqlUserSchema schema = SqlUserSchema.builder().column("Flag", "BOOLEAN", DataType.BOOLEAN).build();
128+
JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema,
129+
() -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
130+
131+
storage.write(UserStorage.MYSQL, "Flag", new DataValueBoolean(true));
132+
133+
verify(metadata).getColumns(null, "tenant", "Users", "Flag");
134+
verify(insert).setString(2, "1");
135+
}
136+
98137
@Test
99138
void postgresBitReadsOneAsTrue() throws Exception {
100139
Connection connection = mock(Connection.class);

0 commit comments

Comments
 (0)