Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc992fd5f8
|
||
|
|
08498dd1db
|
No files matched your search
+5
-1
@@ -8,7 +8,11 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Added
|
||||
|
||||
- Collaboration UI API extension `1.0.0`: 42 typed operations for favorites, recents, attachments, tags, comments, mentions, follow, timeline, people, sharing, access requests, tasks, assignment and My work under `/qqq/v1/qbit/collaboration`, enabled by default and disabled with `withUiApi(false)`.
|
||||
- Phase f custom-field designer: audited definitions, secured EAV children, runtime JSON-path fields, classification, validation, lifecycle actions, PostgreSQL probes/index jobs and migration DDL. Read-only EAV filters and scalar multi-select limitations are documented.
|
||||
- Phase g presence: typed POST/DELETE/GET endpoints, 45-second leases, tab merging, a 50,000-entry cap, internal bus replication, record/table events, summaries and the admin-only Who's here card.
|
||||
- Designer, HTTP, two-pod, clock and JDBC-boundary tests; regenerated contract and existing Checkstyle/JaCoCo gates retained.
|
||||
|
||||
- Collaboration UI API extension `1.0.0`: 45 typed operations for favorites, recents, attachments, tags, comments, mentions, follow, timeline, people, sharing, access requests, tasks, assignment, My work and presence under `/qqq/v1/qbit/collaboration`, enabled by default and disabled with `withUiApi(false)`.
|
||||
- Real multipart uploads; download/preview return five-minute `{url}` responses (clients follow url) because the framework cannot describe a redirect Location header. Configurable attachment storage adapter supports in-memory HTTP tests.
|
||||
- Versioned `ToSchema` wire types, enum values, semantic parameter descriptions, generated OpenAPI fragment and drift check; real HTTP tests cover all operations, paging, parent visibility, permissions, validation and storage failures.
|
||||
- Shared HTTP page bounds of 1-100 (default 20); comments/timeline keyset cursors and offset cursors for complete-list services. People/vocabulary remain bounded searches, while recents/mention candidates retain their 50-item service windows.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QBit Collaboration
|
||||
|
||||
Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and core-integrated tag filtering/search. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g remain planned. Runtime overrides and TAGS mode settings resolve through core.
|
||||
Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and core-integrated tag filtering/search. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g add the custom-field designer and ephemeral replicated presence. Runtime overrides and TAGS mode settings resolve through core.
|
||||
|
||||
Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md).
|
||||
|
||||
@@ -510,3 +510,105 @@ New routes, all under `/qqq/v1/qbit/collaboration`:
|
||||
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension stays at unreleased **1.0.0**; contract tooling accepts these additions without a bump.
|
||||
|
||||
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. The optional followers provider is skipped because `RecordFollowersProviderInterface` is absent from the installed jar. Selectable Tags values remain unavailable because the overlay has no output hook.
|
||||
|
||||
|
||||
## Custom fields (phase f)
|
||||
|
||||
Enable `RecordFeature.CUSTOM_FIELDS` on each host table. **Custom Fields** is the standard,
|
||||
FIELD-audited `collaboration_field_definition` table. Reads require `collaboration.view` (or
|
||||
admin); writes/actions require `collaboration.fields.manage` (or admin). Definitions have a
|
||||
unique `(tableName, name)` key. The CUSTOM table picker lists enabled host tables; this core
|
||||
snapshot has no generic table-name provider. EAV values are a secured child list, not a nav item.
|
||||
|
||||
Create a DRAFT named `[a-z][a-z0-9_]{0,40}`, then run **Publish field**. ACTIVE definitions appear
|
||||
as ordinary `cf_<name>` fields through the overlay. **Archive field** counts records with values
|
||||
in Review and hides the field, retaining its values. This version publishes DRAFT definitions
|
||||
only. **Rebuild index** reruns the index job. Types can change only in DRAFT; table/name identity
|
||||
is immutable. Lifecycle/index fields are server-managed; published definitions cannot be deleted.
|
||||
|
||||
Types: STRING, TEXT, INTEGER, DECIMAL, BOOLEAN, DATE, DATE_TIME, SELECT, MULTI_SELECT, USER and
|
||||
RECORD_LINK. Select options use stable ids, for example:
|
||||
|
||||
```json
|
||||
[{"id":"normal","label":"Normal"},{"id":"urgent","label":"Urgent"}]
|
||||
```
|
||||
|
||||
Active options may be added or relabeled. PostgreSQL containment counts refuse removal of an id
|
||||
still in use; other backends skip this check with a visible warning. USER uses `collaboration_user`
|
||||
PVS; RECORD_LINK requires the linked table's PVS. MULTI_SELECT is a JSON-array **STRING** with a
|
||||
PVS: native array membership operators and multi-select editing await core support. Numeric
|
||||
bounds use `ValueRangeBehavior`; required, maxLength, defaultValue and plain-text help use normal
|
||||
metadata. Optional PUBLIC/CONFIDENTIAL/PII/PHI classification is carried by `AiFieldMetaData`.
|
||||
Sections follow `sectionName`, defaulting to Custom fields. `sortOrder` controls contribution
|
||||
order; searchable definitions join the normal search-field list.
|
||||
|
||||
Definition writes refresh metadata after commit; rollbacks do not. Perform the initial
|
||||
`RuntimeMetaDataOverlay.refresh(instance)` after metadata and database initialization. Other pods
|
||||
must refresh their overlays when definitions change; presence replication does not replicate
|
||||
metadata.
|
||||
|
||||
Memory uses core `JsonPathStorage`. PostgreSQL uses it after a cached
|
||||
`SELECT qqq_custom_fields FROM <backend table> LIMIT 0` probe succeeds. Missing columns and
|
||||
unsupported backends get read-only EAV virtual filters over `collaboration_field_value`, with
|
||||
typed `QSubQuery` criteria. These fields are not query-selectable; EAV value writes and projection
|
||||
are outside this version. `statusMessage` records the fallback reason. After adding a missing
|
||||
column, call `CustomFieldStorage.invalidate(instance, tableName)` and refresh the overlay.
|
||||
|
||||
### PostgreSQL setup and indexes
|
||||
|
||||
Apply the one-time migration with your normal migration tool:
|
||||
|
||||
```sql
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS qqq_custom_fields JSONB NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX IF NOT EXISTS orders_custom_fields_gin ON orders
|
||||
USING gin (qqq_custom_fields jsonb_path_ops);
|
||||
```
|
||||
|
||||
`CollaborationSchema.customFieldsDdl("orders")` generates equivalent SQL with quoted identifiers
|
||||
and a deterministic index name. `postgresScript(config)` includes the two new collaboration
|
||||
tables. Neither helper executes SQL. Do not declare the backing column as a regular QQQ field.
|
||||
|
||||
The hidden `collaborationBuildFieldIndexes` job runs every 60 seconds when `schedulerName` is
|
||||
set, and through Publish/Rebuild/Archive. Definitions show BUILDING/READY/FAILED, SQL and errors.
|
||||
Range fields get typed expression indexes; searchable text uses `gin_trgm_ops` when `pg_trgm`
|
||||
is installed, otherwise a normal expression index. Concurrent DDL uses a fresh backend
|
||||
connection with autocommit, outside the record transaction. PostgreSQL can reject temporal text
|
||||
casts as non-immutable; that failure is recorded. A host migration can supply its chosen
|
||||
immutable expression. Rebuild uses `IF NOT EXISTS`, preserving existing index definitions.
|
||||
Archive drops the index concurrently while retaining values.
|
||||
|
||||
`withManagedIndexes(false)` records `indexDdl` only, with indexState NONE, for host migrations.
|
||||
There is no automatic migration receipt. The home shows definition/index status totals and the
|
||||
number of definitions with diagnostic messages.
|
||||
|
||||
## Presence (phase g)
|
||||
|
||||
Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
|
||||
`/qqq/v1/qbit/collaboration` are:
|
||||
|
||||
| Operation | Input |
|
||||
| --- | --- |
|
||||
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
|
||||
| `DELETE /presence` | `{connectionId, table, pk?}` |
|
||||
| `GET /presence?table=...&pk=...` | Omit pk for a table snapshot |
|
||||
|
||||
Session identity supplies userId and displayName. Every call rechecks table READ and record
|
||||
GetAction visibility. Disabled features and denied/missing parents return the same 404. A
|
||||
refused renewal removes the caller's previous lease. Connection ids are namespaced by user;
|
||||
they need not be active SSE connections, allowing polling clients to participate.
|
||||
|
||||
Renew on open/focus change and every 20 seconds. Leases expire after 45 seconds, with a one-second
|
||||
sweep and expiry checks on reads. The cap is 50,000 leases per pod: new targets are refused with
|
||||
a warning while existing leases can renew. Tabs merge per user; EDITING wins and the latest
|
||||
editor supplies focus. Nothing is persisted, including directory rows.
|
||||
|
||||
Every heartbeat/leave replicates over core's bus on `instance:presence`, a channel refused by
|
||||
core client subscriptions. `presence.changed` is emitted to record and table channels. Table
|
||||
snapshots aggregate users without record ids or focused field names; those stay on record
|
||||
channels. Configure core's ESB bus for multiple pods. Delivery is best effort; clients renew and
|
||||
refetch after reconnecting.
|
||||
|
||||
The initial overlay refresh starts the registry on every pod, including those without local
|
||||
viewers. Endpoints also initialize it lazily. At shutdown call `PresenceRegistry.close(instance)`
|
||||
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user counts
|
||||
per table, and core record-feature summaries include the presence snapshot.
|
||||
@@ -126,3 +126,39 @@ Feature writes refresh the overlay after commit. Initial loading follows core's
|
||||
`CollaborationWorkSchemas` and endpoint specs expose sharing, access requests, tasks, assignment and My work; the README and generated OpenAPI contain the route list. Task PATCH validates before writing and preserves omitted fields. Access requests never inspect parent existence. Contract version remains unreleased 1.0.0.
|
||||
|
||||
`CollaborationRealtime` merges stored references for partial updates, captures immutable identifier payloads and publishes after commit. Notification hints follow actual delivery. Rollback discards callbacks. Bus failure is best-effort and does not fail a committed write.
|
||||
|
||||
|
||||
## Custom fields and presence
|
||||
|
||||
`CustomFieldsProvider` contributes ACTIVE definitions through core's runtime overlay. Tags and
|
||||
custom fields share incoming feature-policy resolution, so a persisted setting takes effect in
|
||||
the same refresh. FIELD-audited definitions enforce management permissions and lifecycle rules
|
||||
on both direct writes and streamed actions. Numeric range behavior is added only when a bound
|
||||
exists. Classification stays in core AiFieldMetaData. Select option ids are stable across label
|
||||
changes, with parameterized PostgreSQL containment checks before removing active options.
|
||||
|
||||
Storage availability is cached per instance/table. Memory needs no schema; PostgreSQL uses a
|
||||
zero-row probe through the backend connection provider. Failure installs a visible read-only
|
||||
EAV filter instead of a broken JSON field. Typed subqueries retain parent permissions/locks;
|
||||
EAV values are neither projected nor written by this PR. The child table declares core reference
|
||||
security and participates in parent cleanup. MULTI_SELECT is a JSON-array scalar string until
|
||||
core adds native array storage/filter contracts.
|
||||
|
||||
Index building is separate from overlay construction. Concurrent DDL runs through independent
|
||||
autocommit connections and records its statement/state/error on the definition. A no-DDL option
|
||||
records migration statements only. No column/table DDL runs during refresh. JDBC-boundary tests
|
||||
cover probe caching, option containment, autocommit and failure reporting. Live PostgreSQL
|
||||
acceptance remains necessary for host index expressions, especially temporal casts.
|
||||
|
||||
Presence is the only non-table state. Pods subscribe at initial overlay refresh and keep bounded
|
||||
user/connection/target leases. `instance:presence` is denied by core's client channel authorizer.
|
||||
State mutation and bus publication occur outside each other's monitor, preventing deadlock on
|
||||
synchronous bus fan-out. Wire data uses transport-safe maps. Record focus stays on record
|
||||
channels; table events omit pk and fieldName. Each HTTP request rechecks parent visibility and
|
||||
PRESENCE; session identity is authoritative and the directory is never touched. Hosts close the
|
||||
registry before the core bus.
|
||||
|
||||
Limits: EAV writes/projection, native multi-select operations, automatic cross-pod metadata
|
||||
refresh, replacing existing index definitions, and live PostgreSQL acceptance are not provided.
|
||||
The no-DDL option does not infer whether an external migration has run. Presence relies on a
|
||||
trusted instance bus and best-effort delivery; renewed heartbeats restore state after outages.
|
||||
@@ -67,6 +67,10 @@
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-object-storage</artifactId>
|
||||
|
||||
@@ -42,7 +42,7 @@ public class CollaborationCleanup implements RecordReferenceCleanupInterface
|
||||
public void onParentsDeleted(String tableName, List<Serializable> primaryKeys) throws QException
|
||||
{
|
||||
new AttachmentCleanup().onParentsDeleted(tableName, primaryKeys);
|
||||
for(String table : List.of(CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION, CollaborationTables.COMMENT,
|
||||
for(String table : List.of(CollaborationTables.FIELD_VALUE, CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION, CollaborationTables.COMMENT,
|
||||
CollaborationTables.FOLLOW_DIGEST, CollaborationTables.FOLLOW, CollaborationTables.ACTIVITY))
|
||||
{
|
||||
while(true)
|
||||
|
||||
@@ -57,6 +57,7 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
private List<String> assignActionTables = List.of();
|
||||
private String backendName;
|
||||
private String schedulerName;
|
||||
private Boolean managedIndexes = true;
|
||||
private String appName = "collaboration";
|
||||
private String parentAppName;
|
||||
private Boolean adminApp = true;
|
||||
@@ -629,4 +630,29 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
mirroredSecurityKeyTypes = List.copyOf(types);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the index job executes DDL or only records migration statements.
|
||||
*******************************************************************************/
|
||||
public Boolean getManagedIndexes()
|
||||
{
|
||||
return (managedIndexes);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure host-managed migrations instead of runtime index creation.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withManagedIndexes(Boolean managedIndexes)
|
||||
{
|
||||
this.managedIndexes = managedIndexes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bean configuration support.
|
||||
*******************************************************************************/
|
||||
public void setManagedIndexes(Boolean managedIndexes)
|
||||
{
|
||||
this.managedIndexes = managedIndexes;
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,8 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
CollaborationSecurity.buildSecurityKeyTypes().forEach(output::add);
|
||||
output.add(TrimRecentsProcess.metadata(qBitConfig));
|
||||
output.add(com.kingsrook.qbits.collaboration.customfields.BuildFieldIndexes.metadata(qBitConfig));
|
||||
com.kingsrook.qbits.collaboration.customfields.FieldProcesses.metadata().forEach(output::add);
|
||||
output.add(com.kingsrook.qbits.collaboration.tasks.SendTaskRemindersProcess.metadata(qBitConfig));
|
||||
WorkProcesses.management().forEach(output::add);
|
||||
for(String table : qBitConfig.getAssignActionTables())
|
||||
|
||||
@@ -119,4 +119,16 @@ public final class CollaborationSchema
|
||||
default -> throw new IllegalArgumentException("Unsupported schema type: " + field.getType());
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-owned one-time PostgreSQL migration; this helper never executes SQL.
|
||||
*******************************************************************************/
|
||||
public static String customFieldsDdl(String tableName)
|
||||
{
|
||||
String table = java.util.Arrays.stream(tableName.split("\\.")).map(com.kingsrook.qbits.collaboration.customfields.CustomFieldStorage::identifier)
|
||||
.collect(java.util.stream.Collectors.joining("."));
|
||||
String index = "cf_gin_" + Integer.toUnsignedString(tableName.hashCode(), 16);
|
||||
return ("ALTER TABLE " + table + " ADD COLUMN IF NOT EXISTS qqq_custom_fields JSONB NOT NULL DEFAULT '{}';\n"
|
||||
+ "CREATE INDEX IF NOT EXISTS " + index + " ON " + table + " USING gin (qqq_custom_fields jsonb_path_ops);\n");
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,18 @@ public class CollaborationSummaryProvider implements RecordFeatureSummaryProvide
|
||||
var features = RecordFeaturesResolver.resolve(QContext.getQInstance(), QContext.getQInstance().getTable(ref.tableName()));
|
||||
String id = parent.getValueString(QContext.getQInstance().getTable(ref.tableName()).getPrimaryKeyField());
|
||||
String user = PersonalRecords.currentUserId();
|
||||
if(features.isEnabled(RecordFeature.PRESENCE))
|
||||
{
|
||||
summary.setPresence(com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).snapshot(ref.tableName(), id).stream().map(viewer ->
|
||||
{
|
||||
java.util.Map<String, java.io.Serializable> entry = new java.util.LinkedHashMap<>();
|
||||
entry.put("userId", viewer.userId());
|
||||
entry.put("displayName", viewer.displayName());
|
||||
entry.put("mode", viewer.mode());
|
||||
entry.put("fieldName", viewer.fieldName());
|
||||
return (entry);
|
||||
}).toList());
|
||||
}
|
||||
if(features.isEnabled(RecordFeature.FAVORITES))
|
||||
{
|
||||
summary.setIsFavorite(!CollaborationRecords.query(CollaborationTables.FAVORITE, new QQueryFilter()
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
public final class CoreCollaboration
|
||||
{
|
||||
private static final Map<String, RecordFeature> REFERENCES = Map.ofEntries(
|
||||
Map.entry(CollaborationTables.FIELD_VALUE, RecordFeature.CUSTOM_FIELDS),
|
||||
Map.entry(CollaborationTables.ATTACHMENT, RecordFeature.ATTACHMENTS),
|
||||
Map.entry(CollaborationTables.RECORD_TAG, RecordFeature.TAGS),
|
||||
Map.entry(CollaborationTables.COMMENT, RecordFeature.COMMENTS),
|
||||
@@ -69,6 +70,7 @@ public final class CoreCollaboration
|
||||
var features = RecordFeaturesMetaData.ofOrWithNew(instance);
|
||||
features.withOverrideProvider(new QCodeReference(FeatureSettingsProvider.class));
|
||||
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagsMetaDataProvider.class));
|
||||
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.CustomFieldsProvider.class));
|
||||
features.getSummaryProviders().add(new QCodeReference(CollaborationSummaryProvider.class));
|
||||
var security = RecordReferenceSecurityConfig.ofOrWithNew(instance).withMirroredSecurityKeyTypes(config.getMirroredSecurityKeyTypes());
|
||||
security.getCleanupProviders().add(new QCodeReference(CollaborationCleanup.class));
|
||||
|
||||
@@ -35,6 +35,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FeaturePermissionMode;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeatureOverride;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Read persisted feature policy during core overlay refresh.
|
||||
@@ -72,4 +74,55 @@ public class FeatureSettingsProvider implements RecordFeatureOverrideProviderInt
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a feature against the incoming policy in core's instance/app/table order.
|
||||
*******************************************************************************/
|
||||
public static boolean enabled(QInstance instance, QTableMetaData table, RecordFeature feature, List<RecordFeatureOverride> overrides)
|
||||
{
|
||||
List<RecordFeaturesMetaData> levels = new ArrayList<>();
|
||||
List<String> apps = new ArrayList<>();
|
||||
levels.add(RecordFeaturesMetaData.of(instance));
|
||||
if(instance.getApps() != null)
|
||||
{
|
||||
for(var app : instance.getApps().values())
|
||||
{
|
||||
if(app.getSections() != null && app.getSections().stream().anyMatch(section -> section.getTables() != null && section.getTables().contains(table.getName())))
|
||||
{
|
||||
levels.add(RecordFeaturesMetaData.of(app));
|
||||
apps.add(app.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
levels.add(RecordFeaturesMetaData.of(table));
|
||||
boolean enabled = false;
|
||||
for(var level : levels)
|
||||
{
|
||||
if(level != null)
|
||||
{
|
||||
if(Boolean.FALSE.equals(level.getFeatureLocks().get(feature)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
enabled = level.getEnabled().getOrDefault(feature, enabled);
|
||||
}
|
||||
}
|
||||
for(var scope : RecordFeatureOverride.ScopeType.values())
|
||||
{
|
||||
List<String> scopes = scope == RecordFeatureOverride.ScopeType.APP ? apps : java.util.Collections.singletonList(scope == RecordFeatureOverride.ScopeType.TABLE ? table.getName() : null);
|
||||
for(String scopeName : scopes)
|
||||
{
|
||||
for(var override : overrides)
|
||||
{
|
||||
if(override.getFeature() == feature && override.getScopeType() == scope && override.getEnabled() != null
|
||||
&& (scope == RecordFeatureOverride.ScopeType.INSTANCE || (scope == RecordFeatureOverride.ScopeType.APP && scopeName.equals(override.getScopeName()))
|
||||
|| (scope == RecordFeatureOverride.ScopeType.TABLE && table.getName().equals(override.getScopeName()))))
|
||||
{
|
||||
enabled = override.getEnabled();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (enabled);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,8 @@ public final class CollaborationApi
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0")
|
||||
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline and people")
|
||||
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
|
||||
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
|
||||
.withSpec(ListFavoritesSpec.class)
|
||||
.withSpec(StarFavoriteSpec.class)
|
||||
.withSpec(UnstarFavoriteSpec.class)
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned presence requests, polling snapshots, and realtime event data.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationPresenceSchemas
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Schema namespace.
|
||||
*******************************************************************************/
|
||||
private CollaborationPresenceSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All identity information is derived from the authenticated session.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Clients renew every twenty seconds; the lease expires after forty-five.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Renew a 45-second presence lease; send every 20 seconds.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"connectionId", text("Client connection identity.", false), "table", text("Parent table name.", false),
|
||||
"pk", text("Optional parent key; omit for a table view.", true),
|
||||
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")),
|
||||
"fieldName", text("Optional focused field on a record.", true)))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave removes only this authenticated user's connection and target.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceLeaveV1(String connectionId, String table, String pk) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Leave does not need a mode or a field.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Remove the caller's lease on this connection and target.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"connectionId", text("Client connection identity.", false), "table", text("Parent table name.", false),
|
||||
"pk", text("Optional parent key.", true)))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The snapshot is also the data shape of presence.changed events.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A table snapshot deliberately excludes record ids and focused field names.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Live viewers, merged across tabs; presence.changed uses this payload.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"table", text("Parent table name.", false), "pk", text("Record key; absent for table events.", true),
|
||||
"viewers", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only public identity and focus are carried, never field contents.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Editing takes precedence when one user has several open tabs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs; EDITING takes precedence.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"userId", text("Stable user identity.", false), "displayName", text("Session display name.", false),
|
||||
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true)))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse scalar schema descriptions consistently.
|
||||
*******************************************************************************/
|
||||
private static Schema text(String description, boolean nullable)
|
||||
{
|
||||
return (new Schema().withType(Type.STRING).withDescription(description).withNullable(nullable));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave presence with fresh parent access checks.
|
||||
*******************************************************************************/
|
||||
public class DeletePresenceSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the extension operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.DELETE, "/presence", "Leave presence"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit query parameters for polling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationMutationResultV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare a typed JSON request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationPresenceLeaveV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services derive identity from QContext and enforce visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceLeaveV1.class);
|
||||
new PresenceService().leave(body.connectionId(), required(body.table(), "table"), body.pk());
|
||||
return (success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** Get presence snapshot with fresh parent access checks.
|
||||
*******************************************************************************/
|
||||
public class GetPresenceSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the extension operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/presence", "Get presence snapshot"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit query parameters for polling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("table!", "pk"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationPresenceSnapshotV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services derive identity from QContext and enforce visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
String table = required(input.getQueryParam("table"), "table");
|
||||
String pk = input.getQueryParam("pk");
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, new PresenceService().snapshot(table, pk)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew presence with fresh parent access checks.
|
||||
*******************************************************************************/
|
||||
public class PostPresenceSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the extension operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/presence", "Renew presence"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit query parameters for polling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationPresenceSnapshotV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare a typed JSON request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationPresenceRequestV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services derive identity from QContext and enforce visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceRequestV1.class);
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(body.table(), body.pk(),
|
||||
new PresenceService().heartbeat(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName())));
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public final class CollaborationHome
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withWidgets(List.of(HOME_WIDGET))
|
||||
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
|
||||
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
|
||||
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
|
||||
app.setParentAppName(config.getParentAppName());
|
||||
return (app);
|
||||
}
|
||||
@@ -142,6 +142,7 @@ public final class CollaborationHome
|
||||
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
|
||||
attachmentSummary(html);
|
||||
tagSummary(html);
|
||||
fieldSummary(html);
|
||||
html.append("<section><h3>Open access requests</h3><p>").append(count(CollaborationTables.ACCESS_REQUEST,
|
||||
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
|
||||
html.append("<section><h3>Open tasks</h3><p>").append(count(CollaborationTables.TASK,
|
||||
@@ -241,6 +242,35 @@ public final class CollaborationHome
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real definition status and index totals, plus ephemeral admin-only counts.
|
||||
*******************************************************************************/
|
||||
private static void fieldSummary(StringBuilder html) throws QException
|
||||
{
|
||||
html.append("<section><h3>Custom fields</h3><ul>");
|
||||
for(String status : List.of("DRAFT", "ACTIVE", "ARCHIVED"))
|
||||
{
|
||||
html.append("<li>").append(status).append(": ").append(count(CollaborationTables.FIELD_DEFINITION,
|
||||
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, status))).append("</li>");
|
||||
}
|
||||
for(String state : List.of("NONE", "BUILDING", "READY", "FAILED"))
|
||||
{
|
||||
html.append("<li>Index ").append(state).append(": ").append(count(CollaborationTables.FIELD_DEFINITION,
|
||||
new QQueryFilter().withCriteria("indexState", QCriteriaOperator.EQUALS, state))).append("</li>");
|
||||
}
|
||||
html.append("</ul><p>Definitions with storage or index messages: ").append(count(CollaborationTables.FIELD_DEFINITION,
|
||||
new QQueryFilter().withCriteria("statusMessage", QCriteriaOperator.IS_NOT_BLANK))).append(". See Custom Fields for details.</p></section>");
|
||||
if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
|
||||
{
|
||||
html.append("<section><h3>Who's here</h3><ul>");
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, count) ->
|
||||
html.append("<li>").append(table.replace("&", "&").replace("<", "<").replace(">", ">")).append(": ").append(count).append("</li>"));
|
||||
html.append("</ul></section>");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Widget renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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.customfields;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
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.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** PostgreSQL indexes use independent autocommit connections, never a row transaction.
|
||||
*******************************************************************************/
|
||||
public class BuildFieldIndexes implements BackendStep
|
||||
{
|
||||
public static final String NAME = "collaborationBuildFieldIndexes";
|
||||
|
||||
/*******************************************************************************
|
||||
** Always callable as a hidden job; scheduling is explicitly configured by the host.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata(CollaborationQBitConfig config)
|
||||
{
|
||||
var process = new QProcessMetaData().withName(NAME).withLabel("Build custom field indexes").withIsHidden(true)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(FieldSecurity.class)))
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(BuildFieldIndexes.class)));
|
||||
if(config.getSchedulerName() != null)
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(60).withInitialDelaySeconds(60));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retry pending and failed indexes, leaving completed indexes alone.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
FieldSecurity.requireManage();
|
||||
for(QRecord definition : CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION,
|
||||
new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, List.of("ACTIVE", "ARCHIVED"))
|
||||
.withCriteria("indexState", QCriteriaOperator.IN, List.of("NONE", "FAILED", "BUILDING"))))
|
||||
{
|
||||
build(definition);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The published definition must commit before its index can be built.
|
||||
*******************************************************************************/
|
||||
public static void afterCommit(QRecord definition) throws QException
|
||||
{
|
||||
if(QContext.getQBackendTransaction() == null)
|
||||
{
|
||||
build(definition);
|
||||
return;
|
||||
}
|
||||
var context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
var id = definition.getValue("id");
|
||||
QContext.getQBackendTransaction().addAfterCommitCallback(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> build(FieldDefinitions.get(id)));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not build custom field index", e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the statement even when the host reserves DDL for migrations.
|
||||
*******************************************************************************/
|
||||
public static void build(QRecord definition) throws QException
|
||||
{
|
||||
FieldSecurity.requireManage();
|
||||
QTableMetaData table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
|
||||
if(!CustomFieldStorage.postgres(table))
|
||||
{
|
||||
return;
|
||||
}
|
||||
boolean managed = Boolean.TRUE.equals(CollaborationRecords.config().getManagedIndexes());
|
||||
String ddl = ddl(table, definition, false);
|
||||
if(ddl == null)
|
||||
{
|
||||
state(definition, "NONE", null, null);
|
||||
return;
|
||||
}
|
||||
if(!managed)
|
||||
{
|
||||
state(definition, "NONE", "Apply indexDdl through the host migration tooling. Text indexes require pg_trgm for substring search.", ddl);
|
||||
return;
|
||||
}
|
||||
try(var connection = CustomFieldStorage.connection(table))
|
||||
{
|
||||
connection.setAutoCommit(true);
|
||||
boolean trigram;
|
||||
try(var statement = connection.createStatement(); var result = statement.executeQuery("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')"))
|
||||
{
|
||||
result.next();
|
||||
trigram = result.getBoolean(1);
|
||||
}
|
||||
ddl = ddl(table, definition, trigram);
|
||||
state(definition, "BUILDING", null, ddl);
|
||||
try(var statement = connection.createStatement())
|
||||
{
|
||||
statement.execute(ddl);
|
||||
}
|
||||
state(definition, "ARCHIVED".equals(definition.getValueString("status")) ? "NONE" : "READY", null, ddl);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
state(definition, "FAILED", e.getMessage(), ddl);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Generate deterministic safe identifiers; PostgreSQL names are at most 63 bytes.
|
||||
*******************************************************************************/
|
||||
public static String ddl(QTableMetaData table, QRecord definition, boolean trigram) throws QException
|
||||
{
|
||||
String key = definition.getValueString("name");
|
||||
if(key == null || !key.matches("[a-z][a-z0-9_]{0,40}"))
|
||||
{
|
||||
throw (new QException("Invalid field name"));
|
||||
}
|
||||
String index = "cf_" + table.getName() + "_" + key;
|
||||
if(index.length() > 63)
|
||||
{
|
||||
index = index.substring(0, 45) + "_" + Integer.toUnsignedString(index.hashCode(), 16);
|
||||
}
|
||||
if("ARCHIVED".equals(definition.getValueString("status")))
|
||||
{
|
||||
return ("DROP INDEX CONCURRENTLY IF EXISTS " + CustomFieldStorage.identifier(index));
|
||||
}
|
||||
String type = switch(definition.getValueString("type"))
|
||||
{
|
||||
case "INTEGER" -> "integer";
|
||||
case "DECIMAL" -> "numeric";
|
||||
case "DATE" -> "date";
|
||||
case "DATE_TIME" -> "timestamptz";
|
||||
default -> null;
|
||||
};
|
||||
boolean searchable = Boolean.TRUE.equals(definition.getValueBoolean("searchable"));
|
||||
if(!searchable && !(Boolean.TRUE.equals(definition.getValueBoolean("filterable")) && type != null))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String expression = "(qqq_custom_fields->>'" + key + "')";
|
||||
String prefix = "CREATE INDEX CONCURRENTLY IF NOT EXISTS " + CustomFieldStorage.identifier(index) + " ON " + CustomFieldStorage.sqlTable(table);
|
||||
if(type == null && searchable && trigram)
|
||||
{
|
||||
return (prefix + " USING gin (" + expression + " gin_trgm_ops)");
|
||||
}
|
||||
return (prefix + " ((" + expression + (type == null ? "" : "::" + type) + "))");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal diagnostic updates do not recursively refresh the overlay.
|
||||
*******************************************************************************/
|
||||
private static void state(QRecord definition, String state, String message, String ddl) throws QException
|
||||
{
|
||||
CollaborationRecords.update(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("id", definition.getValue("id"))
|
||||
.withValue("indexState", state).withValue("statusMessage", message).withValue("indexDdl", ddl));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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.customfields;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Probe once per instance/table; metadata refresh never performs schema DDL.
|
||||
*******************************************************************************/
|
||||
public final class CustomFieldStorage
|
||||
{
|
||||
private static final Map<QInstance, Map<String, Availability>> CACHE = new WeakHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Static backend boundary.
|
||||
*******************************************************************************/
|
||||
private CustomFieldStorage()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A visible reason accompanies every fallback.
|
||||
*******************************************************************************/
|
||||
public record Availability(boolean json, String reason)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Memory needs no schema. Only PostgreSQL implements the JSON SQL contract.
|
||||
*******************************************************************************/
|
||||
public static synchronized Availability availability(QTableMetaData table)
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
return (CACHE.computeIfAbsent(instance, key -> new HashMap<>()).computeIfAbsent(table.getName(), key -> probe(table)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Hosts can retry after applying the one-time migration.
|
||||
*******************************************************************************/
|
||||
public static synchronized void invalidate(QInstance instance, String table)
|
||||
{
|
||||
CACHE.getOrDefault(instance, new HashMap<>()).remove(table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed probes never install JSON fields that would break ordinary queries.
|
||||
*******************************************************************************/
|
||||
private static Availability probe(QTableMetaData table)
|
||||
{
|
||||
var backend = QContext.getQInstance().getBackend(table.getBackendName());
|
||||
if(new MemoryBackendModule().getBackendType().equals(backend.getBackendType()))
|
||||
{
|
||||
return (new Availability(true, null));
|
||||
}
|
||||
if(!postgres(table))
|
||||
{
|
||||
return (new Availability(false, "Backend does not support JsonPathStorage; EAV fields are read-only filters (values are not selected)."));
|
||||
}
|
||||
try(Connection connection = connection(table); var statement = connection.createStatement())
|
||||
{
|
||||
statement.executeQuery("SELECT qqq_custom_fields FROM " + sqlTable(table) + " LIMIT 0").close();
|
||||
return (new Availability(true, null));
|
||||
}
|
||||
catch(SQLException | IllegalArgumentException e)
|
||||
{
|
||||
return (new Availability(false, "JSON column unavailable; EAV fields are read-only filters. " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match the installed RDBMS metadata, not a backend's arbitrary configured name.
|
||||
*******************************************************************************/
|
||||
public static boolean postgres(QTableMetaData table)
|
||||
{
|
||||
return (QContext.getQInstance().getBackend(table.getBackendName()) instanceof RDBMSBackendMetaData backend
|
||||
&& ("postgres".equalsIgnoreCase(backend.getVendor()) || "postgresql".equalsIgnoreCase(backend.getVendor())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh backend connection is intentionally outside QContext's transaction.
|
||||
*******************************************************************************/
|
||||
public static Connection connection(QTableMetaData table) throws SQLException
|
||||
{
|
||||
return (ConnectionManager.getConnection((RDBMSBackendMetaData) QContext.getQInstance().getBackend(table.getBackendName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Quote metadata identifiers; never accept SQL fragments from definitions.
|
||||
*******************************************************************************/
|
||||
public static String identifier(String name)
|
||||
{
|
||||
if(name == null || name.isBlank() || name.indexOf('\0') >= 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid SQL identifier"));
|
||||
}
|
||||
return ("\"" + name.replace("\"", "\"\"") + "\"");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Backend table names may include a schema qualification.
|
||||
*******************************************************************************/
|
||||
public static String sqlTable(QTableMetaData table)
|
||||
{
|
||||
String name = table.getBackendDetails() instanceof com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails details ? details.getTableName() : null;
|
||||
if(name == null)
|
||||
{
|
||||
name = table.getName();
|
||||
}
|
||||
return (java.util.Arrays.stream(name.split("\\.")).map(CustomFieldStorage::identifier).collect(java.util.stream.Collectors.joining(".")));
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.customfields;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.FeatureSettingsProvider;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.BasicCustomPossibleValueProvider;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Picker restricted to tables enabled for the custom-field designer.
|
||||
*******************************************************************************/
|
||||
public class CustomFieldTablesProvider extends BasicCustomPossibleValueProvider<QTableMetaData, String>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve code and persisted policy consistently with the overlay.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected List<QTableMetaData> getAllSourceObjects() throws QException
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
var overrides = new FeatureSettingsProvider().getOverrides(instance);
|
||||
return (instance.getTables().values().stream().filter(table -> !table.getName().startsWith("collaboration_") && table.getPrimaryKeyField() != null
|
||||
&& FeatureSettingsProvider.enabled(instance, table, RecordFeature.CUSTOM_FIELDS, overrides)).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject stale selections when the feature is disabled.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected QTableMetaData getSourceObject(Serializable id) throws QException
|
||||
{
|
||||
return (getAllSourceObjects().stream().filter(table -> table.getName().equals(id)).findFirst().orElse(null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the metadata label rather than the SQL name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected QPossibleValue<String> makePossibleValue(QTableMetaData table)
|
||||
{
|
||||
return (new QPossibleValue<>(table.getName(), table.getLabel()));
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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.customfields;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.FeatureSettingsProvider;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RuntimeMetaDataContribution;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RuntimeTableMetaDataContribution;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage;
|
||||
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.fields.QVirtualFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
|
||||
/*******************************************************************************
|
||||
** ACTIVE definitions become normal fields through the core copy-on-write overlay.
|
||||
*******************************************************************************/
|
||||
public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Build against incoming feature settings, not the previous resolver snapshot.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RuntimeMetaDataContribution getContribution(QInstance instance) throws QException
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(instance);
|
||||
var result = new RuntimeMetaDataContribution();
|
||||
var overrides = new FeatureSettingsProvider().getOverrides(instance);
|
||||
List<QRecord> definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION,
|
||||
new QQueryFilter().withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")));
|
||||
long generation = 1;
|
||||
for(QRecord definition : definitions)
|
||||
{
|
||||
generation = 31 * generation + definition.getValues().hashCode();
|
||||
var table = instance.getTable(definition.getValueString("tableName"));
|
||||
if(!"ACTIVE".equals(definition.getValueString("status")) || table == null
|
||||
|| !FeatureSettingsProvider.enabled(instance, table, RecordFeature.CUSTOM_FIELDS, overrides))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var storage = CustomFieldStorage.availability(table);
|
||||
String storageName = storage.json() ? "JSONB" : "EAV";
|
||||
if(!storageName.equals(definition.getValueString("storage")) || (!storage.json() && !java.util.Objects.equals(storage.reason(), definition.getValueString("statusMessage"))))
|
||||
{
|
||||
CollaborationRecords.update(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("id", definition.getValue("id"))
|
||||
.withValue("storage", storageName).withValue("statusMessage", storage.reason()));
|
||||
}
|
||||
QFieldMetaData field = field(instance, definition, storage.json());
|
||||
var contribution = result.getTables().computeIfAbsent(table.getName(), key -> new RuntimeTableMetaDataContribution());
|
||||
if(storage.json())
|
||||
{
|
||||
contribution.getFields().add(field);
|
||||
contribution.getSectionPlacements().put(field.getName(), definition.getValueString("sectionName") == null || definition.getValueString("sectionName").isBlank() ? "customFields" : definition.getValueString("sectionName"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contribution.getVirtualFields().add((QVirtualFieldMetaData) field);
|
||||
}
|
||||
if(Boolean.TRUE.equals(definition.getValueBoolean("searchable")))
|
||||
{
|
||||
contribution.getSearchFields().add(field.getName());
|
||||
}
|
||||
}
|
||||
return (result.withGeneration(generation & Long.MAX_VALUE));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core supports scalar STRING storage for multi-select, not array operators.
|
||||
*******************************************************************************/
|
||||
public static QFieldType type(String type)
|
||||
{
|
||||
return (switch(type)
|
||||
{
|
||||
case "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK" -> QFieldType.STRING;
|
||||
default -> QFieldType.valueOf(type);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve classifications and supported validation on the contributed field.
|
||||
*******************************************************************************/
|
||||
private QFieldMetaData field(QInstance instance, QRecord definition, boolean json) throws QException
|
||||
{
|
||||
String name = "cf_" + definition.getValueString("name");
|
||||
QFieldType type = type(definition.getValueString("type"));
|
||||
QFieldMetaData field = json ? new QFieldMetaData(name, type).withBackendStorage(new JsonPathStorage().withColumnName("qqq_custom_fields").withKey(definition.getValueString("name")))
|
||||
: new QVirtualFieldMetaData(name, type).withIsEditable(false).withIsQueryCriteria(true).withIsQuerySelectable(false)
|
||||
.withCriteriaExpander(new QCodeReference(FieldCriteriaExpander.class));
|
||||
field.withLabel(definition.getValueString("label")).withIsRequired(json && Boolean.TRUE.equals(definition.getValueBoolean("required")))
|
||||
.withMaxLength(definition.getValueInteger("maxLength"));
|
||||
if(definition.getValue("defaultValue") != null && json)
|
||||
{
|
||||
field.withDefaultValue(com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(type, definition.getValue("defaultValue")));
|
||||
}
|
||||
if(definition.getValueString("helpText") != null)
|
||||
{
|
||||
field.withHelpContent(new QHelpContent().withContentAsText(definition.getValueString("helpText")));
|
||||
}
|
||||
if((type == QFieldType.INTEGER || type == QFieldType.DECIMAL) && (definition.getValue("minValue") != null || definition.getValue("maxValue") != null))
|
||||
{
|
||||
field.withBehavior(new ValueRangeBehavior().withMinValue(definition.getValueBigDecimal("minValue")).withMaxValue(definition.getValueBigDecimal("maxValue")));
|
||||
}
|
||||
if(definition.getValueString("classification") != null)
|
||||
{
|
||||
field.withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.valueOf(definition.getValueString("classification"))));
|
||||
}
|
||||
switch(definition.getValueString("type"))
|
||||
{
|
||||
case "SELECT", "MULTI_SELECT" ->
|
||||
{
|
||||
String source = "collaboration_field_options_" + definition.getValueInteger("id");
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName(source).withType(QPossibleValueSourceType.ENUM)
|
||||
.withEnumValues(new ArrayList<>(FieldDefinitions.options(definition).values())));
|
||||
field.withPossibleValueSourceName(source);
|
||||
if("MULTI_SELECT".equals(definition.getValueString("type")))
|
||||
{
|
||||
field.withHelpContent(new QHelpContent().withContentAsText("Stored as a JSON-array STRING. Array membership operators and multi-select editing are not supported by core yet."));
|
||||
}
|
||||
}
|
||||
case "USER" -> field.withPossibleValueSourceName(CollaborationTables.USER);
|
||||
case "RECORD_LINK" -> field.withPossibleValueSourceName(definition.getValueString("linkTableName"));
|
||||
default ->
|
||||
{
|
||||
}
|
||||
}
|
||||
return (field);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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.customfields;
|
||||
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.query.CriteriaExpansionContext;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.query.VirtualFieldCriteriaExpanderInterface;
|
||||
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.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QSubQuery;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only EAV filters retain the parent's normal query permissions and paging.
|
||||
*******************************************************************************/
|
||||
public class FieldCriteriaExpander implements VirtualFieldCriteriaExpanderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Missing EAV rows count as blank; negative comparisons complement membership.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QQueryFilter expand(QTableMetaData table, QFilterCriteria criteria, CriteriaExpansionContext context) throws QException
|
||||
{
|
||||
String name = criteria.getFieldName().substring(3);
|
||||
var definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter()
|
||||
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("name", QCriteriaOperator.EQUALS, name)
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
|
||||
if(definitions.size() != 1)
|
||||
{
|
||||
throw (new QException("Custom field is unavailable"));
|
||||
}
|
||||
var operator = criteria.getOperator();
|
||||
boolean negate = operator == QCriteriaOperator.IS_BLANK || operator == QCriteriaOperator.NOT_EQUALS || operator == QCriteriaOperator.NOT_IN;
|
||||
var positive = switch(operator)
|
||||
{
|
||||
case IS_BLANK -> QCriteriaOperator.IS_NOT_BLANK;
|
||||
case NOT_EQUALS -> QCriteriaOperator.EQUALS;
|
||||
case NOT_IN -> QCriteriaOperator.IN;
|
||||
default -> operator;
|
||||
};
|
||||
String column = valueColumn(definitions.getFirst().getValueString("type"));
|
||||
var valueType = context.instance().getTable(CollaborationTables.FIELD_VALUE).getField(column).getType();
|
||||
var typedValues = criteria.getValues().stream().map(value -> com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(valueType, value)).toList();
|
||||
QQueryFilter values = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName())
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false).withCriteria("fieldName", QCriteriaOperator.EQUALS, name)
|
||||
.withCriteria(new QFilterCriteria(column, positive, typedValues));
|
||||
return (new QQueryFilter().withCriteria(table.getPrimaryKeyField(), negate ? QCriteriaOperator.NOT_IN : QCriteriaOperator.IN,
|
||||
new QSubQuery(CollaborationTables.FIELD_VALUE, "recordId", values)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared typed column mapping for filtering and value counts.
|
||||
*******************************************************************************/
|
||||
public static String valueColumn(String type)
|
||||
{
|
||||
return (switch(type)
|
||||
{
|
||||
case "INTEGER", "DECIMAL" -> "valueNumber";
|
||||
case "DATE" -> "valueDate";
|
||||
case "DATE_TIME" -> "valueDateTime";
|
||||
case "BOOLEAN" -> "valueBoolean";
|
||||
default -> "valueText";
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core's search callback has no field argument, so combine searchable EAV text.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QQueryFilter expandSearchTerm(QTableMetaData table, String term, com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind kind, CriteriaExpansionContext context) throws QException
|
||||
{
|
||||
QQueryFilter result = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
|
||||
for(var definition : CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter()
|
||||
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")
|
||||
.withCriteria("searchable", QCriteriaOperator.EQUALS, true)))
|
||||
{
|
||||
if("valueText".equals(valueColumn(definition.getValueString("type"))))
|
||||
{
|
||||
result.withSubFilter(expand(table, new QFilterCriteria("cf_" + definition.getValueString("name"), QCriteriaOperator.CONTAINS, term), context));
|
||||
}
|
||||
}
|
||||
if(result.getSubFilters() == null || result.getSubFilters().isEmpty())
|
||||
{
|
||||
result.withCriteria(table.getPrimaryKeyField(), QCriteriaOperator.IN, java.util.List.of());
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.customfields;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.collaboration.FeatureSettingsCustomizer;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct table writes enforce the same designer rules; post hooks refresh metadata.
|
||||
*******************************************************************************/
|
||||
public class FieldDefinitionCustomizer extends FeatureSettingsCustomizer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** New definitions always begin as drafts with server-managed lifecycle fields.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean preview) throws QException
|
||||
{
|
||||
FieldSecurity.requireManage();
|
||||
for(QRecord row : records)
|
||||
{
|
||||
row.withValue("status", "DRAFT").withValue("indexState", "NONE").withValue("storage", null)
|
||||
.withValue("indexDdl", null).withValue("statusMessage", null).withValue("createdByUserId", PersonalRecords.currentUserId());
|
||||
FieldDefinitions.validate(row, null);
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Merge partial updates before validation and protect derived state from clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
|
||||
{
|
||||
FieldSecurity.requireManage();
|
||||
for(QRecord row : records)
|
||||
{
|
||||
QRecord old = FieldDefinitions.get(row.getValue("id"));
|
||||
QRecord merged = new QRecord(old);
|
||||
merged.getValues().putAll(row.getValues());
|
||||
for(String field : List.of("status", "indexState", "storage", "indexDdl", "statusMessage", "createdByUserId"))
|
||||
{
|
||||
if(row.getValues().containsKey(field) && !java.util.Objects.equals(row.getValue(field), old.getValue(field)))
|
||||
{
|
||||
throw (new QException("Use the field actions to change " + field));
|
||||
}
|
||||
}
|
||||
FieldDefinitions.validate(merged, old);
|
||||
if(!java.util.Objects.equals(merged.getValue("statusMessage"), old.getValue("statusMessage")))
|
||||
{
|
||||
row.setValue("statusMessage", merged.getValue("statusMessage"));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Published field values must remain addressable; archive instead of deleting.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput input, List<QRecord> records, boolean preview) throws QException
|
||||
{
|
||||
FieldSecurity.requireManage();
|
||||
for(QRecord row : records)
|
||||
{
|
||||
if(!"DRAFT".equals(FieldDefinitions.get(row.getValue("id")).getValueString("status")))
|
||||
{
|
||||
throw (new QException("Only draft definitions can be deleted; archive published fields."));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* 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.customfields;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.FeatureSettingsCustomizer;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
|
||||
/*******************************************************************************
|
||||
** Designer lifecycle and validations shared by direct writes and record actions.
|
||||
*******************************************************************************/
|
||||
public final class FieldDefinitions
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static domain operations.
|
||||
*******************************************************************************/
|
||||
private FieldDefinitions()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Managers may address definitions independently of host record read access.
|
||||
*******************************************************************************/
|
||||
public static QRecord get(Serializable id) throws QException
|
||||
{
|
||||
FieldSecurity.requireManage();
|
||||
var rows = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable option ids survive label changes. JSON is [{"id":"a","label":"A"}].
|
||||
*******************************************************************************/
|
||||
public static Map<String, QPossibleValue<?>> options(QRecord definition) throws QException
|
||||
{
|
||||
Map<String, QPossibleValue<?>> result = new LinkedHashMap<>();
|
||||
String json = definition.getValueString("options");
|
||||
if(json == null || json.isBlank())
|
||||
{
|
||||
return (result);
|
||||
}
|
||||
try
|
||||
{
|
||||
var array = new ObjectMapper().readTree(json);
|
||||
if(!array.isArray())
|
||||
{
|
||||
throw (new IllegalArgumentException("Expected an array"));
|
||||
}
|
||||
for(var option : array)
|
||||
{
|
||||
if(!option.path("id").isTextual() || option.path("id").asText().isBlank() || !option.path("label").isTextual()
|
||||
|| result.putIfAbsent(option.path("id").asText(), new QPossibleValue<>(option.path("id").asText(), option.path("label").asText())) != null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Options need unique nonblank string ids and labels"));
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Invalid field options: expected an array of {id, label} objects", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject unsafe names, invalid types, missing links and immutable identity edits.
|
||||
*******************************************************************************/
|
||||
public static void validate(QRecord definition, QRecord old) throws QException
|
||||
{
|
||||
String name = definition.getValueString("name");
|
||||
var table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
|
||||
if(name == null || !name.matches("[a-z][a-z0-9_]{0,40}") || table == null || table.getName().startsWith("collaboration_") || table.getPrimaryKeyField() == null)
|
||||
{
|
||||
throw (new QException("Choose a host table and a name matching [a-z][a-z0-9_]{0,40}."));
|
||||
}
|
||||
if(definition.getValueString("type") == null || !List.of("STRING", "TEXT", "INTEGER", "DECIMAL", "BOOLEAN", "DATE", "DATE_TIME", "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK").contains(definition.getValueString("type")))
|
||||
{
|
||||
throw (new QException("Unsupported custom field type"));
|
||||
}
|
||||
if(definition.getValueString("label") == null || definition.getValueString("label").isBlank())
|
||||
{
|
||||
throw (new QException("A field label is required"));
|
||||
}
|
||||
var existing = table.getFields().get("cf_" + name);
|
||||
if(existing != null && (old == null || "DRAFT".equals(old.getValueString("status")) || !(existing.getBackendStorage() instanceof JsonPathStorage storage && name.equals(storage.getKey()))))
|
||||
{
|
||||
throw (new QException("The field name clashes with a code field"));
|
||||
}
|
||||
var virtual = table.getVirtualFields() == null ? null : table.getVirtualFields().get("cf_" + name);
|
||||
if(virtual != null && (old == null || "DRAFT".equals(old.getValueString("status"))))
|
||||
{
|
||||
throw (new QException("The field name clashes with a code virtual field"));
|
||||
}
|
||||
if(old != null && (!Objects.equals(old.getValue("tableName"), definition.getValue("tableName")) || !Objects.equals(old.getValue("name"), definition.getValue("name"))))
|
||||
{
|
||||
throw (new QException("Table and name cannot change after creation"));
|
||||
}
|
||||
if(old != null && !"DRAFT".equals(old.getValueString("status")) && !Objects.equals(old.getValue("type"), definition.getValue("type")))
|
||||
{
|
||||
throw (new QException("Type changes are only allowed in DRAFT"));
|
||||
}
|
||||
String classification = definition.getValueString("classification");
|
||||
if(classification != null && !List.of("PUBLIC", "CONFIDENTIAL", "PII", "PHI").contains(classification))
|
||||
{
|
||||
throw (new QException("Unknown classification"));
|
||||
}
|
||||
if(definition.getValueInteger("maxLength") != null && definition.getValueInteger("maxLength") < 1)
|
||||
{
|
||||
throw (new QException("Maximum length must be positive"));
|
||||
}
|
||||
if(definition.getValueBigDecimal("minValue") != null && definition.getValueBigDecimal("maxValue") != null
|
||||
&& definition.getValueBigDecimal("minValue").compareTo(definition.getValueBigDecimal("maxValue")) > 0)
|
||||
{
|
||||
throw (new QException("Minimum exceeds maximum"));
|
||||
}
|
||||
if("RECORD_LINK".equals(definition.getValueString("type")) && QContext.getQInstance().getPossibleValueSource(definition.getValueString("linkTableName")) == null)
|
||||
{
|
||||
throw (new QException("The link table needs a possible value source"));
|
||||
}
|
||||
if(definition.getValue("defaultValue") != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(CustomFieldsProvider.type(definition.getValueString("type")), definition.getValue("defaultValue"));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
throw (new QException("Invalid default value for this field type", e));
|
||||
}
|
||||
}
|
||||
var choices = options(definition);
|
||||
if(old != null && "ACTIVE".equals(old.getValueString("status")))
|
||||
{
|
||||
for(String removed : options(old).keySet())
|
||||
{
|
||||
if(!choices.containsKey(removed))
|
||||
{
|
||||
if(CustomFieldStorage.postgres(table) && CustomFieldStorage.availability(table).json())
|
||||
{
|
||||
if(optionUses(old, removed) > 0)
|
||||
{
|
||||
throw (new QException("Cannot remove an option that is still used: " + removed));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
definition.setValue("statusMessage", "Warning: option removal usage checks are only available on PostgreSQL JSON storage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Containment values are parameters, including the scalar JSON-array string.
|
||||
*******************************************************************************/
|
||||
private static long optionUses(QRecord definition, String option) throws QException
|
||||
{
|
||||
var table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
|
||||
String predicate = "qqq_custom_fields @> ?::jsonb";
|
||||
boolean multi = "MULTI_SELECT".equals(definition.getValueString("type"));
|
||||
if(multi)
|
||||
{
|
||||
predicate = "(qqq_custom_fields->>?)::jsonb @> ?::jsonb";
|
||||
}
|
||||
try(var connection = CustomFieldStorage.connection(table); var statement = connection.prepareStatement("SELECT COUNT(*) FROM " + CustomFieldStorage.sqlTable(table) + " WHERE " + predicate))
|
||||
{
|
||||
if(multi)
|
||||
{
|
||||
statement.setString(1, definition.getValueString("name"));
|
||||
statement.setString(2, new ObjectMapper().writeValueAsString(List.of(option)));
|
||||
}
|
||||
else
|
||||
{
|
||||
statement.setString(1, new ObjectMapper().writeValueAsString(Map.of(definition.getValueString("name"), option)));
|
||||
}
|
||||
try(var result = statement.executeQuery())
|
||||
{
|
||||
result.next();
|
||||
return (result.getLong(1));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Cannot verify option usage", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Count affected values for the Archive Review, without loading host records.
|
||||
*******************************************************************************/
|
||||
public static long valueCount(QRecord definition) throws QException
|
||||
{
|
||||
var table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
|
||||
String target = CustomFieldStorage.availability(table).json() ? table.getName() : CollaborationTables.FIELD_VALUE;
|
||||
QQueryFilter filter = target.equals(table.getName()) ? new QQueryFilter().withCriteria("cf_" + definition.getValueString("name"), QCriteriaOperator.IS_NOT_BLANK)
|
||||
: new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("fieldName", QCriteriaOperator.EQUALS, definition.getValueString("name"))
|
||||
.withCriteria(FieldCriteriaExpander.valueColumn(definition.getValueString("type")), QCriteriaOperator.IS_NOT_BLANK);
|
||||
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput(target).withFilter(filter);
|
||||
input.withPermissionBypass("Field manager counts affected values for Archive Review.");
|
||||
return (new com.kingsrook.qqq.backend.core.actions.tables.CountAction().execute(input).getCount());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Revalidate immediately before applying the action; refresh only after commit.
|
||||
*******************************************************************************/
|
||||
public static void apply(Serializable id, String operation) throws QException
|
||||
{
|
||||
QRecord definition = get(id);
|
||||
review(definition, operation);
|
||||
if(!"Rebuild".equals(operation))
|
||||
{
|
||||
String status = "Publish".equals(operation) ? "ACTIVE" : "ARCHIVED";
|
||||
definition.setValue("status", status);
|
||||
CollaborationRecords.update(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("id", id).withValue("status", status).withValue("indexState", "NONE"));
|
||||
new FeatureSettingsCustomizer().postUpdate(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withTransaction(QContext.getQBackendTransaction()), List.of(), java.util.Optional.empty());
|
||||
}
|
||||
BuildFieldIndexes.afterCommit(definition);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review and Apply share precisely the same state transition rules.
|
||||
*******************************************************************************/
|
||||
public static void review(QRecord definition, String operation) throws QException
|
||||
{
|
||||
FieldSecurity.requireManage();
|
||||
validate(definition, definition);
|
||||
if(!List.of("Publish", "Archive", "Rebuild").contains(operation))
|
||||
{
|
||||
throw (new QException("Unknown field action"));
|
||||
}
|
||||
if("Publish".equals(operation) && new CustomFieldTablesProvider().getPossibleValue(definition.getValueString("tableName")) == null)
|
||||
{
|
||||
throw (new QException("CUSTOM_FIELDS must be enabled before publishing"));
|
||||
}
|
||||
String expected = "Publish".equals(operation) ? "DRAFT" : "ACTIVE";
|
||||
if(!expected.equals(definition.getValueString("status")))
|
||||
{
|
||||
throw (new QException(operation + " requires " + expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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.customfields;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
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.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
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.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard streamed Review/Result field actions, never navigation entries.
|
||||
*******************************************************************************/
|
||||
public final class FieldProcesses
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static metadata factory.
|
||||
*******************************************************************************/
|
||||
private FieldProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind the action operation into trusted process metadata.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> metadata()
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
for(String operation : List.of("Publish", "Archive", "Rebuild"))
|
||||
{
|
||||
var values = new HashMap<String, Serializable>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.FIELD_DEFINITION);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.FIELD_DEFINITION);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, operation + " selected custom fields.");
|
||||
processes.add(StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values)
|
||||
.withName("collaboration" + operation + "Field").withLabel(operation.equals("Rebuild") ? "Rebuild index" : operation + " field")
|
||||
.withTableName(CollaborationTables.FIELD_DEFINITION)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(FieldSecurity.class))));
|
||||
}
|
||||
return (processes);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Request parameters cannot substitute another lifecycle operation.
|
||||
*******************************************************************************/
|
||||
private static String operation(RunBackendStepInput input)
|
||||
{
|
||||
return (input.getProcess().getName().substring("collaboration".length()).replace("Field", ""));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary language works for both singular and plural selections.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String message)
|
||||
{
|
||||
var line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(message);
|
||||
line.setPluralFutureMessage(message);
|
||||
line.setSingularPastMessage(message);
|
||||
line.setPluralPastMessage(message);
|
||||
return (line);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review validates every row and counts values retained by archive.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed rows are omitted from the load stream with an explicit reason.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(var row : input.getRecords())
|
||||
{
|
||||
var id = row.getValue("id");
|
||||
ProcessSummaryLine summary;
|
||||
try
|
||||
{
|
||||
var definition = FieldDefinitions.get(id);
|
||||
FieldDefinitions.review(definition, operation(input));
|
||||
String message = operation(input) + " field " + definition.getValueString("label");
|
||||
if("Archive".equals(operation(input)))
|
||||
{
|
||||
message += "; " + FieldDefinitions.valueCount(definition) + " records have a value (values retained)";
|
||||
}
|
||||
summary = line(Status.OK, message);
|
||||
output.getRecords().add(row);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
summary = line(Status.WARNING, "Skipped: " + e.getMessage());
|
||||
}
|
||||
summary.incrementCountAndAddPrimaryKey(id);
|
||||
lines.add(summary);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Streamed pages accumulate an explicit Review summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply rechecks permissions and status, then reports actual outcomes.
|
||||
*******************************************************************************/
|
||||
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** One failed field does not prevent other selected fields from completing.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(var row : input.getRecords())
|
||||
{
|
||||
ProcessSummaryLine summary;
|
||||
try
|
||||
{
|
||||
FieldDefinitions.apply(row.getValue("id"), operation(input));
|
||||
summary = line(Status.OK, operation(input) + " completed");
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
summary = line(Status.ERROR, "Failed: " + e.getMessage());
|
||||
}
|
||||
summary.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
lines.add(summary);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Report completed actions independently of Review predictions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.customfields;
|
||||
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
/*******************************************************************************
|
||||
** Definitions are readable with view and writable by field managers or admins.
|
||||
*******************************************************************************/
|
||||
public class FieldSecurity implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Processes and writes require management authority.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput || metadata instanceof QProcessMetaData)
|
||||
{
|
||||
requireManage();
|
||||
return;
|
||||
}
|
||||
var session = QContext.getQSession();
|
||||
if(session == null || (!session.hasPermission(CollaborationPermissions.VIEW) && !session.hasPermission(CollaborationPermissions.ADMIN)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs collaboration.view."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services recheck permission, including at the load step.
|
||||
*******************************************************************************/
|
||||
public static void requireManage() throws QPermissionDeniedException
|
||||
{
|
||||
var session = QContext.getQSession();
|
||||
if(!(session instanceof QSystemUserSession) && (session == null || (!session.hasPermission(CollaborationPermissions.FIELDS_MANAGE) && !session.hasPermission(CollaborationPermissions.ADMIN))))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs collaboration.fields.manage."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
*******************************************************************************/
|
||||
public final class CollaborationTables
|
||||
{
|
||||
public static final String FIELD_DEFINITION = "collaboration_field_definition";
|
||||
public static final String FIELD_VALUE = "collaboration_field_value";
|
||||
public static final String SCOPE_NAME = "collaboration_scope_name";
|
||||
public static final String RECORD_FIELD_NAME = "collaboration_record_field_name";
|
||||
public static final String SHARE = "collaboration_share";
|
||||
@@ -104,6 +106,16 @@ public final class CollaborationTables
|
||||
public static List<QPossibleValueSource> possibleValueSources()
|
||||
{
|
||||
return (List.of(
|
||||
QPossibleValueSource.newForTable(FIELD_DEFINITION), QPossibleValueSource.newForTable(FIELD_VALUE),
|
||||
enumSource("collaboration_field_type", "STRING", "TEXT", "INTEGER", "DECIMAL", "BOOLEAN", "DATE", "DATE_TIME", "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK"),
|
||||
enumSource("collaboration_field_status", "DRAFT", "ACTIVE", "ARCHIVED"),
|
||||
enumSource("collaboration_field_index_state", "NONE", "BUILDING", "READY", "FAILED"),
|
||||
enumSource("collaboration_field_classification", "PUBLIC", "CONFIDENTIAL", "PII", "PHI"),
|
||||
new QPossibleValueSource().withName("collaboration_custom_field_tables").withType(QPossibleValueSourceType.CUSTOM).withIdType(QFieldType.STRING)
|
||||
.withCustomCodeReference(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.CustomFieldTablesProvider.class)),
|
||||
|
||||
|
||||
|
||||
new QPossibleValueSource().withName(SCOPE_NAME).withType(QPossibleValueSourceType.CUSTOM)
|
||||
.withCustomCodeReference(new QCodeReference(FeatureScopeNamePossibleValueProvider.class)),
|
||||
new QPossibleValueSource().withName(RECORD_FIELD_NAME).withType(QPossibleValueSourceType.CUSTOM)
|
||||
@@ -189,6 +201,7 @@ public final class CollaborationTables
|
||||
tables.addAll(conversationTables(config));
|
||||
tables.addAll(tagTables(config));
|
||||
tables.addAll(workTables(config));
|
||||
tables.addAll(customFieldTables(config));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates"));
|
||||
@@ -473,4 +486,63 @@ public final class CollaborationTables
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The designer is an ordinary audited table; fallback values are children.
|
||||
*******************************************************************************/
|
||||
private static List<QTableMetaData> customFieldTables(CollaborationQBitConfig config)
|
||||
{
|
||||
QTableMetaData definitions = base(config, FIELD_DEFINITION, "Custom Fields", "input")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.FieldSecurity.class)))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withRecordLabelFormatAndFields("%s: %s", "tableName", "label")
|
||||
.withField(string("tableName", "Table", 128).withIsRequired(true).withPossibleValueSourceName("collaboration_custom_field_tables"))
|
||||
.withField(string("name", "Name", 41).withIsRequired(true))
|
||||
.withField(string("label", "Label", 255).withIsRequired(true))
|
||||
.withField(string("type", "Type", 32).withIsRequired(true).withPossibleValueSourceName("collaboration_field_type"))
|
||||
.withField(new QFieldMetaData("options", QFieldType.TEXT).withLabel("Options (JSON)"))
|
||||
.withField(string("linkTableName", "Link table", 128))
|
||||
.withField(new QFieldMetaData("required", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(new QFieldMetaData("defaultValue", QFieldType.TEXT))
|
||||
.withField(new QFieldMetaData("minValue", QFieldType.DECIMAL))
|
||||
.withField(new QFieldMetaData("maxValue", QFieldType.DECIMAL))
|
||||
.withField(new QFieldMetaData("maxLength", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("helpText", QFieldType.TEXT))
|
||||
.withField(string("sectionName", "Section", 128))
|
||||
.withField(new QFieldMetaData("sortOrder", QFieldType.INTEGER).withDefaultValue(0))
|
||||
.withField(new QFieldMetaData("filterable", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(new QFieldMetaData("searchable", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(string("classification", "Classification", 32).withPossibleValueSourceName("collaboration_field_classification"))
|
||||
.withField(string("status", "Status", 16).withDefaultValue("DRAFT").withIsRequired(true).withIsEditable(false).withPossibleValueSourceName("collaboration_field_status"))
|
||||
.withField(string("indexState", "Index state", 16).withDefaultValue("NONE").withIsEditable(false).withPossibleValueSourceName("collaboration_field_index_state"))
|
||||
.withField(string("storage", "Storage", 16).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("statusMessage", QFieldType.TEXT).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("indexDdl", QFieldType.TEXT).withLabel("Index DDL").withIsEditable(false))
|
||||
.withField(string("createdByUserId", "Created by", 255).withIsEditable(false))
|
||||
.withUniqueKey(new UniqueKey("tableName", "name"));
|
||||
QCodeReference customizer = new QCodeReference(com.kingsrook.qbits.collaboration.customfields.FieldDefinitionCustomizer.class);
|
||||
definitions.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, customizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.POST_INSERT_RECORD, customizer).withCustomizer(TableCustomizers.POST_UPDATE_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, customizer)
|
||||
.withSection(new QFieldSection("definition", new QIcon("input"), Tier.T1,
|
||||
List.of("id", "tableName", "name", "label", "type", "status", "classification")).withLabel("Definition"))
|
||||
.withSection(new QFieldSection("settings", new QIcon("settings"), Tier.T2,
|
||||
List.of("options", "linkTableName", "required", "defaultValue", "minValue", "maxValue", "maxLength", "helpText", "sectionName", "sortOrder", "filterable", "searchable")).withLabel("Settings"))
|
||||
.withSection(new QFieldSection("storage", new QIcon("storage"), Tier.T2,
|
||||
List.of("storage", "indexState", "statusMessage", "indexDdl", "createdByUserId")).withLabel("Storage and indexes"));
|
||||
QTableMetaData values = referenced(config, FIELD_VALUE, "Custom Field Values", "input")
|
||||
.withField(string("fieldName", "Field name", 41).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("valueText", QFieldType.TEXT))
|
||||
.withField(new QFieldMetaData("valueNumber", QFieldType.DECIMAL))
|
||||
.withField(new QFieldMetaData("valueDate", QFieldType.DATE))
|
||||
.withField(new QFieldMetaData("valueDateTime", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("valueBoolean", QFieldType.BOOLEAN))
|
||||
.withUniqueKey(new UniqueKey("tableName", "recordId", "fieldName"))
|
||||
.withSection(new QFieldSection("value", new QIcon("input"), Tier.T1,
|
||||
List.of("id", "fieldName", "valueText", "valueNumber", "valueDate", "valueDateTime", "valueBoolean")).withLabel("Value"));
|
||||
values.getFields().values().forEach(field -> field.setIsEditable(false));
|
||||
return (List.of(definitions, values));
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,12 @@ public final class ConversationChildren
|
||||
*******************************************************************************/
|
||||
public static void install(QInstance instance)
|
||||
{
|
||||
QJoinMetaData fieldJoin = new QJoinMetaData().withLeftTable(CollaborationTables.FIELD_DEFINITION).withRightTable(CollaborationTables.FIELD_VALUE)
|
||||
.withInferredName().withType(JoinType.ONE_TO_MANY).withJoinOns(List.of(new JoinOn("tableName", "tableName"), new JoinOn("name", "fieldName")));
|
||||
instance.addJoin(fieldJoin);
|
||||
instance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(fieldJoin).withLabel("Custom Field Values").withCanAddChildRecord(false).getWidgetMetaData());
|
||||
instance.getTable(CollaborationTables.FIELD_DEFINITION).withSection(new QFieldSection("values", new QIcon("list"), Tier.T2)
|
||||
.withLabel("Custom Field Values").withWidgetName(fieldJoin.getName()));
|
||||
QJoinMetaData tagJoin = new QJoinMetaData().withLeftTable(CollaborationTables.TAG).withRightTable(CollaborationTables.RECORD_TAG)
|
||||
.withInferredName().withType(JoinType.ONE_TO_MANY).withJoinOns(List.of(new JoinOn("id", "tagId")));
|
||||
instance.addJoin(tagJoin);
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBusInterface;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
|
||||
/*******************************************************************************
|
||||
** Ephemeral per-pod presence replicated over a channel core refuses to clients.
|
||||
** Publish outside the monitor: the in-memory bus waits for listener threads.
|
||||
*******************************************************************************/
|
||||
public final class PresenceRegistry implements AutoCloseable
|
||||
{
|
||||
public static final String INTERNAL_CHANNEL = "instance:presence";
|
||||
public static final long TTL_MILLIS = 45000;
|
||||
public static final int MAX_ENTRIES = 50000;
|
||||
private static final QLogger LOG = QLogger.getLogger(PresenceRegistry.class);
|
||||
private static final Map<QInstance, PresenceRegistry> REGISTRIES = new WeakHashMap<>();
|
||||
private final Map<Key, Entry> entries = new HashMap<>();
|
||||
private final RealtimeBusInterface bus;
|
||||
private final Clock clock;
|
||||
private final int capacity;
|
||||
private final AutoCloseable subscription;
|
||||
private ScheduledExecutorService expiry;
|
||||
|
||||
/*******************************************************************************
|
||||
** Clock and cap injection makes expiry and overload deterministic in tests.
|
||||
*******************************************************************************/
|
||||
public PresenceRegistry(RealtimeBusInterface bus, Clock clock, int capacity)
|
||||
{
|
||||
this.bus = bus;
|
||||
this.clock = clock;
|
||||
this.capacity = capacity;
|
||||
subscription = bus.subscribe(this::receive);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Install on every pod, even before its first local heartbeat.
|
||||
*******************************************************************************/
|
||||
public static synchronized PresenceRegistry get(QInstance instance)
|
||||
{
|
||||
return (REGISTRIES.computeIfAbsent(instance, key ->
|
||||
{
|
||||
var registry = new PresenceRegistry(RealtimeBus.get(instance), Clock.systemUTC(), MAX_ENTRIES);
|
||||
registry.expiry = Executors.newSingleThreadScheduledExecutor(runnable ->
|
||||
{
|
||||
Thread thread = new Thread(runnable, "collaboration-presence-expiry");
|
||||
thread.setDaemon(true);
|
||||
return (thread);
|
||||
});
|
||||
registry.expiry.scheduleWithFixedDelay(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
registry.expire();
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not expire presence", e);
|
||||
}
|
||||
}, 1, 1, TimeUnit.SECONDS);
|
||||
return (registry);
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Hosts stop the timer and unsubscribe before closing the instance realtime bus.
|
||||
*******************************************************************************/
|
||||
public static synchronized void close(QInstance instance)
|
||||
{
|
||||
var registry = REGISTRIES.remove(instance);
|
||||
if(registry != null)
|
||||
{
|
||||
registry.close();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identity namespaces client connection ids by the authenticated user.
|
||||
*******************************************************************************/
|
||||
private record Key(String user, String connection, String table, String pk)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Each tab has its own lease; user views merge the live leases.
|
||||
*******************************************************************************/
|
||||
private record Entry(String displayName, String mode, String fieldName, long at)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport-independent snapshot entry, never a persisted directory row.
|
||||
*******************************************************************************/
|
||||
public record Viewer(String userId, String displayName, String mode, String fieldName) implements Serializable
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Refresh locally even if transport delivery fails; replicate every heartbeat.
|
||||
*******************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName)
|
||||
{
|
||||
expire();
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(displayName, mode, fieldName, clock.millis());
|
||||
if(!apply(key, entry, false))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
publish(key, entry, false);
|
||||
return (true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leaves use the same internal channel so every pod removes the lease.
|
||||
*******************************************************************************/
|
||||
public void leave(String user, String connection, String table, String pk)
|
||||
{
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(null, null, null, clock.millis());
|
||||
apply(key, entry, true);
|
||||
publish(key, entry, true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal payloads contain only identity and focus, never record values.
|
||||
*******************************************************************************/
|
||||
private void publish(Key key, Entry entry, boolean leave)
|
||||
{
|
||||
Map<String, Serializable> data = new LinkedHashMap<>();
|
||||
data.put("userId", key.user());
|
||||
data.put("connectionId", key.connection());
|
||||
data.put("table", key.table());
|
||||
data.put("pk", key.pk());
|
||||
data.put("displayName", entry.displayName());
|
||||
data.put("mode", entry.mode());
|
||||
data.put("fieldName", entry.fieldName());
|
||||
data.put("at", entry.at());
|
||||
data.put("leave", leave);
|
||||
bus.publish(new RealtimeMessage().withChannel(INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(data));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Client-visible change events cannot enter the replication path.
|
||||
*******************************************************************************/
|
||||
private void receive(RealtimeMessage message)
|
||||
{
|
||||
if(!INTERNAL_CHANNEL.equals(message.getChannel()) || !"presence.heartbeat".equals(message.getEvent()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var data = message.getData();
|
||||
if(!(data.get("at") instanceof Number at) || !(data.get("userId") instanceof String user)
|
||||
|| !(data.get("connectionId") instanceof String connection) || !(data.get("table") instanceof String table))
|
||||
{
|
||||
return;
|
||||
}
|
||||
expire();
|
||||
long receivedAt = Math.min(at.longValue(), clock.millis());
|
||||
if(receivedAt + TTL_MILLIS <= clock.millis())
|
||||
{
|
||||
return;
|
||||
}
|
||||
apply(new Key(user, connection, table, (String) data.get("pk")),
|
||||
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unchanged renewals extend TTL without generating client event traffic.
|
||||
*******************************************************************************/
|
||||
private boolean apply(Key key, Entry entry, boolean leave)
|
||||
{
|
||||
boolean changed;
|
||||
synchronized(this)
|
||||
{
|
||||
Entry old = entries.get(key);
|
||||
if(old != null && old.at() > entry.at())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(!leave && old == null && entries.size() >= capacity)
|
||||
{
|
||||
LOG.warn("Presence capacity reached; refusing a new target");
|
||||
return (false);
|
||||
}
|
||||
List<Viewer> before = snapshotInternal(key.table(), key.pk());
|
||||
if(leave)
|
||||
{
|
||||
entries.remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
entries.put(key, entry);
|
||||
}
|
||||
changed = !before.equals(snapshotInternal(key.table(), key.pk()));
|
||||
}
|
||||
if(changed)
|
||||
{
|
||||
changed(key.table(), key.pk());
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Expiry is active even without a polling client; expired data is never returned.
|
||||
*******************************************************************************/
|
||||
public void expire()
|
||||
{
|
||||
List<Key> removed = new ArrayList<>();
|
||||
synchronized(this)
|
||||
{
|
||||
entries.entrySet().removeIf(entry ->
|
||||
{
|
||||
if(entry.getValue().at() + TTL_MILLIS <= clock.millis())
|
||||
{
|
||||
removed.add(entry.getKey());
|
||||
return (true);
|
||||
}
|
||||
return (false);
|
||||
});
|
||||
}
|
||||
removed.stream().map(key -> new Key(null, null, key.table(), key.pk())).distinct().forEach(key -> changed(key.table(), key.pk()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Table snapshots omit record keys and focused fields to avoid lock disclosures.
|
||||
*******************************************************************************/
|
||||
public List<Viewer> snapshot(String table, String pk)
|
||||
{
|
||||
expire();
|
||||
synchronized(this)
|
||||
{
|
||||
return (snapshotInternal(table, pk));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Editing wins over viewing; within a mode the most recent tab supplies focus.
|
||||
*******************************************************************************/
|
||||
private List<Viewer> snapshotInternal(String table, String pk)
|
||||
{
|
||||
Map<String, Entry> users = new java.util.TreeMap<>();
|
||||
for(var row : entries.entrySet())
|
||||
{
|
||||
if(row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
|
||||
{
|
||||
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
|
||||
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
|
||||
}
|
||||
}
|
||||
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName())).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only counts are exposed to the admin home, with no row contents or record ids.
|
||||
*******************************************************************************/
|
||||
public Map<String, Integer> counts()
|
||||
{
|
||||
expire();
|
||||
synchronized(this)
|
||||
{
|
||||
Map<String, Integer> result = new java.util.TreeMap<>();
|
||||
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, snapshotInternal(table, null).size()));
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record focus stays on its record channel; table events contain table snapshots.
|
||||
*******************************************************************************/
|
||||
private void changed(String table, String pk)
|
||||
{
|
||||
if(pk != null)
|
||||
{
|
||||
emit("record:" + table + ":" + pk, table, pk);
|
||||
}
|
||||
emit("table:" + table, table, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Maps keep the bus payload portable across in-memory and JSON ESB transports.
|
||||
*******************************************************************************/
|
||||
private void emit(String channel, String table, String pk)
|
||||
{
|
||||
Map<String, Serializable> data = new LinkedHashMap<>();
|
||||
data.put("table", table);
|
||||
if(pk != null)
|
||||
{
|
||||
data.put("pk", pk);
|
||||
}
|
||||
ArrayList<LinkedHashMap<String, Serializable>> viewers = new ArrayList<>();
|
||||
synchronized(this)
|
||||
{
|
||||
for(Viewer viewer : snapshotInternal(table, pk))
|
||||
{
|
||||
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
|
||||
value.put("userId", viewer.userId());
|
||||
value.put("displayName", viewer.displayName());
|
||||
value.put("mode", viewer.mode());
|
||||
value.put("fieldName", viewer.fieldName());
|
||||
viewers.add(value);
|
||||
}
|
||||
}
|
||||
data.put("viewers", viewers);
|
||||
bus.publish(new RealtimeMessage().withChannel(channel).withEvent("presence.changed").withData(data));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Release listeners and timer on host shutdown or in tests.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
if(expiry != null)
|
||||
{
|
||||
expiry.shutdownNow();
|
||||
}
|
||||
try
|
||||
{
|
||||
subscription.close();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not unsubscribe presence", e);
|
||||
}
|
||||
synchronized(this)
|
||||
{
|
||||
entries.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticated, permission-checked boundary for presence; nothing is persisted.
|
||||
*******************************************************************************/
|
||||
public class PresenceService
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Recheck visibility on every renewal and remove a refused caller's old lease.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> heartbeat(String connection, String table, String pk, String mode, String field) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
validateConnection(connection);
|
||||
pk = canonicalKey(table, pk);
|
||||
try
|
||||
{
|
||||
pk = authorize(table, pk);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
registry().leave(user, connection, table, pk);
|
||||
throw (e);
|
||||
}
|
||||
if(!List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
|
||||
{
|
||||
throw (new QBadRequestException("Mode must be VIEWING or EDITING"));
|
||||
}
|
||||
if(field != null && (pk == null || QContext.getQInstance().getTable(table).getFields().get(field) == null))
|
||||
{
|
||||
throw (new QBadRequestException("Unknown focused field"));
|
||||
}
|
||||
String name = QContext.getQSession().getUser().getFullName();
|
||||
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, table, pk, mode, field))
|
||||
{
|
||||
throw (new QBadRequestException("Presence capacity reached; retry later"));
|
||||
}
|
||||
return (registry().snapshot(table, pk));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Namespacing by user prevents one user from removing another user's connection.
|
||||
*******************************************************************************/
|
||||
public void leave(String connection, String table, String pk) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
validateConnection(connection);
|
||||
String canonical = authorize(table, pk);
|
||||
registry().leave(user, connection, table, canonical);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Polling has the same access boundary as heartbeat and SSE record subscriptions.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> snapshot(String table, String pk) throws QException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
return (registry().snapshot(table, authorize(table, pk)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A refusal always has the same not-found result, including disabled features.
|
||||
*******************************************************************************/
|
||||
private String authorize(String table, String pk) throws QException
|
||||
{
|
||||
if(!ParentRecordAccess.canReadTable(table) || !RecordFeaturesResolver.resolve(QContext.getQInstance(), QContext.getQInstance().getTable(table)).isEnabled(RecordFeature.PRESENCE))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
if(pk != null)
|
||||
{
|
||||
var record = ParentRecordAccess.requireVisibleParent(table, pk);
|
||||
return (record.getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField()));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonicalize before the read check so a refused renewal removes an aliased key.
|
||||
*******************************************************************************/
|
||||
private String canonicalKey(String tableName, String pk) throws QNotFoundException
|
||||
{
|
||||
if(pk == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
var table = QContext.getQInstance().getTable(tableName);
|
||||
if(table == null || table.getPrimaryKeyField() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Unknown table"));
|
||||
}
|
||||
return (com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), pk).toString());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound caller-controlled keys independently of the registry entry cap.
|
||||
*******************************************************************************/
|
||||
private void validateConnection(String connection) throws QBadRequestException
|
||||
{
|
||||
if(connection == null || connection.isBlank() || connection.length() > 255)
|
||||
{
|
||||
throw (new QBadRequestException("A connectionId of at most 255 characters is required"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All endpoints on a pod share the eagerly installed instance registry.
|
||||
*******************************************************************************/
|
||||
private PresenceRegistry registry()
|
||||
{
|
||||
return (PresenceRegistry.get(QContext.getQInstance()));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -55,7 +55,7 @@ public class CollaborationRowSecurity implements CustomPermissionChecker
|
||||
fields.addAll(java.util.List.of("id", "tableName", "recordId", "parentDeleted"));
|
||||
query.setFieldNamesToInclude(fields);
|
||||
}
|
||||
if(!(metadata instanceof QTableMetaData table && table.getName().equals(CollaborationTables.RECORD_TAG)))
|
||||
if(!(metadata instanceof QTableMetaData table && (table.getName().equals(CollaborationTables.RECORD_TAG) || table.getName().equals(CollaborationTables.FIELD_VALUE))))
|
||||
{
|
||||
new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
package com.kingsrook.qbits.collaboration.tags;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.FeatureSettingsProvider;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
@@ -30,14 +29,11 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeatureOverride;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RuntimeMetaDataContribution;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RuntimeTableMetaDataContribution;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Contribute the Tags filter and quick-search field without changing host hooks.
|
||||
@@ -56,7 +52,7 @@ public class TagsMetaDataProvider implements RuntimeMetaDataProviderInterface
|
||||
{
|
||||
if(!table.getName().startsWith("collaboration_") && table.getPrimaryKeyField() != null
|
||||
&& (table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_GET) || table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_QUERY))
|
||||
&& enabled(instance, table, overrides))
|
||||
&& FeatureSettingsProvider.enabled(instance, table, RecordFeature.TAGS, overrides))
|
||||
{
|
||||
contribution.getTables().put(table.getName(), new RuntimeTableMetaDataContribution()
|
||||
.withVirtualFields(List.of(new QVirtualFieldMetaData("collaborationTags", QFieldType.STRING).withLabel("Tags")
|
||||
@@ -68,54 +64,4 @@ public class TagsMetaDataProvider implements RuntimeMetaDataProviderInterface
|
||||
return (contribution);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve TAGS against the incoming policy in core's instance/app/table order.
|
||||
*******************************************************************************/
|
||||
private boolean enabled(QInstance instance, QTableMetaData table, List<RecordFeatureOverride> overrides)
|
||||
{
|
||||
List<RecordFeaturesMetaData> levels = new ArrayList<>();
|
||||
List<String> apps = new ArrayList<>();
|
||||
levels.add(RecordFeaturesMetaData.of(instance));
|
||||
if(instance.getApps() != null)
|
||||
{
|
||||
for(var app : instance.getApps().values())
|
||||
{
|
||||
if(app.getSections() != null && app.getSections().stream().anyMatch(section -> section.getTables() != null && section.getTables().contains(table.getName())))
|
||||
{
|
||||
levels.add(RecordFeaturesMetaData.of(app));
|
||||
apps.add(app.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
levels.add(RecordFeaturesMetaData.of(table));
|
||||
boolean enabled = false;
|
||||
for(var level : levels)
|
||||
{
|
||||
if(level != null)
|
||||
{
|
||||
if(Boolean.FALSE.equals(level.getFeatureLocks().get(RecordFeature.TAGS)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
enabled = level.getEnabled().getOrDefault(RecordFeature.TAGS, enabled);
|
||||
}
|
||||
}
|
||||
for(var scope : RecordFeatureOverride.ScopeType.values())
|
||||
{
|
||||
List<String> scopes = scope == RecordFeatureOverride.ScopeType.APP ? apps : java.util.Collections.singletonList(scope == RecordFeatureOverride.ScopeType.TABLE ? table.getName() : null);
|
||||
for(String scopeName : scopes)
|
||||
{
|
||||
for(var override : overrides)
|
||||
{
|
||||
if(override.getFeature() == RecordFeature.TAGS && override.getScopeType() == scope && override.getEnabled() != null
|
||||
&& (scope == RecordFeatureOverride.ScopeType.INSTANCE || (scope == RecordFeatureOverride.ScopeType.APP && scopeName.equals(override.getScopeName()))
|
||||
|| (scope == RecordFeatureOverride.ScopeType.TABLE && table.getName().equals(override.getScopeName()))))
|
||||
{
|
||||
enabled = override.getEnabled();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (enabled);
|
||||
}
|
||||
}
|
||||
@@ -1046,11 +1046,151 @@ info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "Record collaboration: favorites, recents, attachments, tags, conversations,\
|
||||
\ following, timeline and people"
|
||||
\ following, timeline, people and presence"
|
||||
title: "QQQ Middleware API extension: collaboration"
|
||||
version: "1.0.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/qbit/collaboration/presence:
|
||||
delete:
|
||||
description: "Leave presence. Uses the signed-in caller's permissions and record\
|
||||
\ visibility."
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationPresenceLeaveV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationMutationResultV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Leave presence"
|
||||
get:
|
||||
description: "Get presence snapshot. Uses the signed-in caller's permissions\
|
||||
\ and record visibility."
|
||||
parameters:
|
||||
- description: "Parent table metadata name."
|
||||
in: "query"
|
||||
name: "table"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Parent record primary key."
|
||||
in: "query"
|
||||
name: "pk"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationPresenceSnapshotV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Get presence snapshot"
|
||||
post:
|
||||
description: "Renew presence. Uses the signed-in caller's permissions and record\
|
||||
\ visibility."
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationPresenceRequestV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationErrorV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationPresenceSnapshotV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Renew presence"
|
||||
/qqq/v1/qbit/collaboration/favorites:
|
||||
delete:
|
||||
description: "Unstar a target. Uses the signed-in caller's permissions and record\
|
||||
|
||||
@@ -59,7 +59,7 @@ class CollaborationApiContractTest
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration"));
|
||||
assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/"));
|
||||
assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor");
|
||||
assertThat(extension.getSpecs()).hasSize(42);
|
||||
assertThat(extension.getSpecs()).hasSize(45);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -166,6 +166,24 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
assertError(400, "POST", "/records/document/1/tasks", "{\"title\":5}", "alice");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Presence endpoints share parent checks and return typed merged snapshots.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void presenceHttp() throws Exception
|
||||
{
|
||||
String body = "{\"connectionId\":\"tab-one\",\"table\":\"document\",\"pk\":\"1\",\"mode\":\"EDITING\",\"fieldName\":\"title\"}";
|
||||
assertThat(ok("POST", "/presence", body).get("viewers")).hasSize(1);
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).at("/viewers/0/mode").asText()).isEqualTo("EDITING");
|
||||
assertThat(ok("GET", "/presence?table=document", null).at("/viewers/0/fieldName").isNull()).isTrue();
|
||||
assertError(404, "GET", "/presence?table=document&pk=3", null, "alice");
|
||||
assertError(404, "POST", "/presence", body, "outsider");
|
||||
assertError(400, "POST", "/presence", "{\"connectionId\":\"x\",\"table\":\"document\",\"mode\":\"BAD\"}", "alice");
|
||||
assertError(400, "GET", "/presence", null, "alice");
|
||||
assertThat(ok("DELETE", "/presence", "{\"connectionId\":\"tab-one\",\"table\":\"document\",\"pk\":\"1\"}").get("success").asBoolean()).isTrue();
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).get("viewers")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every personal operation and offset cursor runs through the HTTP boundary.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -114,7 +114,7 @@ class CollaborationTest
|
||||
CollaborationTables.SCOPE_TYPE, CollaborationTables.FEATURE, CollaborationTables.PERMISSION_MODE);
|
||||
assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12);
|
||||
assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName())
|
||||
.containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
|
||||
.containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
|
||||
assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET);
|
||||
assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull();
|
||||
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
@@ -177,9 +177,9 @@ class CollaborationTest
|
||||
QInstance headless = new QInstance();
|
||||
headless.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
new CollaborationQBitProducer().withQBitConfig(config.withAdminApp(false)).produce(headless);
|
||||
assertThat(headless.getTables()).hasSize(17);
|
||||
assertThat(headless.getTables()).hasSize(19);
|
||||
assertThat(headless.getApps()).isEmpty();
|
||||
assertThat(headless.getWidgets()).hasSize(4);
|
||||
assertThat(headless.getWidgets()).hasSize(5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.customfields.BuildFieldIndexes;
|
||||
import com.kingsrook.qbits.collaboration.customfields.CustomFieldStorage;
|
||||
import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionProviderInterface;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** JDBC contract tests capture SQL and connection state without a live PostgreSQL server.
|
||||
*******************************************************************************/
|
||||
public class CustomFieldSqlTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Use the real backend connection boundary with a deterministic driver double.
|
||||
*******************************************************************************/
|
||||
private QRecord setupSql() throws Exception
|
||||
{
|
||||
admin();
|
||||
Jdbc.sql.clear();
|
||||
Jdbc.parameters.clear();
|
||||
Jdbc.failProbe = false;
|
||||
Jdbc.failDdl = false;
|
||||
Jdbc.usage = 0;
|
||||
Jdbc.connections = 0;
|
||||
ConnectionManager.resetConnectionProviders();
|
||||
instance.addBackend(new RDBMSBackendMetaData().withName("test-postgres").withVendor("postgres").withConnectionProvider(new QCodeReference(Jdbc.class)));
|
||||
instance.getTable("document").withBackendName("test-postgres").withBackendDetails(new RDBMSTableBackendDetails().withTableName("tenant.orders"));
|
||||
return (CollaborationRecords.insert(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("tableName", "document")
|
||||
.withValue("name", "total").withValue("label", "Total").withValue("type", "DECIMAL").withValue("status", "ACTIVE")
|
||||
.withValue("filterable", true).withValue("indexState", "NONE")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Column absence is cached; retry after migration is explicit and executes once.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void columnProbeIsCachedAndReportsFailures() throws Exception
|
||||
{
|
||||
setupSql();
|
||||
var table = instance.getTable("document");
|
||||
Jdbc.failProbe = true;
|
||||
assertThat(CustomFieldStorage.availability(table).json()).isFalse();
|
||||
assertThat(CustomFieldStorage.availability(table).reason()).contains("column unavailable");
|
||||
assertThat(Jdbc.connections).isEqualTo(1);
|
||||
Jdbc.failProbe = false;
|
||||
CustomFieldStorage.invalidate(instance, "document");
|
||||
assertThat(CustomFieldStorage.availability(table).json()).isTrue();
|
||||
assertThat(Jdbc.connections).isEqualTo(2);
|
||||
assertThat(Jdbc.sql).contains("SELECT qqq_custom_fields FROM \"tenant\".\"orders\" LIMIT 0");
|
||||
assertThatThrownBy(() -> CustomFieldStorage.identifier(" ")).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Concurrent DDL requires autocommit and failures remain visible on the definition.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void indexConnectionAndFailureStates() throws Exception
|
||||
{
|
||||
var definition = setupSql();
|
||||
BuildFieldIndexes.build(definition);
|
||||
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("indexState")).isEqualTo("READY");
|
||||
assertThat(Jdbc.sql).anyMatch(sql -> sql.contains("CREATE INDEX CONCURRENTLY") && sql.contains("::numeric"));
|
||||
Jdbc.failDdl = true;
|
||||
BuildFieldIndexes.build(definition);
|
||||
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("indexState")).isEqualTo("FAILED");
|
||||
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("statusMessage")).contains("test DDL failure");
|
||||
Jdbc.failDdl = false;
|
||||
definition.withValue("status", "ARCHIVED");
|
||||
BuildFieldIndexes.build(definition);
|
||||
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("indexState")).isEqualTo("NONE");
|
||||
assertThat(Jdbc.sql).anyMatch(sql -> sql.startsWith("DROP INDEX CONCURRENTLY"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Used stable ids cannot be removed; labels may change and new ids may be added.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void activeOptionRemovalUsesBoundContainmentQuery() throws Exception
|
||||
{
|
||||
var definition = setupSql().withValue("type", "SELECT").withValue("options", "[{\"id\":\"a\",\"label\":\"Alpha\"}]");
|
||||
var changed = new QRecord(definition).withValue("options", "[]");
|
||||
Jdbc.usage = 1;
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(changed, definition)).hasMessageContaining("still used");
|
||||
assertThat(Jdbc.sql).anyMatch(sql -> sql.contains("qqq_custom_fields @> ?::jsonb"));
|
||||
assertThat(Jdbc.parameters.get(1)).isEqualTo("{\"total\":\"a\"}");
|
||||
FieldDefinitions.validate(new QRecord(definition).withValue("options", "[{\"id\":\"a\",\"label\":\"Renamed\"},{\"id\":\"b\",\"label\":\"Added\"}]"), definition);
|
||||
Jdbc.usage = 0;
|
||||
FieldDefinitions.validate(changed, definition);
|
||||
definition.withValue("type", "MULTI_SELECT");
|
||||
changed.withValue("type", "MULTI_SELECT");
|
||||
FieldDefinitions.validate(changed, definition);
|
||||
assertThat(Jdbc.parameters.get(1)).isEqualTo("total");
|
||||
assertThat(Jdbc.parameters.get(2)).isEqualTo("[\"a\"]");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture the JDBC boundary, including whether concurrent DDL was autocommitted.
|
||||
*******************************************************************************/
|
||||
public static class Jdbc implements ConnectionProviderInterface
|
||||
{
|
||||
static final List<String> sql = new ArrayList<>();
|
||||
static final Map<Integer, String> parameters = new LinkedHashMap<>();
|
||||
static boolean failProbe;
|
||||
static boolean failDdl;
|
||||
static long usage;
|
||||
static int connections;
|
||||
|
||||
/*******************************************************************************
|
||||
** No external connection configuration is needed by the test double.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void init(RDBMSBackendMetaData backend)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A new connection starts outside autocommit to verify the index job sets it.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Connection getConnection()
|
||||
{
|
||||
connections++;
|
||||
boolean[] autoCommit = {false};
|
||||
return ((Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{Connection.class}, (proxy, method, args) ->
|
||||
{
|
||||
return (switch(method.getName())
|
||||
{
|
||||
case "setAutoCommit" ->
|
||||
{
|
||||
autoCommit[0] = (Boolean) args[0];
|
||||
yield null;
|
||||
}
|
||||
case "getAutoCommit" -> autoCommit[0];
|
||||
case "createStatement" -> statement(null, autoCommit);
|
||||
case "prepareStatement" -> statement((String) args[0], autoCommit);
|
||||
case "close" -> null;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only supported SQL operations are accepted; unexpected calls fail the test.
|
||||
*******************************************************************************/
|
||||
private Object statement(String prepared, boolean[] autoCommit)
|
||||
{
|
||||
return (Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{prepared == null ? Statement.class : PreparedStatement.class}, (proxy, method, args) ->
|
||||
{
|
||||
if(method.getName().equals("close"))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(method.getName().equals("setString"))
|
||||
{
|
||||
parameters.put((Integer) args[0], (String) args[1]);
|
||||
return (null);
|
||||
}
|
||||
String command = prepared == null ? (String) args[0] : prepared;
|
||||
sql.add(command);
|
||||
if(command.startsWith("SELECT qqq_custom_fields") && failProbe)
|
||||
{
|
||||
throw (new SQLException("column unavailable"));
|
||||
}
|
||||
if(method.getName().equals("execute"))
|
||||
{
|
||||
assertThat(autoCommit[0]).isTrue();
|
||||
if(failDdl)
|
||||
{
|
||||
throw (new SQLException("test DDL failure"));
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
return (Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{ResultSet.class}, (result, operation, values) -> switch(operation.getName())
|
||||
{
|
||||
case "next", "getBoolean" -> true;
|
||||
case "getLong" -> usage;
|
||||
case "close" -> null;
|
||||
default -> throw new UnsupportedOperationException(operation.getName());
|
||||
}));
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* 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.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.customfields.BuildFieldIndexes;
|
||||
import com.kingsrook.qbits.collaboration.customfields.CustomFieldTablesProvider;
|
||||
import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions;
|
||||
import com.kingsrook.qbits.collaboration.customfields.FieldProcesses;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
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.fields.JsonPathStorage;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise designer rules and storage through ordinary core actions.
|
||||
*******************************************************************************/
|
||||
public class CustomFieldsTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Create a real draft through the permission and pre-insert customizers.
|
||||
*******************************************************************************/
|
||||
private QRecord draft(String name, String type) throws Exception
|
||||
{
|
||||
var result = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
|
||||
new QRecord().withValue("tableName", "document").withValue("name", name).withValue("label", name).withValue("type", type)))).getRecords().getFirst();
|
||||
assertThat(result.getErrors()).isNullOrEmpty();
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A published numeric JSON field is writable, filterable, validated and classified.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memoryFieldsUseNormalActionsAndPreserveClassification() throws Exception
|
||||
{
|
||||
admin();
|
||||
QRecord definition = draft("score", "INTEGER");
|
||||
var result = new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
|
||||
new QRecord().withValue("id", definition.getValue("id")).withValue("minValue", 1).withValue("maxValue", 20).withValue("classification", "PHI")
|
||||
.withValue("sectionName", "scores")))).getRecords().getFirst();
|
||||
assertThat(result.getErrors()).isNullOrEmpty();
|
||||
FieldDefinitions.apply(definition.getValue("id"), "Publish");
|
||||
var field = instance.getTable("document").getField("cf_score");
|
||||
assertThat(field.getBackendStorage()).isInstanceOf(JsonPathStorage.class);
|
||||
assertThat(AiDataClassifications.ofField(instance.getTable("document"), "cf_score")).isEqualTo(DataClassification.PHI);
|
||||
assertThat(instance.getTable("document").getSections()).anySatisfy(section -> assertThat(section.getFieldNames()).contains("cf_score"));
|
||||
login("alice");
|
||||
var inserted = new InsertAction().execute(new InsertInput("document").withRecords(List.of(new QRecord().withValue("id", 4).withValue("tenant", "a")
|
||||
.withValue("title", "Fourth").withValue("cf_score", 7)))).getRecords().getFirst();
|
||||
assertThat(inserted.getErrors()).isNullOrEmpty();
|
||||
assertThat(query("cf_score", QCriteriaOperator.GREATER_THAN, 5)).extracting(row -> row.getValueInteger("id")).containsExactly(4);
|
||||
var updated = new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", 4).withValue("cf_score", 12)))).getRecords().getFirst();
|
||||
assertThat(updated.getErrors()).isNullOrEmpty();
|
||||
assertThat(query("cf_score", QCriteriaOperator.EQUALS, 12)).hasSize(1);
|
||||
var invalid = new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", 4).withValue("cf_score", 30)))).getRecords().getFirst();
|
||||
assertThat(invalid.getErrors()).isNotEmpty();
|
||||
admin();
|
||||
assertThat(FieldDefinitions.valueCount(FieldDefinitions.get(definition.getValue("id")))).isEqualTo(1);
|
||||
FieldDefinitions.apply(definition.getValue("id"), "Archive");
|
||||
assertThat(instance.getTable("document").getFields()).doesNotContainKey("cf_score");
|
||||
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("status")).isEqualTo("ARCHIVED");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Normal permissions and direct table operations cannot bypass lifecycle rules.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validationPermissionsAndTypeRules() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> draft("denied", "STRING")).hasMessageContaining("collaboration.fields.manage");
|
||||
login("manager", "collaboration.fields.manage", "collaboration.view");
|
||||
QRecord row = draft("choice", "SELECT");
|
||||
var duplicate = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(new QRecord()
|
||||
.withValue("tableName", "document").withValue("name", "choice").withValue("label", "Duplicate").withValue("type", "SELECT")))).getRecords().getFirst();
|
||||
assertThat(duplicate.getErrors()).isNotEmpty();
|
||||
assertThatThrownBy(() -> draft("Bad-name", "STRING")).hasMessageContaining("name");
|
||||
instance.getTable("document").withField(new QFieldMetaData("cf_clash", QFieldType.STRING));
|
||||
assertThatThrownBy(() -> draft("clash", "STRING")).hasMessageContaining("clash");
|
||||
instance.getTable("document").getFields().remove("cf_clash");
|
||||
assertThatThrownBy(() -> draft("link", "RECORD_LINK")).hasMessageContaining("possible value");
|
||||
assertThat(new CustomFieldTablesProvider().getPossibleValue("document")).isNotNull();
|
||||
assertThat(new CustomFieldTablesProvider().getPossibleValue("missing")).isNull();
|
||||
var changed = new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(new QRecord()
|
||||
.withValue("id", row.getValue("id")).withValue("type", "STRING")))).getRecords().getFirst();
|
||||
assertThat(changed.getErrors()).isNullOrEmpty();
|
||||
FieldDefinitions.apply(row.getValue("id"), "Publish");
|
||||
assertThatThrownBy(() -> FieldDefinitions.apply(row.getValue("id"), "Publish")).hasMessageContaining("DRAFT");
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
|
||||
new QRecord().withValue("id", row.getValue("id")).withValue("type", "INTEGER"))))).hasMessageContaining("DRAFT");
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
|
||||
new QRecord().withValue("id", row.getValue("id")).withValue("status", "ARCHIVED"))))).hasMessageContaining("actions");
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(row.getValue("id"))))).hasMessageContaining("archive");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("name", "renamed"), row)).hasMessageContaining("cannot change");
|
||||
assertThatThrownBy(() -> FieldDefinitions.options(new QRecord().withValue("options", "{}"))).hasMessageContaining("options");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("classification", "BAD"), row)).hasMessageContaining("classification");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("minValue", 5).withValue("maxValue", 1), row)).hasMessageContaining("Minimum");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Definitions contribute all supported types, options, search and defaults.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contributionMappingsAndGeneration() throws Exception
|
||||
{
|
||||
admin();
|
||||
long before = RuntimeMetaDataOverlay.getGeneration(instance);
|
||||
for(String type : List.of("STRING", "TEXT", "DECIMAL", "BOOLEAN", "DATE", "DATE_TIME", "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK"))
|
||||
{
|
||||
QRecord row = new QRecord().withValue("tableName", "document").withValue("name", type.toLowerCase()).withValue("label", type)
|
||||
.withValue("type", type).withValue("status", "ACTIVE").withValue("linkTableName", CollaborationTables.USER)
|
||||
.withValue("helpText", "Helpful text").withValue("maxLength", type.equals("STRING") ? 30 : null)
|
||||
.withValue("searchable", type.equals("STRING")).withValue("options", "[{\"id\":\"a\",\"label\":\"Alpha\"}]");
|
||||
CollaborationRecords.insert(CollaborationTables.FIELD_DEFINITION, row);
|
||||
}
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isGreaterThan(before);
|
||||
assertThat(instance.getTable("document").getField("cf_decimal").getType()).isEqualTo(QFieldType.DECIMAL);
|
||||
assertThat(instance.getTable("document").getField("cf_multi_select").getType()).isEqualTo(QFieldType.STRING);
|
||||
assertThat(instance.getTable("document").getSearchFields()).contains("cf_string");
|
||||
assertThat(instance.getTable("document").getField("cf_user").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
|
||||
assertThat(instance.getTable("document").getField("cf_select").getPossibleValueSourceName()).startsWith("collaboration_field_options_");
|
||||
QRecord draft = draft("transaction", "STRING");
|
||||
QBackendTransaction tx = new QBackendTransaction();
|
||||
before = RuntimeMetaDataOverlay.getGeneration(instance);
|
||||
new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withTransaction(tx).withRecords(List.of(new QRecord()
|
||||
.withValue("id", draft.getValue("id")).withValue("label", "Updated"))));
|
||||
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isEqualTo(before);
|
||||
tx.commit();
|
||||
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isGreaterThan(before);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build migration SQL without opening a PostgreSQL connection or executing DDL.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void managedFalseRecordsDdl() throws Exception
|
||||
{
|
||||
admin();
|
||||
QRecord row = draft("amount", "DECIMAL");
|
||||
row.withValue("filterable", true).withValue("status", "ACTIVE");
|
||||
var backend = new RDBMSBackendMetaData().withVendor("postgres").withName("postgres");
|
||||
instance.addBackend(backend);
|
||||
instance.getTable("document").setBackendName("postgres");
|
||||
config.withManagedIndexes(false);
|
||||
assertThat(config.getManagedIndexes()).isFalse();
|
||||
BuildFieldIndexes.build(row);
|
||||
QRecord stored = FieldDefinitions.get(row.getValue("id"));
|
||||
assertThat(stored.getValueString("indexState")).isEqualTo("NONE");
|
||||
assertThat(stored.getValueString("indexDdl")).contains("CREATE INDEX CONCURRENTLY IF NOT EXISTS", "qqq_custom_fields->>'amount'", "::numeric");
|
||||
assertThat(BuildFieldIndexes.ddl(instance.getTable("document"), new QRecord(row).withValue("type", "STRING").withValue("searchable", true), true)).contains("USING gin", "gin_trgm_ops");
|
||||
assertThat(BuildFieldIndexes.ddl(instance.getTable("document"), new QRecord(row).withValue("status", "ARCHIVED"), false)).startsWith("DROP INDEX CONCURRENTLY");
|
||||
assertThat(BuildFieldIndexes.ddl(instance.getTable("document"), new QRecord(row).withValue("filterable", false), false)).isNull();
|
||||
assertThat(CollaborationSchema.customFieldsDdl("orders")).contains("ADD COLUMN IF NOT EXISTS qqq_custom_fields JSONB", "jsonb_path_ops");
|
||||
assertThat(FieldProcesses.metadata()).extracting(process -> process.getLabel()).containsExactly("Publish field", "Archive field", "Rebuild index");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An unsupported JSON backend gets typed, read-only subquery filters.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void eavFiltersAndSearch() throws Exception
|
||||
{
|
||||
admin();
|
||||
instance.addBackend(new RDBMSBackendMetaData().withName("fallback").withVendor("h2"));
|
||||
instance.getTable("document").setBackendName("fallback");
|
||||
QRecord definition = draft("rating", "INTEGER");
|
||||
FieldDefinitions.apply(definition.getValue("id"), "Publish");
|
||||
instance.getTable("document").setBackendName("memory");
|
||||
var virtual = instance.getTable("document").getVirtualField("cf_rating");
|
||||
assertThat(virtual).isNotNull();
|
||||
assertThat(virtual.getIsQuerySelectable()).isFalse();
|
||||
assertThat(virtual.getIsEditable()).isFalse();
|
||||
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("storage")).isEqualTo("EAV");
|
||||
CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "1")
|
||||
.withValue("fieldName", "rating").withValue("valueNumber", new BigDecimal("7")).withValue("parentDeleted", false));
|
||||
login("alice");
|
||||
assertThat(query("cf_rating", QCriteriaOperator.GREATER_THAN, 5)).extracting(row -> row.getValueInteger("id")).containsExactly(1);
|
||||
assertThat(query("cf_rating", QCriteriaOperator.NOT_EQUALS, 7)).extracting(row -> row.getValueInteger("id")).containsExactly(2);
|
||||
assertThat(new QueryAction().execute(new QueryInput("document").withFilter(new QQueryFilter().withCriteria("cf_rating", QCriteriaOperator.IS_BLANK))).getRecords())
|
||||
.extracting(row -> row.getValueInteger("id")).containsExactly(2);
|
||||
admin();
|
||||
assertThat(FieldDefinitions.valueCount(FieldDefinitions.get(definition.getValue("id")))).isEqualTo(1);
|
||||
var expander = new com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander();
|
||||
assertThat(expander.expandSearchTerm(instance.getTable("document"), "text", com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind.TEXT, null)).isNotNull();
|
||||
assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("DATE")).isEqualTo("valueDate");
|
||||
assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("DATE_TIME")).isEqualTo("valueDateTime");
|
||||
assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("BOOLEAN")).isEqualTo("valueBoolean");
|
||||
assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("STRING")).isEqualTo("valueText");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review and load execute the real lifecycle and retain failures in their summaries.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void streamedActionsAndScheduledJob() throws Exception
|
||||
{
|
||||
admin();
|
||||
var row = draft("action", "STRING");
|
||||
var process = instance.getProcess("collaborationPublishField");
|
||||
var input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName(process.getName()).withRecords(List.of(row));
|
||||
var output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
|
||||
var review = new FieldProcesses.Review();
|
||||
review.runOnePage(input, output);
|
||||
assertThat(output.getRecords()).hasSize(1);
|
||||
assertThat(review.getProcessSummary(output, false)).hasSize(1);
|
||||
var apply = new FieldProcesses.Apply();
|
||||
apply.runOnePage(input, output);
|
||||
assertThat(apply.getProcessSummary(output, true)).hasSize(1);
|
||||
review.runOnePage(input, output);
|
||||
apply.runOnePage(input, output);
|
||||
assertThat(review.getProcessSummary(output, false)).hasSize(2);
|
||||
input.withProcessName("collaborationArchiveField");
|
||||
review.runOnePage(input, output);
|
||||
FieldDefinitions.apply(row.getValue("id"), "Rebuild");
|
||||
new BuildFieldIndexes().run(input, output);
|
||||
assertThat(BuildFieldIndexes.metadata(config.withSchedulerName("scheduler")).getSchedule()).isNotNull();
|
||||
config.withSchedulerName(null);
|
||||
config.setManagedIndexes(true);
|
||||
assertThat(config.getManagedIndexes()).isTrue();
|
||||
var draft = draft("delete", "STRING");
|
||||
new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(draft.getValue("id"))));
|
||||
assertThatThrownBy(() -> FieldDefinitions.get(draft.getValue("id"))).hasMessageContaining("Not found");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query paths remain ordinary filters; callers retain their record locks.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> query(String field, QCriteriaOperator operator, java.io.Serializable value) throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput("document").withFilter(new QQueryFilter().withCriteria(field, operator, value))).getRecords());
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,8 @@ public abstract class PersonalServiceFixture
|
||||
@AfterEach
|
||||
public void tearDown()
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.close(instance);
|
||||
com.kingsrook.qqq.backend.core.realtime.RealtimeBus.close(instance);
|
||||
QContext.clear();
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.realtime.InMemoryRealtimeBus;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Two independent pods, a real in-memory bus, controlled time and real permissions.
|
||||
*******************************************************************************/
|
||||
class PresenceTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Renewals replicate even without a visible change; tabs merge until all expire.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void twoPodsMergeRenewExpireAndLeave() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
|
||||
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
|
||||
{
|
||||
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
|
||||
bus.subscribe(events::add);
|
||||
first.heartbeat("alice", "Alice", "one", "document", "1", "VIEWING", null);
|
||||
assertThat(second.snapshot("document", "1")).hasSize(1);
|
||||
clock.advance(1000);
|
||||
second.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title");
|
||||
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title"));
|
||||
assertThat(first.snapshot("document", null).getFirst().fieldName()).isNull();
|
||||
int changes = (int) events.stream().filter(event -> event.getEvent().equals("presence.changed")).count();
|
||||
clock.advance(20000);
|
||||
first.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title");
|
||||
assertThat(events.stream().filter(event -> event.getEvent().equals("presence.changed")).count()).isEqualTo(changes);
|
||||
clock.advance(25000);
|
||||
first.expire();
|
||||
second.expire();
|
||||
assertThat(first.snapshot("document", "1")).hasSize(1);
|
||||
assertThat(second.snapshot("document", "1")).hasSize(1);
|
||||
second.leave("alice", "two", "document", "1");
|
||||
assertThat(first.snapshot("document", "1")).isEmpty();
|
||||
first.heartbeat("bob", "Bob", "three", "document", null, "VIEWING", null);
|
||||
clock.advance(45000);
|
||||
assertThat(second.snapshot("document", null)).isEmpty();
|
||||
assertThat(first.counts()).isEmpty();
|
||||
assertThat(events).anySatisfy(event ->
|
||||
{
|
||||
assertThat(event.getEvent()).isEqualTo("presence.heartbeat");
|
||||
assertThat(event.getChannel()).isEqualTo("instance:presence");
|
||||
});
|
||||
assertThat(events.stream().filter(event -> event.getChannel().equals("table:document"))).allSatisfy(event -> assertThat(event.getData()).doesNotContainKey("pk"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** At capacity, existing leases renew while new targets are refused on both pods.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundedRegistryAndMalformedMessages() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
assertThat(PresenceRegistry.MAX_ENTRIES).isEqualTo(50000);
|
||||
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, 2); var second = new PresenceRegistry(bus, clock, 2))
|
||||
{
|
||||
assertThat(first.heartbeat("alice", "Alice", "one", "document", "1", "VIEWING", null)).isTrue();
|
||||
assertThat(first.heartbeat("bob", "Bob", "one", "document", "1", "VIEWING", null)).isTrue();
|
||||
assertThat(second.heartbeat("carol", "Carol", "one", "document", "1", "VIEWING", null)).isFalse();
|
||||
assertThat(first.heartbeat("alice", "Alice", "one", "document", "1", "EDITING", "title")).isTrue();
|
||||
assertThat(second.counts()).containsEntry("document", 2);
|
||||
bus.publish(new RealtimeMessage().withChannel(PresenceRegistry.INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(java.util.Map.of()));
|
||||
assertThat(second.snapshot("document", "1")).hasSize(2);
|
||||
clock.advance(45000);
|
||||
assertThat(first.heartbeat("carol", "Carol", "one", "document", "1", "VIEWING", null)).isTrue();
|
||||
assertThat(first.snapshot("document", "1")).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Denials are opaque, current sessions are checked, and nothing enters the directory.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void accessFeatureAndSnapshot() throws Exception
|
||||
{
|
||||
var service = new PresenceService();
|
||||
assertThat(service.heartbeat("one", "document", "01", "EDITING", "title")).hasSize(1);
|
||||
assertThat(service.snapshot("document", "1")).extracting(PresenceRegistry.Viewer::userId).containsExactly("alice");
|
||||
assertThat(service.snapshot("document", null)).hasSize(1);
|
||||
assertThatThrownBy(() -> service.heartbeat("one", "document", "3", "VIEWING", null)).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> service.snapshot("missing", null)).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> service.heartbeat("one", "document", "1", "BAD", null)).hasMessageContaining("Mode");
|
||||
assertThatThrownBy(() -> service.heartbeat("one", "document", "1", "VIEWING", "bad")).hasMessageContaining("field");
|
||||
assertThatThrownBy(() -> service.heartbeat("", "document", null, "VIEWING", null)).hasMessageContaining("connectionId");
|
||||
login("bob");
|
||||
service.leave("one", "document", "1");
|
||||
assertThat(service.snapshot("document", "1")).hasSize(1);
|
||||
login("alice");
|
||||
QContext.getQSession().getSecurityKeyValues().clear();
|
||||
assertThatThrownBy(() -> service.heartbeat("one", "document", "1", "VIEWING", null)).isInstanceOf(QNotFoundException.class);
|
||||
login("alice");
|
||||
assertThat(service.snapshot("document", "1")).isEmpty();
|
||||
service.heartbeat("table", "document", null, "VIEWING", null);
|
||||
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.PRESENCE, false);
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
assertThatThrownBy(() -> service.snapshot("document", null)).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> service.heartbeat("table", "document", null, "VIEWING", null)).isInstanceOf(QNotFoundException.class);
|
||||
admin();
|
||||
assertThat(CollaborationRecords.query(com.kingsrook.qbits.collaboration.model.CollaborationTables.USER, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty();
|
||||
assertThat(CollaborationHome.render(Instant.now())).contains("Who's here", "Custom fields", "Index NONE");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clock controls the TTL boundary without sleeps.
|
||||
*******************************************************************************/
|
||||
private static class MutableClock extends Clock
|
||||
{
|
||||
private Instant now = Instant.parse("2026-10-13T12:00:00Z");
|
||||
/*******************************************************************************
|
||||
** Move deterministic time forward.
|
||||
*******************************************************************************/
|
||||
void advance(long millis)
|
||||
{
|
||||
now = now.plusMillis(millis);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed UTC test zone.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ZoneId getZone()
|
||||
{
|
||||
return (ZoneId.of("UTC"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Zone does not affect elapsed time.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone)
|
||||
{
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Current test instant.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Instant instant()
|
||||
{
|
||||
return (now);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user