diff --git a/README.md b/README.md index 565fb7e..be1044c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,14 @@ ALTER TABLE data_analysis_schedule ALTER COLUMN last_status TYPE TEXT; The metadata-generated create script includes these fields for new installations; it does not migrate existing databases. Fix older placeholder definitions (such as `{}` for a column profile) to valid typed requests. Edit existing enabled schedules through core CRUD to validate recipients and initialize `next_run_at`. +Installations created with the earlier generated constraint also need a new host migration after ensuring the read-owner projection is populated. SQL CHECK constraints accept UNKNOWN, so `shared = TRUE` allowed a null shared flag and null read owner. Only explicit sharing may omit the read owner: + +```sql +ALTER TABLE data_analysis_saved + DROP CONSTRAINT IF EXISTS ck_data_analysis_saved_read_owner, + ADD CONSTRAINT ck_data_analysis_saved_read_owner CHECK (shared IS TRUE OR read_owner_user_id IS NOT NULL); +``` + Minimum group size defaults to 0 (off). Hosts should set it for personal-data tables. Groups, bins and distinct listings below N are merged into an Other row with no value or drill. Min/max, percentiles, mean and standard deviation are suppressed when the assessed population is below N. Outlier listings and their count's drill are removed whenever the guard is enabled. The guard runs before chart construction and before `result_json` is stored. Source fields must be present in personalized metadata, pass `FieldSecurityLockHelper`, and pass the configured allow/exclude lists. FK labels are queried with enforcement before core PVS formatting; unreadable related records or label fields fall back to the source id. This avoids RC.5's ordinary table-PVS translator's trusted permission bypass. diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisSchema.java b/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisSchema.java index ff1ed94..b8d02f1 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisSchema.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisSchema.java @@ -63,7 +63,7 @@ public final class DataAnalysisSchema // a null read-owner projection means "shared": make the database refuse it // // on a private row, so a row written outside QQQ fails closed, not open // /////////////////////////////////////////////////////////////////////////////// - columns.add(" CONSTRAINT ck_data_analysis_saved_read_owner CHECK (shared = TRUE OR read_owner_user_id IS NOT NULL)"); + columns.add(" CONSTRAINT ck_data_analysis_saved_read_owner CHECK (shared IS TRUE OR read_owner_user_id IS NOT NULL)"); } statements.add("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", columns) + "\n)"); if(table.getUniqueKeys() != null) diff --git a/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisMetadataTest.java b/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisMetadataTest.java index 12571a9..7834f91 100644 --- a/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisMetadataTest.java +++ b/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisMetadataTest.java @@ -213,7 +213,7 @@ class DataAnalysisMetadataTest "modify_date TIMESTAMP WITHOUT TIME ZONE", "rows_scanned BIGINT", "result_json TEXT", "shared BOOLEAN", "day INTEGER", "owner_user_id VARCHAR(250) NOT NULL", "date_field VARCHAR(250) NOT NULL", "last_value NUMERIC", "last_message TEXT", "CREATE UNIQUE INDEX IF NOT EXISTS uq_data_analysis_table_config_1 ON data_analysis_table_config (table_name)", "CREATE UNIQUE INDEX IF NOT EXISTS uq_data_analysis_saved_star_1 ON data_analysis_saved_star (saved_id, user_id)", - "CHECK (shared = TRUE OR read_owner_user_id IS NOT NULL)"); + "CHECK (shared IS TRUE OR read_owner_user_id IS NOT NULL)"); assertThat(ddl).endsWith(";\n"); } } diff --git a/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisSchemaTest.java b/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisSchemaTest.java new file mode 100644 index 0000000..4cf1564 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisSchemaTest.java @@ -0,0 +1,88 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.dataanalysis; + + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Execute generated saved-analysis DDL on H2 in PostgreSQL mode, including SQL's three-valued + ** CHECK semantics. These direct writes intentionally bypass SavedCustomizer. + *******************************************************************************/ +class DataAnalysisSchemaTest +{ + /******************************************************************************* + ** A null read owner grants shared reads, so only an explicit true shared flag + ** may omit it. Null and omitted flags must fail closed like false. + *******************************************************************************/ + @Test + void savedAnalysisRequiresReadOwnerUnlessExplicitlyShared() throws Exception + { + try(Connection connection = DriverManager.getConnection("jdbc:h2:mem:saved_schema_" + UUID.randomUUID() + ";MODE=PostgreSQL;DATABASE_TO_UPPER=FALSE"); + var statement = connection.createStatement()) + { + for(String sql : DataAnalysisSchema.postgresScript(new DataAnalysisQBitConfig().withBackendName("database")).split(";")) + { + if(sql.stripLeading().startsWith("CREATE TABLE IF NOT EXISTS data_analysis_saved (")) + { + statement.execute(sql); + } + } + + assertThatThrownBy(() -> statement.executeUpdate("INSERT INTO data_analysis_saved (name, table_name, kind, definition_json, owner_user_id) VALUES ('omitted flag', 'test_table', 'PIVOT', '{}', 'alice')")) + .isInstanceOf(SQLException.class).hasMessageContaining("ck_data_analysis_saved_read_owner"); + assertThatThrownBy(() -> insert(connection, null, null)).isInstanceOf(SQLException.class).hasMessageContaining("ck_data_analysis_saved_read_owner"); + assertThatThrownBy(() -> insert(connection, false, null)).isInstanceOf(SQLException.class).hasMessageContaining("ck_data_analysis_saved_read_owner"); + + insert(connection, true, null); + insert(connection, false, "alice"); + insert(connection, null, "alice"); + try(var rows = statement.executeQuery("SELECT COUNT(*) FROM data_analysis_saved")) + { + assertThat(rows.next()).isTrue(); + assertThat(rows.getInt(1)).isEqualTo(3); + } + } + } + + + + /******************************************************************************* + ** Nullable values are bound explicitly so no Java boolean conversion can hide + ** the SQL null case that caused the original constraint to admit private rows. + *******************************************************************************/ + private static void insert(Connection connection, Boolean shared, String readOwner) throws SQLException + { + try(var statement = connection.prepareStatement("INSERT INTO data_analysis_saved (name, table_name, kind, definition_json, owner_user_id, shared, read_owner_user_id) VALUES ('schema test', 'test_table', 'PIVOT', '{}', 'alice', ?, ?)")) + { + statement.setObject(1, shared); + statement.setString(2, readOwner); + statement.executeUpdate(); + } + } +}