diff --git a/frontend/src/pages/entity-record-viewer.vue b/frontend/src/pages/entity-record-viewer.vue index ba86229..fc8f2c6 100644 --- a/frontend/src/pages/entity-record-viewer.vue +++ b/frontend/src/pages/entity-record-viewer.vue @@ -19,7 +19,6 @@ interface ChildRecords { entity: Entity relationshipField: Field - relationshipDirection?: 'INBOUND' | 'OUTBOUND' records: Record[] } interface EntityRecord { @@ -59,7 +58,7 @@ } function childKey (child: ChildRecords) { - return `${child.entity.id}-${child.relationshipField.id}-${child.relationshipDirection ?? 'INBOUND'}` + return `${child.entity.id}-${child.relationshipField.id}` } function editableChildFields (child: ChildRecords) { @@ -151,19 +150,17 @@ saving.value = true errorMessage.value = '' try { - const childUrl = new URL( + const response = await fetch( `${recordUrl.value}/children/${encodeURIComponent(child.entity.name)}/${child.relationshipField.id}`, - window.location.origin, - ) - childUrl.searchParams.set('direction', child.relationshipDirection ?? 'INBOUND') - const response = await fetch(childUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${auth.token}`, - 'Content-Type': 'application/x-www-form-urlencoded', + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${auth.token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formBody(fields, childValues[key]), }, - body: formBody(fields, childValues[key]), - }) + ) if (!response.ok) throw new Error('Child create failed') const created = await response.json() as EntityRecord child.records.push(created.values) diff --git a/frontend/tests/user-file.spec.ts b/frontend/tests/user-file.spec.ts index 0eeb42b..7903e94 100644 --- a/frontend/tests/user-file.spec.ts +++ b/frontend/tests/user-file.spec.ts @@ -32,6 +32,14 @@ test('admin opens a user file and views a linked entity record', async ({ page } targetEntityId: 6, relationshipType: 'ONE_TO_MANY', }, + { + id: 14, + name: 'Tasks', + identifier: 'tasks', + type: 'RELATIONSHIP', + targetEntityId: 4, + relationshipType: 'ONE_TO_MANY', + }, ], }, values: { id: 42, title: projectTitle, owner: 7 }, @@ -43,20 +51,22 @@ test('admin opens a user file and views a linked entity record', async ({ page } fields: [ { id: 20, name: 'Description', identifier: 'description', type: 'TEXT' }, { - id: 21, - name: 'Project', - identifier: 'project', + id: 22, + name: 'Notes', + identifier: 'notes', type: 'RELATIONSHIP', - targetEntityId: 3, + targetEntityId: 5, + relationshipType: 'ONE_TO_MANY', }, ], }, relationshipField: { - id: 21, - name: 'Project', - identifier: 'project', + id: 14, + name: 'Tasks', + identifier: 'tasks', type: 'RELATIONSHIP', - targetEntityId: 3, + targetEntityId: 4, + relationshipType: 'ONE_TO_MANY', }, records: tasks, }, { @@ -74,7 +84,6 @@ test('admin opens a user file and views a linked entity record', async ({ page } targetEntityId: 6, relationshipType: 'ONE_TO_MANY', }, - relationshipDirection: 'OUTBOUND', records: milestones, }], }) @@ -117,9 +126,9 @@ test('admin opens a user file and views a linked entity record', async ({ page } } await route.fulfill({ json: projectResponse() }) }) - await page.route('**/api/users/7/entities/Project/records/42/children/Task/21*', async route => { + await page.route('**/api/users/7/entities/Project/records/42/children/Task/14', async route => { const values = new URLSearchParams(route.request().postData() ?? '') - const created = { id: 55, description: values.get('description'), project: 42 } + const created = { id: 55, description: values.get('description'), notes: null } tasks.push(created) await route.fulfill({ status: 201, @@ -130,8 +139,7 @@ test('admin opens a user file and views a linked entity record', async ({ page } }, }) }) - await page.route('**/api/users/7/entities/Project/records/42/children/Milestone/13*', async route => { - expect(new URL(route.request().url()).searchParams.get('direction')).toBe('OUTBOUND') + await page.route('**/api/users/7/entities/Project/records/42/children/Milestone/13', async route => { const values = new URLSearchParams(route.request().postData() ?? '') const created = { id: 60, label: values.get('label') } milestones.push(created) @@ -151,23 +159,23 @@ test('admin opens a user file and views a linked entity record', async ({ page } identifier: 'note', fields: [ { id: 30, name: 'Contents', identifier: 'contents', type: 'TEXT' }, - { id: 31, name: 'Task', identifier: 'task', type: 'RELATIONSHIP', targetEntityId: 4 }, ], }, relationshipField: { - id: 31, - name: 'Task', - identifier: 'task', + id: 22, + name: 'Notes', + identifier: 'notes', type: 'RELATIONSHIP', - targetEntityId: 4, + targetEntityId: 5, + relationshipType: 'ONE_TO_MANY', }, records: notes, }], }, })) - await page.route('**/api/users/7/entities/Task/records/55/children/Note/31*', async route => { + await page.route('**/api/users/7/entities/Task/records/55/children/Note/22', async route => { const values = new URLSearchParams(route.request().postData() ?? '') - const created = { id: 56, contents: values.get('contents'), task: 55 } + const created = { id: 56, contents: values.get('contents') } notes.push(created) await route.fulfill({ status: 201, @@ -210,7 +218,7 @@ test('admin opens a user file and views a linked entity record', async ({ page } const childForm = page.getByRole('form', { name: 'Add Task' }) await childForm.getByLabel('Description').fill('Prepare launch') await childForm.getByRole('button', { name: 'Add Task' }).click() - await expect(page.getByRole('row').filter({ hasText: 'Prepare launch' })).toContainText('42') + await expect(page.getByRole('row').filter({ hasText: 'Prepare launch' })).toBeVisible() await page.getByRole('link', { name: 'View Task record 55' }).click() await expect(page.getByRole('heading', { name: 'Task #55' })).toBeVisible() @@ -218,5 +226,5 @@ test('admin opens a user file and views a linked entity record', async ({ page } const noteForm = page.getByRole('form', { name: 'Add Note' }) await noteForm.getByLabel('Contents').fill('Nested child') await noteForm.getByRole('button', { name: 'Add Note' }).click() - await expect(page.getByRole('row').filter({ hasText: 'Nested child' })).toContainText('55') + await expect(page.getByRole('row').filter({ hasText: 'Nested child' })).toBeVisible() }) diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt index af08607..5b22e7b 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt @@ -22,11 +22,8 @@ data class ChildEntityRecords( val entity: EntityDefinition, val relationshipField: EntityField, val records: List>, - val relationshipDirection: RelationshipDirection = RelationshipDirection.INBOUND, ) -enum class RelationshipDirection { INBOUND, OUTBOUND } - class EntityRecordRepository( private val database: Database, private val entities: EntityDefinitionRepository, @@ -112,77 +109,45 @@ class EntityRecordRepository( parentRecordId: Long, childEntityName: String, relationshipFieldId: Long, - relationshipDirection: RelationshipDirection, submittedValues: Map, ): EntityRecord? { val definitions = entities.findAll() val parent = definitions.firstOrNull { it.name == parentEntityName } ?: return null if (findRecordLinkedToUser(userId, parentEntityName, parentRecordId) == null) return null val child = definitions.firstOrNull { it.name == childEntityName } ?: return null - if (relationshipDirection == RelationshipDirection.OUTBOUND) { - val relationship = parent.fields.firstOrNull { - it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == child.id - } ?: return null - val storedTargetId = findValues(parent, parentRecordId)?.get(relationship.identifier) as? Number - if ( - relationship.relationshipType == RelationshipType.ONE_TO_ONE && - (storedTargetId != null || outboundChildIds(relationship.id, parentRecordId).isNotEmpty()) - ) { - throw IllegalArgumentException("This relationship already has a child") - } - val childId = insertRecord(child, userId, submittedValues) - database.executeUpdate( - "INSERT INTO entity_record_relationships (field_id, source_record_id, target_record_id) VALUES (?, ?, ?)", - ) { - setLong(1, relationship.id) - setLong(2, parentRecordId) - setLong(3, childId) - } - if (relationship.relationshipType == RelationshipType.ONE_TO_ONE) { - database.executeUpdate( - "UPDATE ${quote(parent.identifier)} SET ${quote(relationship.identifier)} = ? WHERE \"id\" = ?", - ) { - setLong(1, childId) - setLong(2, parentRecordId) - } - } - return findChildRecord(child, childId) - } - val parentField = child.fields.firstOrNull { - it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == parent.id + val relationship = parent.fields.firstOrNull { + it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == child.id } ?: return null - val fields = child.fields - val sql = "INSERT INTO ${quote(child.identifier)} (${fields.joinToString { quote(it.identifier) }}) " + - "VALUES (${fields.joinToString { "?" }}) RETURNING \"id\"" - val childId = checkNotNull(database.queryOne(sql, bind = { - fields.forEachIndexed { index, field -> - when { - field.id == parentField.id -> setLong(index + 1, parentRecordId) - field.type == FieldType.USER -> setLong(index + 1, userId) - else -> bindField(index + 1, field, submittedValues[field.identifier].orEmpty()) - } + val storedTargetId = findValues(parent, parentRecordId)?.get(relationship.identifier) as? Number + if ( + relationship.relationshipType == RelationshipType.ONE_TO_ONE && + (storedTargetId != null || outboundChildIds(relationship.id, parentRecordId).isNotEmpty()) + ) { + throw IllegalArgumentException("This relationship already has a child") + } + val childId = insertRecord(child, userId, submittedValues) + database.executeUpdate( + "INSERT INTO entity_record_relationships (field_id, source_record_id, target_record_id) VALUES (?, ?, ?)", + ) { + setLong(1, relationship.id) + setLong(2, parentRecordId) + setLong(3, childId) + } + if (relationship.relationshipType == RelationshipType.ONE_TO_ONE) { + database.executeUpdate( + "UPDATE ${quote(parent.identifier)} SET ${quote(relationship.identifier)} = ? WHERE \"id\" = ?", + ) { + setLong(1, childId) + setLong(2, parentRecordId) } - }) { getLong("id") }) + } return findChildRecord(child, childId) } private fun findChildren(parentEntityId: Long, parentRecordId: Long): List { val definitions = entities.findAll() - val inbound = definitions.flatMap { child -> - child.fields - .filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId == parentEntityId } - .map { relationship -> - val columns = listOf("id") + child.fields.map(EntityField::identifier) - val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(child.identifier)} " + - "WHERE ${quote(relationship.identifier)} = ? ORDER BY \"id\"" - val records = database.queryList(sql, bind = { setLong(1, parentRecordId) }) { - toRecord(columns) - } - ChildEntityRecords(child, relationship, records) - } - } - val parent = definitions.firstOrNull { it.id == parentEntityId } ?: return inbound - val outbound = parent.fields + val parent = definitions.firstOrNull { it.id == parentEntityId } ?: return emptyList() + return parent.fields .filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId != null } .mapNotNull { relationship -> val child = definitions.firstOrNull { it.id == relationship.targetEntityId } ?: return@mapNotNull null @@ -190,9 +155,8 @@ class EntityRecordRepository( ?.let { it as? Number }?.toLong() val childIds = (outboundChildIds(relationship.id, parentRecordId) + listOfNotNull(storedTargetId)).distinct() val records = childIds.mapNotNull { findValues(child, it) } - ChildEntityRecords(child, relationship, records, RelationshipDirection.OUTBOUND) + ChildEntityRecords(child, relationship, records) } - return inbound + outbound } private fun outboundChildIds(fieldId: Long, sourceRecordId: Long): List = diff --git a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt index b707adb..190700d 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt @@ -1,7 +1,6 @@ package dev.mduchene.bolts.web import dev.mduchene.bolts.entity.EntityRecordRepository -import dev.mduchene.bolts.entity.RelationshipDirection import dev.mduchene.bolts.user.LoginService import dev.mduchene.bolts.user.UserRepository import io.javalin.router.JavalinDefaultRoutingApi @@ -115,9 +114,6 @@ class UserController( recordId, childEntityName, fieldId, - ctx.queryParam("direction")?.let { - runCatching { RelationshipDirection.valueOf(it) }.getOrNull() - } ?: RelationshipDirection.INBOUND, ctx.singleFormParams(), ) if (child == null) ctx.notFound() else ctx.status(201).json(child)