Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbce2e5d6e
|
||
|
|
70f6184a7a
|
||
|
|
16bd4f768f
|
||
|
|
04d04adc6e
|
||
|
|
de922d7e86
|
||
|
|
c6b077321c
|
||
|
|
2837f4152a
|
||
|
|
f0f1db7642
|
||
|
|
c8150bc9cf
|
No files matched your search
@@ -29,6 +29,9 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Fixed
|
||||
|
||||
- Favorites and recents accept a table whose custom permission checker tells a read from a write by the type of the action input. `TargetResolver` asked whether a table is readable with an untyped `AbstractActionInput`; such a checker refuses an input it does not recognise, so `POST /recents` (which the UI sends on every list and record screen) and starring answered `404 Not found` for people the checker lets read the table. The read is now asked as the query it stands for, as `ParentRecordAccess` already asks with a get. Who may read a table is unchanged.
|
||||
- Reads of a table that has no tag assignment no longer query `collaboration_record_tag`. Core selects the Tags field for every query or get that names no fields, so each of them (including per-record gets and the stored-record read before an update) ran one assignment statement whether or not the table had ever been tagged: 600 gets of an untagged table ran 600 statements, now none. Each node remembers which tables have no assignment (`TaggedTables`, core `Memoization` registered with `ClusterCaches`) for `CollaborationQBitConfig.withTagLookupCacheSeconds` (default 30, 0 disables); writing an assignment clears it on the node and through the ClusterBus. Tables with assignments are read as before, one lookup per 500 parents. No API or schema change. See "What the Tags field costs a read" in the README.
|
||||
- Browsing one of the qbit's child tables (comments, tasks, record tags, attachments and the other parent-referencing tables) reads each distinct parent once per result page to authorize its rows, instead of once per row: 61 tasks on two parents ran 61 parent reads, now 2. The check itself and its outcome are unchanged.
|
||||
- The retained upload journal's PostgreSQL 17 qualification accepts Amazon Aurora PostgreSQL, which reports `full_page_writes=off` and does not let a deployment change it. `off` is accepted only when the settings query detects Aurora (`to_regprocedure('aurora_version()') IS NOT NULL`); stock PostgreSQL with `full_page_writes=off` is still refused, and every other requirement is unchanged on Aurora. See `docs/RETAINED-UPLOAD-JOURNAL.md`.
|
||||
- Bounded STRING custom fields carry QQQ's native `ValueTooLongBehavior.ERROR`, so a write longer than `maxLength` is rejected with a field error and the stored value is preserved, instead of passing through unchecked (the QQQ default `PASS_THROUGH`).
|
||||
- Persisted help content is reloaded after each committed runtime metadata refresh, so sections and fields created by publishing a custom-field definition receive their stored `helpContent` rows without a restart.
|
||||
|
||||
@@ -409,7 +409,16 @@ size, pending suggestions, and the five most-used approved tags.
|
||||
|
||||
`collaborationTags` is a virtual STRING field labeled Tags with possible-value source `collaboration_tag`. `TagCriteriaExpander` supports IN, NOT_IN, EQUALS, NOT_EQUALS, IS_BLANK, IS_NOT_BLANK and CONTAINS_ALL using core `QSubQuery` membership filters. Ordinary quick search matches labels; `#name` matches normalized names. Numeric criteria are tag IDs; string criteria are names. The old ID materializer is removed. Core owns SQL execution or bounded cross-backend materialization; its bound applies to inner rows, not final parent matches.
|
||||
|
||||
The runtime provider adds this field and its search entry where TAGS is enabled. It is queryable but not selectable: the overlay has no virtual-field post-query hook and existing host customizers must be preserved.
|
||||
The runtime provider adds this field and its search entry where TAGS is enabled. It is queryable and selectable (`TagValueProvider` fills it after host customizers have run).
|
||||
|
||||
### What the Tags field costs a read
|
||||
|
||||
Core selects every selectable virtual field for a query or get that names no fields, so each such read of a table with TAGS enabled runs `TagValueProvider`, including the reads other code makes one record at a time (a get, the stored-record read before an update, a lookup made per row). The provider therefore costs:
|
||||
|
||||
- nothing for a table that has no tag assignment. Each node remembers that one fact per table (`TaggedTables`) for `withTagLookupCacheSeconds` (default 30; 0 looks every time) and learns it with one indexed `LIMIT 1` statement;
|
||||
- one assignment statement and one vocabulary statement per 500 parents for a table that has assignments. Record tags themselves are never cached.
|
||||
|
||||
A node that writes an assignment forgets the table's answer at once and again after commit, and publishes core's `memo-invalidate` cluster event (`ClusterCaches`, name `collaboration.taggedTables`). Hosts with a ClusterBus transport see a table's first tag on every node immediately; without one, another node shows it after at most the configured seconds. The same bound covers assignments written outside QQQ. The answer does not depend on the session (the probe ignores row locks and returns no data), so it cannot change which records or tags a caller sees. A failed probe is thrown, not remembered.
|
||||
|
||||
## UI API
|
||||
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
No schema change and nothing required of hosts.
|
||||
|
||||
- New table action **Create rows from configuration** on Feature Settings. Run it once if you want the effective feature settings visible and editable as rows. It changes no behavior and does nothing once the table has any row. Note that after rows exist they win over later changes to `withDefaultFeatures` or `withTableFeatures` in code, so change those settings in the rows from then on.
|
||||
- Reads of tables without any tag assignment skip the assignment lookup (`withTagLookupCacheSeconds`, default 30 seconds, 0 restores a lookup on every read). A host running several nodes without a ClusterBus transport shows a table's very first tag on the other nodes after at most that long; later tags on that table show at once, as before.
|
||||
- New **Setup** widget first on the Collaboration home, and table help text. Hosts that override help on a table keep their own.
|
||||
@@ -21,22 +21,22 @@
|
||||
<!-- Only the published Redis API package is a permitted cross-qbit boundary. -->
|
||||
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email.api,com.kingsrook.qbits.redis.api</qbit.guardrails.allowedApiPackages>
|
||||
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:qbit-email,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-redis</qbit.guardrails.allowedQbitArtifacts>
|
||||
<revision>1.1.0-RC.2</revision>
|
||||
<revision>1.2.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<!-- qbit-email from Maven Central -->
|
||||
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
|
||||
<qbit-email.version>0.1.0-RC.11</qbit-email.version>
|
||||
<!-- Core RC.22 (QBitMetaDataProducer, VirtualFieldValueProviderInterface, QServiceUnavailableException); BLOCKED until it resolves from Central/the registry in CI. -->
|
||||
<qqq.version>4.1.0-RC.22</qqq.version>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version>
|
||||
<!-- qbit-redis first release candidate of release/0.1 (producer run 5119 did not publish it yet): BLOCKED, verify the published artifact before pushing. -->
|
||||
<qbit-redis.version>0.1.0-RC.1</qbit-redis.version>
|
||||
<qbit-redis.version>0.2.0-RC.1</qbit-redis.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.1.1</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -101,7 +101,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-object-storage</artifactId>
|
||||
<version>0.1.0-RC.4</version>
|
||||
<version>0.1.0-RC.10</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
@@ -51,6 +51,7 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
private Function<String, String> recipientEmailResolver = null;
|
||||
private QCodeReference userSessionProvider = null;
|
||||
private String defaultTagMode = "FREE";
|
||||
private Integer tagLookupCacheSeconds = 30;
|
||||
private List<String> tagActionTables = List.of();
|
||||
private List<String> followActionTables = List.of();
|
||||
private List<String> followChangeTables = List.of();
|
||||
@@ -325,6 +326,7 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors);
|
||||
}
|
||||
assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors);
|
||||
assertCondition(tagLookupCacheSeconds != null && tagLookupCacheSeconds >= 0, "tagLookupCacheSeconds must be 0 (off) or more", errors);
|
||||
for(List<String> tables : List.of(followActionTables, followChangeTables, tagActionTables, assignActionTables))
|
||||
{
|
||||
for(String table : tables)
|
||||
@@ -550,6 +552,27 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** How long a node remembers whether a table has any tag assignment.
|
||||
*******************************************************************************/
|
||||
public Integer getTagLookupCacheSeconds()
|
||||
{
|
||||
return (tagLookupCacheSeconds);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads of a table without any tag assignment skip the assignment lookup while
|
||||
** this node remembers that (default 30 seconds; 0 looks every time). A node
|
||||
** that writes an assignment forgets at once and tells the others over the
|
||||
** ClusterBus; without a cluster transport another node shows a table's first
|
||||
** tag after at most this long. Tables that have assignments are always read.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withTagLookupCacheSeconds(Integer tagLookupCacheSeconds)
|
||||
{
|
||||
this.tagLookupCacheSeconds = tagLookupCacheSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host tables receiving Add tags and Remove tags actions.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -33,7 +33,8 @@ import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
|
||||
/*******************************************************************************
|
||||
** Identifier-only hints, published after successful commit.
|
||||
** Identifier-only hints, published after successful commit. A tag assignment
|
||||
** also clears what this node remembers about untagged tables (TaggedTables).
|
||||
*******************************************************************************/
|
||||
public class CollaborationRealtime implements RecordChangeListenerInterface
|
||||
{
|
||||
@@ -55,6 +56,7 @@ public class CollaborationRealtime implements RecordChangeListenerInterface
|
||||
public void onRecordsChanged(RecordChangeEvent change) throws QException
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
java.util.Set<String> newlyTaggedTables = new java.util.HashSet<>();
|
||||
for(int index = 0; index < change.getRecords().size(); index++)
|
||||
{
|
||||
var row = new com.kingsrook.qqq.backend.core.model.data.QRecord(change.getRecords().get(index));
|
||||
@@ -70,6 +72,13 @@ public class CollaborationRealtime implements RecordChangeListenerInterface
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(CollaborationTables.RECORD_TAG.equals(change.getTableName()) && change.getType() != RecordChangeType.DELETE && newlyTaggedTables.add(table))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// the Tags field skips tables known to have no assignment: forget it //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
com.kingsrook.qbits.collaboration.tags.TaggedTables.assignmentsChanged(table, change.getTransaction());
|
||||
}
|
||||
String event = switch(change.getTableName())
|
||||
{
|
||||
case CollaborationTables.COMMENT -> change.getType() == RecordChangeType.DELETE || row.getValue("deletedAt") != null
|
||||
|
||||
@@ -288,12 +288,16 @@ public final class TargetResolver
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard existence before invoking the core table permission helper.
|
||||
** Guard existence before invoking the core table permission helper. The read
|
||||
** is asked as the query it stands for: core hands a table's custom permission
|
||||
** checker the action input and not the permission asked, so a checker that
|
||||
** tells a read from a write by the input's type refuses an untyped input, and
|
||||
** its table was then no target for the people it lets read it.
|
||||
*******************************************************************************/
|
||||
private static boolean canReadTable(String table)
|
||||
{
|
||||
return (table != null && QContext.getQInstance().getTable(table) != null
|
||||
&& PermissionsHelper.hasTablePermission(new AbstractActionInput(), table, TablePermissionSubType.READ));
|
||||
&& PermissionsHelper.hasTablePermission(new QueryInput(table), table, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -55,6 +55,7 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
|
||||
List<QRecord> filter(List<QRecord> records, boolean isGet, boolean generateUrls, AttachmentsService service) throws QException
|
||||
{
|
||||
List<QRecord> visible = new ArrayList<>();
|
||||
ParentRecordAccess.ResultSetParents parents = new ParentRecordAccess.ResultSetParents();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
try
|
||||
@@ -63,7 +64,7 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
ParentRecordAccess.requireVisibleParent(record.getValueString("tableName"), record.getValueString("recordId"));
|
||||
parents.requireVisible(record.getValueString("tableName"), record.getValueString("recordId"));
|
||||
}
|
||||
catch(QNotFoundException expected)
|
||||
{
|
||||
|
||||
@@ -81,6 +81,42 @@ public final class ParentRecordAccess
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One result set's parent checks: rows that share a parent cost one lookup.
|
||||
** The session and transaction do not change within the call that creates it,
|
||||
** so never keep an instance beyond that call.
|
||||
*******************************************************************************/
|
||||
public static final class ResultSetParents
|
||||
{
|
||||
private final java.util.Map<java.util.List<String>, Boolean> visible = new java.util.HashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Same outcome as ParentRecordAccess.requireVisibleParent for every row.
|
||||
*******************************************************************************/
|
||||
public void requireVisible(String tableName, String recordId) throws QException
|
||||
{
|
||||
java.util.List<String> parent = java.util.Arrays.asList(tableName, recordId);
|
||||
Boolean known = visible.get(parent);
|
||||
if(known == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
requireVisibleParent(tableName, recordId);
|
||||
known = true;
|
||||
}
|
||||
catch(QNotFoundException expected)
|
||||
{
|
||||
known = false;
|
||||
}
|
||||
visible.put(parent, known);
|
||||
}
|
||||
if(!known)
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core record capability checks include parent WRITE locks for implied grants.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -43,6 +43,7 @@ public class ParentRowCustomizer implements TableCustomizerInterface
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
List<QRecord> visible = new ArrayList<>();
|
||||
ParentRecordAccess.ResultSetParents parents = new ParentRecordAccess.ResultSetParents();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
try
|
||||
@@ -51,7 +52,7 @@ public class ParentRowCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
ParentRecordAccess.requireVisibleParent(record.getValueString("tableName"), record.getValueString("recordId"));
|
||||
parents.requireVisible(record.getValueString("tableName"), record.getValueString("recordId"));
|
||||
QRecord row = new QRecord(record);
|
||||
if(row.getValue("deletedAt") != null)
|
||||
{
|
||||
|
||||
@@ -45,16 +45,26 @@ public class TagValueProvider implements VirtualFieldValueProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Batch both assignments and vocabulary; never recursively load parent records.
|
||||
** A table without any assignment costs no statement: core selects this field
|
||||
** for every read that names no fields, so untagged tables must stay free.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void fill(QTableMetaData table, String fieldName, List<QRecord> records, QueryInput origin) throws QException
|
||||
{
|
||||
if(!TaggedTables.mayHaveAssignments(table.getName(), origin.getTransaction()))
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
record.setValue(fieldName, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
QTableMetaData metadata = QContext.getQInstance().getTable(CollaborationTables.RECORD_TAG).clone();
|
||||
metadata.withCustomizers(null);
|
||||
metadata.getSupplementalMetaData().remove(RecordReferenceTableMetaData.NAME);
|
||||
for(List<QRecord> page : CollectionUtils.getPages(records, 500))
|
||||
{
|
||||
List<String> ids = page.stream().map(record -> String.valueOf(record.getValue(table.getPrimaryKeyField()))).distinct().toList();
|
||||
QTableMetaData metadata = QContext.getQInstance().getTable(CollaborationTables.RECORD_TAG).clone();
|
||||
metadata.withCustomizers(null);
|
||||
metadata.getSupplementalMetaData().remove(RecordReferenceTableMetaData.NAME);
|
||||
QueryInput input = new QueryInput(CollaborationTables.RECORD_TAG).withTransaction(origin.getTransaction()).withFilter(new QQueryFilter()
|
||||
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("recordId", QCriteriaOperator.IN, ids)
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false));
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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.collaboration.tags;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
|
||||
|
||||
/*******************************************************************************
|
||||
** Which host tables have any tag assignment at all. Every table with TAGS
|
||||
** enabled carries the Tags field, so every read of such a table would otherwise
|
||||
** look for assignments, including the tables nobody ever tagged.
|
||||
**
|
||||
** Only that one fact per table is remembered, never a record's tags: a table
|
||||
** known to have assignments is always read from the database. The answer is the
|
||||
** same for every session (the probe ignores row locks and returns no data), so
|
||||
** it cannot widen or narrow what a caller sees.
|
||||
**
|
||||
** Each node keeps its own answers. An assignment written here clears the
|
||||
** table's answer on this node at once and again after commit, and on the other
|
||||
** nodes through the ClusterBus (memo-invalidate). The timeout bounds everything
|
||||
** that event cannot reach: hosts without a cluster transport, writes made
|
||||
** outside QQQ, and a probe that raced an uncommitted assignment.
|
||||
*******************************************************************************/
|
||||
public final class TaggedTables
|
||||
{
|
||||
public static final String CACHE_NAME = "collaboration.taggedTables";
|
||||
|
||||
private static final int MAX_TABLES = 10_000;
|
||||
|
||||
private static final Map<QInstance, Memoization<String, Boolean>> CACHES = ClusterCaches.shared(CACHE_NAME,
|
||||
new WeakHashMap<QInstance, Memoization<String, Boolean>>(), TaggedTables::clearAll, TaggedTables::clearTable);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static lookups only.
|
||||
*******************************************************************************/
|
||||
private TaggedTables()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** False only when the table is known to have no assignment. A failed probe is
|
||||
** thrown, never remembered. With the cache disabled the answer is always true.
|
||||
*******************************************************************************/
|
||||
public static boolean mayHaveAssignments(String tableName, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
Integer seconds = CollaborationRecords.config().getTagLookupCacheSeconds();
|
||||
if(seconds == null || seconds <= 0)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
return (cache(QContext.getQInstance(), seconds).getResultThrowing(tableName, name -> hasAssignments(name, transaction)).orElse(true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An assignment was written for this parent table. Forget the answer now, so
|
||||
** the writing transaction reads its own assignment, and again after commit, so
|
||||
** a probe that ran in between and saw nothing cannot outlive the commit.
|
||||
*******************************************************************************/
|
||||
public static void assignmentsChanged(String tableName, QBackendTransaction transaction)
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
forget(instance, tableName);
|
||||
if(transaction != null)
|
||||
{
|
||||
transaction.addAfterCommitCallback(() -> forget(instance, tableName));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear on this node and tell the others; a transport failure is logged by the
|
||||
** bus and the timeout covers the nodes that did not hear.
|
||||
*******************************************************************************/
|
||||
private static void forget(QInstance instance, String tableName)
|
||||
{
|
||||
ClusterBus.publish(instance, new ClusterEvent(ClusterEvent.TYPE_MEMO_INVALIDATE, CACHE_NAME, tableName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One indexed row at most, in the caller's transaction, as the same answer for
|
||||
** every session: no row locks, no parent check and no customizers.
|
||||
*******************************************************************************/
|
||||
private static Boolean hasAssignments(String tableName, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(CollaborationTables.RECORD_TAG).withTransaction(transaction).withFieldNamesToInclude(Set.of("id"))
|
||||
.withFilter(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName).withLimit(1));
|
||||
input.setTableMetaData(CollaborationRecords.cleanupMetadata(CollaborationTables.RECORD_TAG));
|
||||
input.withPermissionBypass("Tag provider asks only whether a table has any assignment.");
|
||||
return (!new QueryAction().execute(input).getRecords().isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The instance's answers, following the configured timeout.
|
||||
*******************************************************************************/
|
||||
private static Memoization<String, Boolean> cache(QInstance instance, int seconds)
|
||||
{
|
||||
synchronized(CACHES)
|
||||
{
|
||||
Memoization<String, Boolean> cache = CACHES.computeIfAbsent(instance, key -> new Memoization<String, Boolean>(MAX_TABLES).withMayStoreNullValues(false));
|
||||
cache.setTimeout(Duration.ofSeconds(seconds));
|
||||
return (cache);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every instance in this JVM: an extra probe is cheaper than a stale answer.
|
||||
*******************************************************************************/
|
||||
private static List<Memoization<String, Boolean>> caches()
|
||||
{
|
||||
synchronized(CACHES)
|
||||
{
|
||||
return (List.copyOf(CACHES.values()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ClusterCaches: forget every table.
|
||||
*******************************************************************************/
|
||||
private static void clearAll()
|
||||
{
|
||||
caches().forEach(Memoization::clear);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ClusterCaches: forget one table.
|
||||
*******************************************************************************/
|
||||
private static void clearTable(String tableName)
|
||||
{
|
||||
caches().forEach(cache -> cache.clearKey(tableName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* 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.collaboration;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagEntry;
|
||||
import com.kingsrook.qbits.collaboration.tags.TaggedTables;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagsService;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterCacheAudit;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceSecurityConfig;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeVoidVoidMethod;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count the backend queries this qbit adds to ordinary reads of a host table.
|
||||
** Every backend query is one SQL statement (and one pool checkout) on an RDBMS
|
||||
** backend, and core selects the Tags field for every read that names no fields:
|
||||
** a table nobody tagged must not pay for it, and a tagged table pays per page.
|
||||
**
|
||||
** Before TaggedTables (1.2.0-RC.2), with 600 added parents: a 602-row list ran
|
||||
** 2 assignment queries, 600 gets ran 600 and 100 single-record updates ran 100.
|
||||
*******************************************************************************/
|
||||
class TagLookupStatementBudgetTest extends PersonalServiceFixture
|
||||
{
|
||||
private static final int PARENTS = 600;
|
||||
|
||||
private final TagsService tags = new TagsService();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 600 more visible parents, and the Tags field every enabled table receives.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void parents() throws Exception
|
||||
{
|
||||
List<QRecord> parents = new ArrayList<>();
|
||||
for(int id = 4; id <= PARENTS + 3; id++)
|
||||
{
|
||||
parents.add(new QRecord().withValue("id", id).withValue("title", "Document " + id).withValue("tenant", "a"));
|
||||
}
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput("document").withRecords(parents), true);
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
assertThat(instance.getTable("document").getVirtualFields()).containsKey("collaborationTags");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table nobody tagged: one probe, then lists, per-record gets and per-record
|
||||
** updates (which read the stored record first) add nothing at all.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void untaggedTableCostsOneProbeThenNothing() throws Exception
|
||||
{
|
||||
assertThat(collaborationQueries(this::listWithoutTags)).isEqualTo(Map.of(CollaborationTables.RECORD_TAG, 1L));
|
||||
assertThat(collaborationQueries(this::listWithoutTags)).isEmpty();
|
||||
assertThat(collaborationQueries(() ->
|
||||
{
|
||||
for(int id = 4; id <= PARENTS + 3; id++)
|
||||
{
|
||||
assertThat(new GetAction().executeForRecord(new GetInput("document").withPrimaryKey(id)).getValue("collaborationTags")).isNull();
|
||||
}
|
||||
})).isEmpty();
|
||||
assertThat(collaborationQueries(() ->
|
||||
{
|
||||
for(int id = 4; id <= 103; id++)
|
||||
{
|
||||
new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", id).withValue("title", "Renamed " + id))));
|
||||
}
|
||||
})).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A tagged table: one assignment lookup and one vocabulary lookup per 500
|
||||
** parents, however many records the caller asked for, plus the probe whenever
|
||||
** the node has to ask again.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void taggedTableCostsOneLookupPerPage() throws Exception
|
||||
{
|
||||
TagEntry tag = tags.create("document", "Boundary", null);
|
||||
tags.apply("document", 1, List.of(tag.id()), List.of());
|
||||
tags.apply("document", 504, List.of(tag.id()), List.of());
|
||||
assertThat(collaborationQueries(() -> assertThat(taggedIds()).containsExactlyInAnyOrder(1, 504)))
|
||||
.isEqualTo(Map.of(CollaborationTables.RECORD_TAG, 2L, CollaborationTables.TAG, 2L));
|
||||
ClusterCaches.invalidate(TaggedTables.CACHE_NAME);
|
||||
assertThat(collaborationQueries(() -> assertThat(taggedIds()).containsExactlyInAnyOrder(1, 504)))
|
||||
.isEqualTo(Map.of(CollaborationTables.RECORD_TAG, 3L, CollaborationTables.TAG, 2L));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table's first assignment shows at once on the node that wrote it, and the
|
||||
** last removal leaves the field empty again.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void firstAssignmentShowsAtOnce() throws Exception
|
||||
{
|
||||
listWithoutTags();
|
||||
TagEntry tag = tags.create("document", "Urgent", null);
|
||||
tags.apply("document", 2, List.of(tag.id()), List.of());
|
||||
assertThat(taggedIds()).containsExactly(2);
|
||||
assertThat(new GetAction().executeForRecord(new GetInput("document").withPrimaryKey(2)).getValueString("collaborationTags")).isEqualTo("Urgent");
|
||||
tags.apply("document", 2, List.of(), List.of(tag.id()));
|
||||
listWithoutTags();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The answer is forgotten when the assignment is written, so its transaction
|
||||
** reads its own tag (the service already asked again, so the list does not),
|
||||
** and again when it commits, so a probe that ran in between and could not see
|
||||
** the assignment yet does not outlive the commit.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void commitForgetsAgain() throws Exception
|
||||
{
|
||||
listWithoutTags();
|
||||
TagEntry tag = tags.create("document", "Urgent", null);
|
||||
QBackendTransaction transaction = new QBackendTransaction();
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
tags.apply("document", 2, List.of(tag.id()), List.of());
|
||||
assertThat(assignmentQueries(() -> assertThat(taggedIds()).containsExactly(2))).isEqualTo(2L);
|
||||
transaction.commit();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
assertThat(assignmentQueries(() -> assertThat(taggedIds()).containsExactly(2))).isEqualTo(3L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An assignment this node did not write (another node, or SQL outside QQQ)
|
||||
** shows when the ClusterBus says so.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void anotherNodesAssignmentShowsAfterClusterInvalidation() throws Exception
|
||||
{
|
||||
listWithoutTags();
|
||||
assignOutsideThisNode(tags.create("document", "Elsewhere", null));
|
||||
listWithoutTags();
|
||||
ClusterCaches.invalidateKey(TaggedTables.CACHE_NAME, "document");
|
||||
assertThat(taggedIds()).containsExactly(1);
|
||||
|
||||
ClusterCaches.invalidate(TaggedTables.CACHE_NAME);
|
||||
assertThat(assignmentQueries(() -> assertThat(taggedIds()).containsExactly(1))).isEqualTo(3L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a cluster transport the timeout is the bound, and 0 turns the
|
||||
** memory off: every read looks, as before.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void timeoutBoundsWhatNoEventReachesAndZeroDisables() throws Exception
|
||||
{
|
||||
config.withTagLookupCacheSeconds(1);
|
||||
listWithoutTags();
|
||||
assignOutsideThisNode(tags.create("document", "Elsewhere", null));
|
||||
listWithoutTags();
|
||||
Thread.sleep(1100);
|
||||
assertThat(taggedIds()).containsExactly(1);
|
||||
|
||||
config.withTagLookupCacheSeconds(0);
|
||||
assertThat(assignmentQueries(() -> assertThat(taggedIds()).containsExactly(1))).isEqualTo(2L);
|
||||
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.withTagLookupCacheSeconds(-1).validate(instance, errors);
|
||||
assertThat(errors).contains("tagLookupCacheSeconds must be 0 (off) or more");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The remembered answer is the same for every session: a caller who may not
|
||||
** see a table's only tagged record must not hide its tags from one who may.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void answerDoesNotDependOnTheCallersRowAccess() throws Exception
|
||||
{
|
||||
config.withMirroredSecurityKeyTypes(List.of("tenant"));
|
||||
RecordReferenceSecurityConfig.of(instance).withMirroredSecurityKeyTypes(List.of("tenant"));
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
CoreCollaboration.reference(table, config);
|
||||
}
|
||||
QContext.getQSession().withSecurityKeyValue("allTenants", true);
|
||||
TagEntry tag = tags.create("document", "Secret", null);
|
||||
tags.apply("document", 3, List.of(tag.id()), List.of());
|
||||
assertThat(CollaborationRecords.cleanupQuery(CollaborationTables.RECORD_TAG, new QQueryFilter()).getFirst().getValueString("lockKey_tenant")).isEqualTo("b");
|
||||
|
||||
login("alice");
|
||||
ClusterCaches.invalidate(TaggedTables.CACHE_NAME);
|
||||
assertThat(assignmentQueries(this::listWithoutTags)).isEqualTo(3L);
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("b")));
|
||||
assertThat(new QueryAction().execute(new QueryInput("document")).getRecords()).extracting(row -> row.getValueString("collaborationTags")).containsExactly("Secret");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Browsing one of this qbit's child tables authorizes each distinct parent
|
||||
** once, not once per row (61 rows on two parents ran 61 parent reads before).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void childRowsSharingAParentCostOneParentLookup() throws Exception
|
||||
{
|
||||
List<QRecord> tasks = new ArrayList<>();
|
||||
for(int id = 1; id <= 61; id++)
|
||||
{
|
||||
tasks.add(new QRecord().withValue("id", id).withValue("tableName", "document").withValue("recordId", id == 61 ? "2" : "1")
|
||||
.withValue("title", "Task " + id).withValue("assigneeUserId", "alice").withValue("createdByUserId", "alice")
|
||||
.withValue("status", "OPEN").withValue("parentDeleted", false));
|
||||
}
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.TASK).withRecords(tasks), true);
|
||||
login("alice", CollaborationPermissions.VIEW);
|
||||
assertThat(backendQueries(() -> assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.TASK)).getRecords()).hasSize(61)).get("document")).isEqualTo(2L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A static cache that is not registered would go stale on the other nodes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cachesAreRegisteredForClusterInvalidation() throws Exception
|
||||
{
|
||||
listWithoutTags();
|
||||
assertThat(ClusterCacheAudit.unregisteredStaticCaches("com.kingsrook.qbits.collaboration.tags")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every visible parent, none of them tagged.
|
||||
*******************************************************************************/
|
||||
private void listWithoutTags() throws Exception
|
||||
{
|
||||
assertThat(new QueryAction().execute(new QueryInput("document")).getRecords()).hasSize(PARENTS + 2)
|
||||
.allSatisfy(row -> assertThat(row.getValue("collaborationTags")).isNull());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ids of the visible parents that carry a tag label.
|
||||
*******************************************************************************/
|
||||
private List<Integer> taggedIds() throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput("document")).getRecords().stream()
|
||||
.filter(row -> row.getValue("collaborationTags") != null).map(row -> row.getValueInteger("id")).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store an assignment for document 1 without any action or listener running.
|
||||
*******************************************************************************/
|
||||
private void assignOutsideThisNode(TagEntry tag) throws Exception
|
||||
{
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.RECORD_TAG).withRecords(List.of(new QRecord()
|
||||
.withValue("tableName", "document").withValue("recordId", "1").withValue("tagId", tag.id())
|
||||
.withValue("createdByUserId", "alice").withValue("parentDeleted", false))), true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Backend queries against the assignment table while the work runs.
|
||||
*******************************************************************************/
|
||||
private long assignmentQueries(UnsafeVoidVoidMethod<Exception> work) throws Exception
|
||||
{
|
||||
return (collaborationQueries(work).getOrDefault(CollaborationTables.RECORD_TAG, 0L));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Backend queries against this qbit's tables while the work runs, by table.
|
||||
*******************************************************************************/
|
||||
private Map<String, Long> collaborationQueries(UnsafeVoidVoidMethod<Exception> work) throws Exception
|
||||
{
|
||||
Map<String, Long> result = backendQueries(work);
|
||||
result.keySet().removeIf(table -> !table.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX));
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every backend query while the work runs, by table.
|
||||
*******************************************************************************/
|
||||
private Map<String, Long> backendQueries(UnsafeVoidVoidMethod<Exception> work) throws Exception
|
||||
{
|
||||
MemoryRecordStore.resetStatistics();
|
||||
MemoryRecordStore.setCollectStatistics(true);
|
||||
try
|
||||
{
|
||||
work.run();
|
||||
Map<String, Long> result = new TreeMap<>();
|
||||
List<AbstractActionInput> inputs = MemoryRecordStore.getActionInputs().get(QueryInput.class);
|
||||
for(AbstractActionInput input : inputs == null ? List.<AbstractActionInput>of() : inputs)
|
||||
{
|
||||
result.merge(((QueryInput) input).getTableName(), 1L, Long::sum);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MemoryRecordStore.setCollectStatistics(false);
|
||||
MemoryRecordStore.resetStatistics();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,14 +35,19 @@ import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
@@ -51,11 +56,17 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
@@ -158,6 +169,70 @@ class RecentsServiceTest extends PersonalServiceFixture
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table whose permission checker goes by the action input it is handed (a
|
||||
** query, a get or a count reads; anything else is refused as a write) is a
|
||||
** target for whoever that checker lets read it, and for nobody else: the
|
||||
** visibility check asks as the query it stands for, never with an untyped
|
||||
** input the checker cannot recognise.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableWithAnInputTypedPermissionCheckerIsATarget() throws Exception
|
||||
{
|
||||
instance.addTable(new QTableMetaData().withName("ledger").withLabel("Ledger").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withCustomPermissionChecker(new QCodeReference(ReadsByInputType.class)))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("title", QFieldType.STRING))
|
||||
.withRecordLabelFormatAndFields("%s", "title"));
|
||||
InsertInput seed = new InsertInput("ledger").withRecords(List.of(new QRecord().withValue("id", 7).withValue("title", "Seventh entry")));
|
||||
seed.withPermissionBypass("Test fixture seeds a row of a table nobody may write.");
|
||||
assertThat(new InsertAction().execute(seed).getRecords()).allSatisfy(row -> assertThat(row.getErrors()).isNullOrEmpty());
|
||||
|
||||
login("alice", ReadsByInputType.PERMISSION);
|
||||
at(START);
|
||||
RecentsService.recordView("TABLE", "ledger", null);
|
||||
at(START.plusSeconds(1));
|
||||
RecentsService.recordView("RECORD", "ledger", "7");
|
||||
assertThat(RecentsService.list(50)).extracting(FavoriteEntry::label).containsExactly("Seventh entry", "Ledger");
|
||||
|
||||
login("bob");
|
||||
assertThatThrownBy(() -> RecentsService.recordView("TABLE", "ledger", null)).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> RecentsService.recordView("RECORD", "ledger", "7")).hasMessage("Not found");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A checker of the kind hosts and other qbits write for read-only tables: QQQ
|
||||
** hands it the action input and not the permission asked, so it reads the
|
||||
** input's type. A query, a get and a count need its permission; every other
|
||||
** input is refused to everyone.
|
||||
*******************************************************************************/
|
||||
public static class ReadsByInputType implements CustomPermissionChecker
|
||||
{
|
||||
static final String PERMISSION = "ledger.view";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refuse whatever is not a recognised read, then require the permission.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
|
||||
{
|
||||
if(!(actionInput instanceof QueryInput || actionInput instanceof GetInput || actionInput instanceof CountInput))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This table is read-only."));
|
||||
}
|
||||
if(!QContext.getQSession().hasPermission(PERMISSION))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Newest-first ordering is stable; locked rows are dropped before the limit.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user