fix(education): validate adopted unique indexes
This commit is contained in:
@@ -75,7 +75,7 @@ mvn -pl yudao-server -am -DskipTests clean compile — BUILD SUCCESS
|
||||
git diff --check — passed
|
||||
```
|
||||
|
||||
V4010 and V4020 retained their pre-ticket SHA-256 values. V4050 was added later as a metadata-only forward migration to document protected snapshot and idempotency state columns. On 2026-07-30, the five-scenario Flyway integration suite migrated and validated V4010 through V4050 against an isolated no-volume PostgreSQL 16 container: 5 tests passed with no failures, errors, or skips. The corrected Education package and server compile also succeeded, and V4010 through V4050 were present under the packaged migration path. No shared or production PostgreSQL database was changed.
|
||||
V4010 and V4020 retained their pre-ticket SHA-256 values. V4050 was added later as a metadata-only forward migration to document protected snapshot and idempotency state columns. V4060 validates every adopted Practice unique index against its required uniqueness and ordered key columns, failing closed when a stale same-named index would weaken tenant or idempotency guarantees. The corrected Education package, PostgreSQL Flyway suite, persistence suite, and server compile are rerun after each forward migration. No shared or production PostgreSQL database was changed.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
|
||||
@@ -184,13 +184,6 @@ public class EducationTenantController {
|
||||
return EducationTenantRespVO.builder().tenantId(tenant.getId()).displayName(tenant.getName()).build();
|
||||
}
|
||||
|
||||
private boolean isLocalHostnameClaim(String hostname) {
|
||||
if (!educationProperties.getTenantResolution().isLocalDevelopmentEnabled() || StrUtil.isBlank(hostname)) {
|
||||
return false;
|
||||
}
|
||||
return isLocalHost(normalizeHost(hostname));
|
||||
}
|
||||
|
||||
static String normalizeHost(String value) {
|
||||
if (StrUtil.isBlank(value)) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Fail closed when an adopted Practice schema already contains a same-named
|
||||
-- index whose uniqueness or ordered key columns differ from the application contract.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
expected RECORD;
|
||||
actual_columns TEXT[];
|
||||
actual_unique BOOLEAN;
|
||||
BEGIN
|
||||
FOR expected IN
|
||||
SELECT * FROM (VALUES
|
||||
('uk_education_practice_session_tenant_client', 'education_practice_session', ARRAY['tenant_id', 'client_session_id']),
|
||||
('uk_education_practice_question_session_sequence', 'education_practice_question', ARRAY['session_id', 'sequence']),
|
||||
('uk_education_practice_report_tenant_session', 'education_practice_report', ARRAY['tenant_id', 'session_id']),
|
||||
('uk_education_practice_report_detail_report_sequence', 'education_practice_report_detail', ARRAY['report_id', 'sequence']),
|
||||
('uk_education_wrong_question_tenant_user_question', 'education_wrong_question', ARRAY['tenant_id', 'user_id', 'question_id']),
|
||||
('uk_education_wrong_idempotency_tenant_user_question_report', 'education_wrong_question_idempotency', ARRAY['tenant_id', 'user_id', 'question_id', 'report_id']),
|
||||
('uk_education_favorite_tenant_user_target', 'education_favorite', ARRAY['tenant_id', 'user_id', 'target_type', 'target_id']),
|
||||
('uk_education_idempotency_tenant_user_operation_key', 'education_idempotency', ARRAY['tenant_id', 'user_id', 'operation', 'idempotency_key'])
|
||||
) AS definitions(index_name, table_name, column_names)
|
||||
LOOP
|
||||
SELECT index_metadata.indisunique,
|
||||
ARRAY_AGG(attribute.attname ORDER BY key_position.ordinality)
|
||||
INTO actual_unique, actual_columns
|
||||
FROM pg_class index_relation
|
||||
JOIN pg_namespace index_namespace ON index_namespace.oid = index_relation.relnamespace
|
||||
JOIN pg_index index_metadata ON index_metadata.indexrelid = index_relation.oid
|
||||
JOIN pg_class table_relation ON table_relation.oid = index_metadata.indrelid
|
||||
JOIN LATERAL UNNEST(index_metadata.indkey::SMALLINT[]) WITH ORDINALITY
|
||||
AS key_position(attribute_number, ordinality) ON true
|
||||
JOIN pg_attribute attribute
|
||||
ON attribute.attrelid = table_relation.oid
|
||||
AND attribute.attnum = key_position.attribute_number
|
||||
WHERE index_namespace.nspname = current_schema()
|
||||
AND index_relation.relname = expected.index_name
|
||||
AND table_relation.relname = expected.table_name
|
||||
GROUP BY index_metadata.indisunique;
|
||||
|
||||
IF actual_unique IS DISTINCT FROM true OR actual_columns IS DISTINCT FROM expected.column_names THEN
|
||||
RAISE EXCEPTION 'Practice unique index % must be UNIQUE on %.%, found unique=% columns=%',
|
||||
expected.index_name, expected.table_name, expected.column_names, actual_unique, actual_columns;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -83,6 +83,31 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
.isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenAdoptedUniqueIndexHasWrongDefinition() throws SQLException {
|
||||
String schema = createSchema("wrong_index");
|
||||
createCompatibleManualPracticeFixture(schema);
|
||||
execute(schema, """
|
||||
CREATE TABLE education_favorite (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
|
||||
target_type VARCHAR(32) NOT NULL, target_id VARCHAR(64) NOT NULL,
|
||||
creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
CREATE INDEX uk_education_favorite_tenant_user_target
|
||||
ON education_favorite (tenant_id, user_id, target_type, target_id);
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> configureFlyway(schema, true).load().migrate())
|
||||
.hasMessageContaining("Practice unique index uk_education_favorite_tenant_user_target")
|
||||
.hasMessageContaining("must be UNIQUE");
|
||||
assertThat(queryLong(schema,
|
||||
"SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4060' AND success = TRUE"))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMigratePlatformSchemaFromBaseline4009() throws SQLException {
|
||||
String schema = createSchema("baseline");
|
||||
@@ -94,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050");
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060");
|
||||
assertThat(queryLong(schema,
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
|
||||
"AND table_name = 'education_idempotency'"))
|
||||
@@ -156,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050");
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060");
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT table_name FROM information_schema.tables " +
|
||||
"WHERE table_schema = current_schema() AND table_name IN (" +
|
||||
|
||||
Reference in New Issue
Block a user