Merge branch 'main' into releases/4.7.0

This commit is contained in:
Alexandre Bodin 2023-02-27 13:46:19 +01:00
commit 461f2862dd
19 changed files with 412 additions and 2 deletions

View File

@ -0,0 +1,17 @@
---
title: Introduction
slug: /database
tags:
- database
---
# Database
This section is an overview of all the features related to the Database package:
```mdx-code-block
import DocCardList from '@theme/DocCardList';
import { useCurrentSidebarCategory } from '@docusaurus/theme-common';
<DocCardList items={useCurrentSidebarCategory().items} />
```

View File

@ -0,0 +1,119 @@
---
title: Relations
slug: /database/relations/reordering
description: Conceptual guide to relations reordering in the Database
tags:
- database
- relations
- reordering
---
Strapi allows you to reorder a relation list.
<img src="/img/database/reordering.png" alt="An example of reordering in the CM" />
This reordering feature is available in the Content Manager and the API.
## Code location
`packages/core/database/lib/entity-manager/relations-orderer.js`
## How is the order stored in DB?
- We store the order value of the relation in an `order` field.
- For bidirectional relations, we store the order value of the other side in an `inverse_order` field.
We store order values for all type of relations, except for:
- Polymorphic relations (too complicated to implement).
- One to one relations (as there is only one relation per pair)
### Many to many (Addresses <-> Categories)
<img src="/img/database/m2m-example.png" alt="many to many relation" />
- `category_order` is the order value of the categories relations in an address entity.
- `address_order` is the order value of the addresses relations in a category entity.
### One to one (Kitchensinks <-> Tags)
<img src="/img/database/o2o-example.png" alt="one to one relation" />
- there is no `order` fields as there is only one relation per pair.
### One way relation (Restaurants <-> Categories)
Where a restaurant has many categories:
<img src="/img/database/mw-example.png" alt="many way relation" />
- `category_order` is the order value of the categories relations in a restaurant entity.
- There is no `restaurant_order` as it is a one way relation.
## How to reorder relations in the DB layer
See more on [Strapi Docs](https://docs.strapi.io/dev-docs/api/rest/relations#connect)
The database layer should receive a payload shown below:
```js
category: {
connect: [
{ id: 6, position: { after: 1} }, // It should be after relation id=1
{ id: 8, position: { end: true }}, // It should be at the end
],
disconnect: [
{ id: 4 }
]
}
```
## How does relations reordering work?
We use fractional indexing. This means that we use decimal numbers to order the relations. See the following diagrams below for a more detailed understanding.
### Simple example
<img src="/img/database/reordering-algo-1.png" alt="An example of reordering in the CM" />
### Complex example
<img src="/img/database/reordering-algo-2.png" alt="An example of reordering in the CM" />
### Algorithm steps
From the `connect` array:
- For every element, **load relations by id**, **from fields `after` or `before`**.
- Start computing based on the `after` and `before` relations:
- **Initialize** with after/before relations (**step 1**). Let's call these ones **init relations.**
- **Apply the updates** from the `connect` array, **sequentially**.
- If the update is of type `before`:
- Place the element with the given `id` **before** the specified element in the list.
- If the specified element is an `init relation`, place the element in between that relation and the one before it.
- To determine the order value, **order = beforeRelation.order - 0.5**. This ensures the element is placed before the `before` relation and after the one before it.
- Else **order = beforeRelation.order**
- If the update is of type `after`:
- Place the element with the given `id` **after** the specified element in the list.
- If the specified element is an `init relation`, place the element in between that relation and the one after it.
- To determine the order value, **order = beforeRelation.order + 0.5**. This ensures the element is placed before the `after` relation and before the one after it.
- Else **order = beforeRelation.order**
- If the update is of type `end`:
- Place at the **end**
- If placing after an init relation: **order = lastRelation.order + 0.5**
- Else **order = lastRelation.order**
- If the update is of type `start`:
-Place at the **start**
- **order = 0.5**
- `before/after`: If the **id does not exist in the current array**, **throw an error**
- If an **id** was **already in this array, remove the previous one**
- **Grouping by the order value**, and ignoring init relations
- Recalculate order values for each group, so there are no repeated numbers & they keep the same order.
- Example : [ {id: 5 , order: 1.5}, {id: 3, order: 1.5 } ] → [ {id: 5 , order: 1.33}, {id: 3, order: 1.66 } ]
- **Insert values in the database**
- **Update database order based on their order position.** (using ROW_NUMBER() clause)
From the disconnect array:
- Delete the relations from the database.
- Reorder the remaining elements in the database based on their position, using ROW_NUMBER() clause.

BIN
docs/static/img/database/m2m-example.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
docs/static/img/database/mw-example.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
docs/static/img/database/o2o-example.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
docs/static/img/database/reordering.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@ -0,0 +1,83 @@
import React from 'react';
import styled from 'styled-components';
import { useIntl } from 'react-intl';
import { useTracking, pxToRem } from '@strapi/helper-plugin';
import { Box, Flex, Typography } from '@strapi/design-system';
import cloudIconBackgroundImage from './assets/strapi-cloud-background.png';
import cloudIcon from './assets/strapi-cloud-icon.svg';
import cloudFlagsImage from './assets/strapi-cloud-flags.svg';
const BlockLink = styled.a`
text-decoration: none;
`;
const CloudCustomWrapper = styled(Box)`
background-image: url(${({ backgroundImage }) => backgroundImage});
`;
const CloudIconWrapper = styled(Flex)`
background: rgba(255, 255, 255, 0.3);
`;
const CloudBox = () => {
const { formatMessage } = useIntl();
const { trackUsage } = useTracking();
return (
<BlockLink
href="https://cloud.strapi.io"
target="_blank"
rel="noopener noreferrer nofollow"
onClick={() => {
trackUsage('didClickOnTryStrapiCloudSection');
}}
>
<Flex
shadow="tableShadow"
hasRadius
padding={6}
background="neutral0"
position="relative"
gap={6}
>
<CloudCustomWrapper backgroundImage={cloudIconBackgroundImage} hasRadius padding={3}>
<CloudIconWrapper
width={pxToRem(32)}
height={pxToRem(32)}
justifyContent="center"
hasRadius
alignItems="center"
>
<img
src={cloudIcon}
alt={formatMessage({
id: 'app.components.BlockLink.cloud',
defaultMessage: 'Strapi Cloud',
})}
/>
</CloudIconWrapper>
</CloudCustomWrapper>
<Flex gap={1} direction="column" alignItems="start">
<Flex>
<Typography fontWeight="semiBold" variant="pi">
{formatMessage({
id: 'app.components.BlockLink.cloud',
defaultMessage: 'Strapi Cloud',
})}
</Typography>
</Flex>
<Typography textColor="neutral600">
{formatMessage({
id: 'app.components.BlockLink.cloud.content',
defaultMessage:
'A fully composable, and collaborative platform to boost your team velocity.',
})}
</Typography>
<Box src={cloudFlagsImage} position="absolute" top={0} right={0} as="img" />
</Flex>
</Flex>
</BlockLink>
);
};
export default CloudBox;

View File

@ -4,6 +4,7 @@ import { useIntl } from 'react-intl';
import { ContentBox, useTracking } from '@strapi/helper-plugin';
import { Stack } from '@strapi/design-system';
import { InformationSquare, CodeSquare, PlaySquare, FeatherSquare } from '@strapi/icons';
import CloudBox from './CloudBox';
const BlockLink = styled.a`
text-decoration: none;
@ -19,6 +20,7 @@ const ContentBlocks = () => {
return (
<Stack spacing={5}>
<CloudBox />
<BlockLink
href="https://strapi.io/resource-center"
target="_blank"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="15" fill="none"><path fill="#fff" fill-rule="evenodd" d="M4.39453 13.8298C1.93859 13.6455 0 11.468 0 8.80884 0 6.0289 2.11876 3.7753 4.73238 3.7753c.46775 0 .91964.07218 1.34638.20664C7.21234 1.62909 9.66469 0 12.5073 0c2.5102 0 4.7161 1.27036 5.9782 3.18766a4.54297 4.54297 0 0 1 .6132-.04144C21.8056 3.14622 24 5.54066 24 8.49436c0 2.89194-2.1036 5.24784-4.7323 5.34504v.0031l-1.8948.278a38.18054 38.18054 0 0 1-11.08354 0l-1.89483-.278v-.0127Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@ -65,6 +65,7 @@ describe('Homepage', () => {
});
test.each([
'strapi cloud a fully composable, and collaborative platform to boost your team velocity.',
'documentation discover the essential concepts, guides and instructions.',
'code example learn by using ready-made starters for your projects.',
'tutorials follow step-by-step instructions to use and customize strapi.',

View File

@ -353,6 +353,8 @@
"app.components.BlockLink.documentation.content": "Discover the essential concepts, guides and instructions.",
"app.components.BlockLink.tutorial": "Tutorials",
"app.components.BlockLink.tutorial.content": "Follow step-by-step instructions to use and customize Strapi.",
"app.components.BlockLink.cloud": "Strapi Cloud",
"app.components.BlockLink.cloud.content": "A fully composable, and collaborative platform to boost your team velocity.",
"app.components.Button.cancel": "Cancel",
"app.components.Button.confirm": "Confirm",
"app.components.Button.reset": "Reset",

View File

@ -165,4 +165,4 @@
}
}
}
}
}

View File

@ -5,6 +5,7 @@ import { Flex, Stack, Typography } from '@strapi/design-system';
const IconWrapper = styled(Flex)`
margin-right: ${({ theme }) => theme.spaces[6]};
svg {
width: ${32 / 16}rem;
height: ${32 / 16}rem;

View File

@ -159,11 +159,27 @@ const createScalarAttributeValidator = (createOrUpdate) => (metas, options) => {
return validator;
};
const createMediaAttributeValidator = (createOrUpdate) => (metas, options) => {
let validator;
if (metas.attr.multiple) {
validator = yup.array().of(yup.mixed()).min(1);
} else {
validator = yup.mixed();
}
validator = addRequiredValidation(createOrUpdate)(validator, {
attr: { required: !options.isDraft && metas.attr.required },
});
return validator;
};
const createAttributeValidator = (createOrUpdate) => (metas, options) => {
let validator;
if (isMediaAttribute(metas.attr)) {
validator = yup.mixed();
validator = createMediaAttributeValidator(createOrUpdate)(metas, options);
} else if (isScalarAttribute(metas.attr)) {
validator = createScalarAttributeValidator(createOrUpdate)(metas, options);
} else {

View File

@ -0,0 +1,167 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { createStrapiInstance } = require('../../../../../test/helpers/strapi');
const { createTestBuilder } = require('../../../../../test/helpers/builder');
const { createContentAPIRequest } = require('../../../../../test/helpers/request');
const builder = createTestBuilder();
let strapi;
let rq;
const productWithMedia = {
attributes: {
name: {
type: 'string',
},
description: {
type: 'text',
},
media: {
type: 'media',
multiple: false,
required: true,
allowedTypes: ['images', 'files', 'videos', 'audios'],
},
multipleMedia: {
type: 'media',
multiple: true,
required: true,
allowedTypes: ['images', 'files', 'videos', 'audios'],
},
},
displayName: 'product-with-media',
singularName: 'product-with-media',
pluralName: 'product-with-medias',
description: '',
collectionName: '',
};
describe('Core API - Basic + required media', () => {
let file;
beforeAll(async () => {
await builder.addContentType(productWithMedia).build();
strapi = await createStrapiInstance();
rq = await createContentAPIRequest({ strapi });
// Create file
const result = await rq({
method: 'POST',
url: '/upload',
formData: {
files: fs.createReadStream(path.join(__dirname, 'basic-media.test.api.js')),
},
});
file = result.body[0];
});
afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});
test('Create entry without required multiple media', async () => {
const product = {
name: 'product',
description: 'description',
media: file.id,
};
const res = await rq({
method: 'POST',
url: '/product-with-medias',
body: { data: product },
qs: { populate: true },
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
data: null,
error: {
status: 400,
name: 'ValidationError',
message: 'multipleMedia must be defined.',
details: {
errors: [
{
path: ['multipleMedia'],
message: 'multipleMedia must be defined.',
name: 'ValidationError',
},
],
},
},
});
});
test('Create entry with required multiple media as an empty array', async () => {
const product = {
name: 'product',
description: 'description',
multipleMedia: [],
media: file.id,
};
const res = await rq({
method: 'POST',
url: '/product-with-medias',
body: { data: product },
qs: { populate: true },
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
data: null,
error: {
status: 400,
name: 'ValidationError',
message: 'multipleMedia field must have at least 1 items',
details: {
errors: [
{
path: ['multipleMedia'],
message: 'multipleMedia field must have at least 1 items',
name: 'ValidationError',
},
],
},
},
});
});
test('Create entry without required single media', async () => {
const product = {
name: 'product',
description: 'description',
multipleMedia: [{ id: file.id }],
};
const res = await rq({
method: 'POST',
url: '/product-with-medias',
body: { data: product },
qs: { populate: true },
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
data: null,
error: {
status: 400,
name: 'ValidationError',
message: 'media must be defined.',
details: {
errors: [
{
path: ['media'],
message: 'media must be defined.',
name: 'ValidationError',
},
],
},
},
});
});
});