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 { ContentBox, useTracking } from '@strapi/helper-plugin';
import { Stack } from '@strapi/design-system'; import { Stack } from '@strapi/design-system';
import { InformationSquare, CodeSquare, PlaySquare, FeatherSquare } from '@strapi/icons'; import { InformationSquare, CodeSquare, PlaySquare, FeatherSquare } from '@strapi/icons';
import CloudBox from './CloudBox';
const BlockLink = styled.a` const BlockLink = styled.a`
text-decoration: none; text-decoration: none;
@ -19,6 +20,7 @@ const ContentBlocks = () => {
return ( return (
<Stack spacing={5}> <Stack spacing={5}>
<CloudBox />
<BlockLink <BlockLink
href="https://strapi.io/resource-center" href="https://strapi.io/resource-center"
target="_blank" 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([ test.each([
'strapi cloud a fully composable, and collaborative platform to boost your team velocity.',
'documentation discover the essential concepts, guides and instructions.', 'documentation discover the essential concepts, guides and instructions.',
'code example learn by using ready-made starters for your projects.', 'code example learn by using ready-made starters for your projects.',
'tutorials follow step-by-step instructions to use and customize strapi.', '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.documentation.content": "Discover the essential concepts, guides and instructions.",
"app.components.BlockLink.tutorial": "Tutorials", "app.components.BlockLink.tutorial": "Tutorials",
"app.components.BlockLink.tutorial.content": "Follow step-by-step instructions to use and customize Strapi.", "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.cancel": "Cancel",
"app.components.Button.confirm": "Confirm", "app.components.Button.confirm": "Confirm",
"app.components.Button.reset": "Reset", "app.components.Button.reset": "Reset",

View File

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

View File

@ -159,11 +159,27 @@ const createScalarAttributeValidator = (createOrUpdate) => (metas, options) => {
return validator; 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) => { const createAttributeValidator = (createOrUpdate) => (metas, options) => {
let validator; let validator;
if (isMediaAttribute(metas.attr)) { if (isMediaAttribute(metas.attr)) {
validator = yup.mixed(); validator = createMediaAttributeValidator(createOrUpdate)(metas, options);
} else if (isScalarAttribute(metas.attr)) { } else if (isScalarAttribute(metas.attr)) {
validator = createScalarAttributeValidator(createOrUpdate)(metas, options); validator = createScalarAttributeValidator(createOrUpdate)(metas, options);
} else { } 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',
},
],
},
},
});
});
});