Introduction

In most cases the mapping extension is used to populate tables that were added to Shopware by plugins. Therefore, this guide explains how to configure the mapping-extension and your documents using a fictitious plugin. The plugin used in this example intends to provide examples for references and translations and does not try to be as realistic as possible. In order to understand this guide it is highly recommended to read all documentations about the general concepts of the mapping-extension beforehand.

Inspecting the Plugin

The first step is always to analyze the tables added by the plugin. The plugin adds the possibility to customers to make a request for products, for example because they are not available at the moment. This is implemented by adding the following tables:

Shopware Table UML

  • The table request represents a customer request, so it has a reference to the customer table from Shopware.
  • A single request can affect several products, so there is a many-to-many reference to the product table from Shopware (implemented by the junction table product_request added by the plugin). You can also describe this as "request positions".
  • A request can change its state (e.g. open, in progress, closed), so it has a reference to the request_state table added by the plugin as well.
  • The name of a request state is translatable, so it is stored in a translation table called request_state_translation

It is a naming convention to add a prefix to our table names (e.g. s_request instead of request). That's why you will find both names in this guide.

In the following you can find a simplified representation of the migration to create the tables:

CREATE TABLE `s_request_state`
(
    `id`         BINARY(16) NOT NULL,
    `code`       VARCHAR(2) NOT NULL,
    `created_at` DATETIME(3) NOT NULL,
    `updated_at` DATETIME(3) NULL,
    PRIMARY KEY (`id`)
);

CREATE TABLE `s_request_state_translation`
(
    `s_request_state_id` BINARY(16) NOT NULL,
    `language_id`        BINARY(16) NOT NULL,
    `name`               VARCHAR(255) NOT NULL,
    `created_at`         DATETIME(3) NOT NULL,
    `updated_at`         DATETIME(3) NULL,
    PRIMARY KEY (`s_request_state_id`, `language_id`),
    CONSTRAINT `...` FOREIGN KEY (`language_id`) REFERENCES `language` (`id`) ON ...,
    CONSTRAINT `...` FOREIGN KEY (`s_request_state_id`) REFERENCES `s_request_state` (`id`) ON ...
);

CREATE TABLE `s_request`
(
    `id`                 BINARY(16) NOT NULL,
    `s_request_state_id` BINARY(16) NOT NULL,
    `customer_id`        BINARY(16) NOT NULL,
    `language_id`        BINARY(16) NOT NULL,
    `comment`            varchar(255) NULL,
    `created_at`         DATETIME(3) NOT NULL,
    `updated_at`         DATETIME(3) NULL,
    PRIMARY KEY (`id`),
    CONSTRAINT `...` FOREIGN KEY (`s_request_state_id`) REFERENCES `s_request_state` (`id`) ON ...,
    CONSTRAINT `...` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`id`) ON ...,
    CONSTRAINT `...` FOREIGN KEY (`language_id`) REFERENCES `language` (`id`) ON ...
);

CREATE TABLE `s_request_product`
(
    `product_id`   BINARY(16) NOT NULL,
    `s_request_id` BINARY(16) NOT NULL,
    PRIMARY KEY (`product_id`, `s_request_id`),
    CONSTRAINT `...` FOREIGN KEY (`product_id`) REFERENCES `product` (`id`) ON ...,
    CONSTRAINT `...` FOREIGN KEY (`s_request_id`) REFERENCES `s_request` (`id`) ON ...
);

Create Documents and Mapping Configurations

The next steps are to derive corresponding documents for your tables and to generate an object-mapping configuration for that document. We will perform those steps for all Shopware tables the plugin implements.

Request State

We start with the request_state and request_state_translation tables. This shows you how simple it actually is to map translations with the mapping-extension. All you have to do is to add the field name with type TranslationCollection to your document and set its isTranslated configuration to true.

All documents need a valid and unique identifier since the mapping extension calculates the uuids to use in Shopware from the identifiers. That's why Shopware field code is used as identifier.

The following figure shows you the document RequestState.

/**  
 * Document for the tables 'request_state' and 'request_state_translations'. 
 * 
 * @MongoDB\Document  
 */  
class RequestState extends BaseDocument  
{  
    use UniqueIdentifiableDocument;  
  
    /**  
	 * Example for automatic translations mapping * * @var TranslationCollection  
	 * @MongoDB\EmbedMany(  
	 *     collectionClass=TranslationCollection::class, 
	 *     targetDocument=Translation::class, 
	 *     strategy="set"
	 * )
	 */
     private TranslationCollection $name;  

	// constructor, getters, setters, ...
}

Afterwards you have to create the object-mapping configuration. The object-mapping configuration tells the mapping-extension how to populate a Shopware table with the data of your documents.

As mentioned before there is no separate configuration for the translations table. So you only have to add two fields to the configuration in this case: code and name.

The field code is a string field and is configured as follows:

{
    "targetFieldName": "code",
    "sourcePath": "identifier",
    "stringFieldConfig": {
        "maxLength": 2,
        "allowsHtml": false,
        "isTranslated": false,
        "allowsNullValues": false
    }
}
  • Since code is a varchar column we choose a field configuration of type stringFieldConfig
  • The column only allows strings of length 2, so we set the maxLength value accordingly
  • Please note that the config values for allowsHtml, isTranslated and allowsNullValues are equal to their default values and could be omitted in this case

The field name is a translated field. All you have to do is to set isTranslated to true. So the configuration looks like this:

{
    "targetFieldName": "name",
    "sourcePath": "name",
    "stringFieldConfig": {
        "maxLength": 255,
        "isTranslated": true
    }
}

If you combine those configurations you get the following result for the object-mapping configuration for our RequestState document:

{
    "mappings": [
        {
            "targetTableName": "s_request_state",
            "sourceDocumentFqcn": "Synqup\\...\\RequestState",
            "fields": [
                {
                    "targetFieldName": "code",
                    "sourcePath": "identifier",
                    "stringFieldConfig": {
                        "maxLength": 2,
                        "allowsHtml": false,
                        "isTranslated": false,
                        "allowsNullValues": false
                    }
                },
                {
                    "targetFieldName": "name",
                    "sourcePath": "name",
                    "stringFieldConfig": {
                        "maxLength": 255,
                        "allowsHtml": false,
                        "isTranslated": true
                    }
                }
            ]
        }
    ]
}

Please note that you have to configure a valid locale configuration in order to map translations successfully.

Request

The next table we want to map is request. So we implement the corresponding document Request:

/**  
 * Document for the table 'request'. 
 * 
 * @MongoDB\Document  
 */  
class Request extends BaseDocument  
{  
    use UniqueIdentifiableDocument;  
  
    /**  
     * Example for an external reference with documents. References a customer (CustomerEntity) in Shopware. 
     * 
     * @var Customer  
     * @MongoDB\ReferenceOne(targetDocument=Customer::class, cascade="persist")  
     */
     private Customer $customer;  
  
    /**  
     * Example for an internal reference.  
     * 
     * @var RequestState  
     * @MongoDB\ReferenceOne(targetDocument=RequestState::class, cascade="persist")  
     */
     private RequestState $state;  
  
    /**  
     * Requested products
     * 
     * @var Collection<Product>  
     * @MongoDB\ReferenceMany(targetDocument=Product::class)  
     */    
     protected Collection $products;  
  
    /**  
     * @var string  
     * @MongoDB\Field(type="string")  
     */
     private string $comment;  
  
    /**  
     * Example for an external reference without documents. References a language (LanguageEntity) in Shopware.
     * 
     * @var string  
     * @MongoDB\Field(type="string")  
     */    
     private string $language;  
  
    // construct, getters, setters, ...  
}

This document contains three foreign key relations:

  • Internal reference to the document RequestState
  • External reference to the document Customer
  • External reference to the Shopware entity Language

As you can see there are actually two types of external references. You can either reference a document from your database or just use simple values like a string to generate a reference. Please refer to the documentation about general concepts for more information.

Internal Reference to States

It is pretty straight forward to generate a reference to a document that is mapped by the mapping-extension as well. The mapping extension detects internal foreign keys automatically. All you have to do is the following:

  • set a value into references.table that is equal to a targetTableName of another (the referenced) object-mapping configuration
  • set the sourcePath of the field to the referenced document (do not reference its identifier or id). This leads to the following configuration:
{
    "targetFieldName": "s_request_state_id",
    "sourcePath": "state",
    "foreignKeyFieldConfig": {
        "references": {
            "table": "s_request_state"
        },
        "allowsNullValues": false
    }
}

External Reference to Customer

External references a more complicated to set up than internal references. As mentioned before there are two possible ways to set up an external reference (document or single value). They are not that different to configure though. The reference to customers is an example for a reference via document.

The concept of external references is easiest to understand with an example. Let's assume the document Request references a Customer whose identifier is 12345. You would implement this reference as follows:

$request = new Request(...);  
$customerRepository = $this->documentManager->getRepository(Customer::class);  
$customer = $customerRepository->findOneBy(['identifier' => '12345']);  
$request->setCustomer($customer);

We make a small excursion into the internals of the mapping extension before generating the configuration of this field. After setting up the reference in our document we want the mapping extension to reference the corresponding customer in Shopware. In this case this is done by the customer number. In other words: You want the mapping extension to generate a foreign key reference to the Shopware customer whose customer number is equal to 12345. For this the mapping-extension has to write the id of the referenced customer into our customer_id column. This requirement as a mysql query looks like this:

  • In General: SELECT {{references.field}} FROM {{references.table}} WHERE {{filterSearchField}} = {{value @ sourcePath}}.
  • With values: SELECT id FROM customer WHERE customer_number = 12345

Of course the mapping-extension uses the API to find the corresponding customer. But the query illustrates very well what which value actually does in the configuration. The same example would look like this as api-request:

In General {{HOST}}/api/search/{{references.table}}/

{  
  "filter": [  
	 {      
	     "type": "equals",  
	     "field": "{{filterSearchField}}",  
	     "value": "{{value @ sourcePath}}"  
     }
   ]
}  

With values: {{HOST}}/api/search/customer/

{  
  "filter": [  
	 {      
	     "type": "equals",  
	     "field": "customerNumber",  
	     "value": "12345"  
     }
   ]
}  

This request returns the matching customer entity. Its UUID is written to the customer_id field.

Now we are able to generate the configuration for this foreign key field:

{
    "targetFieldName": "customer_id",
    "sourcePath": "customer.identifier",
    "foreignKeyFieldConfig": {
        "references": {
            "table": "customer"
        },
        "allowsNullValues": false,
        "filterSearchField": "customerNumber"
    }
}

So what the mapping extension will do in order to generate a reference to a customer from this configuration:

  • sourcePath: Read the value from "customer.identifier" (12345)
  • references.table: Generate a search request to the table customer (.../search/customer)
  • filterSearchField: Add an equals filter to the search request for the field "customerNumber" with the value from sourcePath (12345)
  • allowsNullValues: If no customer was found the document will be sorted out by validation.

References: On-Demand vs Bulk

Please refer to the general documentation for an in-depth explanation of the different reference modes.

This example is also able to explain the difference between the two reference loading modes the mapping extension provides (on-demand and bulk).

The on-demand mode is exactly what was explained in the section before: Read a single corresponding Shopware entity when needed by the help of the request shown above.

The difference in the bulk mode is that entities are not searched when needed but all at once. This would lead to a request like this: {{HOST}}/api/search/customer/

{  
  "filter": [  
	 {      
	     "type": "equalsAny",  
	     "field": "customerNumber",  
	     "value": ["12345", "112233", "...", "..."]  
     }
   ]
}  

To be able to search several referenced entities at once the module needs to extract those referenced beforehand. This is done in the subsection relation-extract. The entities are searched in the subsection relation-cache. Those subsections are only added to the subsection tree of an object-mapping with the bulk mode is present.

External Reference to Language

This is an example for an external reference via single value. Let's assume our Request document references a language whose locale is equal to en-GB:

$request = new Request(...);  
$request->setLanguage('en-GB');

We want to reference the language in Shopware whose locale code is equal to en-GB. If you take a look at the structure of the table in shopware there is one problem though:

-- simplified language DDL
create table language
(
    id                  binary(16) not null primary key,
    name                varchar(50) not null,
    parent_id           binary(16) null,
    locale_id           binary(16) not null,
    translation_code_id binary(16) null,
    custom_fields       json null,
    -- timestamps ...
    constraint `fk.language.locale_id` foreign key (locale_id) references locale (id) on update ...,
    -- other constraints ...
);

As you can see there is no locale field in the table. Instead, locales are stored in a separate table. However, the language has a reference to that table which we can use as an association in our configuration and api request. If we want to find the language whose locale code is equal to en-GB we can use the following search request:

{  
  "filter": [  
	 {      
	     "type": "equals",  
	     "field": "locale.code",  
	     "value": "de-DE"  
     }
   ],
   "associations": {
	   "locale": {}
   }
}  

This request returns the language whose locale is en-GB. If you want the mapping-extension to recreate this request in order to set the uuid of that language to your entity we can use the following configuration:

{
    "targetFieldName": "language_id",
    "sourcePath": "language",
    "foreignKeyFieldConfig": {
        "references": {
            "table": "language"
        },
        "allowsNullValues": false,
        "filterSearchField": "locale.code"
    }
}

Using filter paths

In this example there is a very important detail: the value in filterSearchField is actually a path instead of a field. You can search external referenced entities with associations. But please note that this must be a valid filter path in Shopware. There are some limitations to using filter paths instead of fields:

  • The path must be a valid association in Shopware. Please test the request you want to generate (e.g. in Postman) to avoid FRAMEWORK_ASSOCIATION_NOT_FOUND errors.
  • Only single layered associations are supported, the path one.two.three would be invalid.
  • Although it is not an association you can also search for values of customFields (e.g. customFields.identifier).

External Reference to Products

Since this is an example for many-to-many associations and will populate the table request_product please refer to the next section.

Request Positions - Many-To-Many References

Now It's time to configure the last table that is left which is product_request.

As mentioned in the introduction a request can have several "request positions", which basically means that a customer can request several products at the same time. So this is a many-to-many association between the tables request and product, which is implemented by the help of the junction table product_request.

As a preparation we already added the field products to the document Request which contains all requested products (see above):

/**  
 * @var Collection<Product>  
 * @MongoDB\ReferenceMany(targetDocument=Product::class)  
 */
 protected Collection $products;

Unfortunately there is a limitation regarding the mapping of many-to-many associations. It is not possible to use this collection of products to populate the table product_request automatically yet. However, the procedure to populate this table is not that different from the examples above. You have to create a corresponding document for the product_request table that the mapping extension will use to populate this association:

/**  
 * Document for the junction table "product_request" since many-to-many associations are not mapped automatically yet. 
 * 
 * @MongoDB\Document  
 */  
class RequestProduct extends BaseDocument  
{  
    use UniqueIdentifiableDocument;  
  
    /**  
     * Example for an internal reference.
     * 
     * @var DemoRequest  
     * @MongoDB\ReferenceOne(targetDocument=DemoRequest::class, cascade="persist")  
     */    
     private DemoRequest $request;  
  
    /**  
     * Example for an external reference without documents.    
     * 
     * @var string  
     * @MongoDB\Field(type="string")  
     */    
     private string $productNumber;

	// construct, getters, setters, ...
	
}

A possible workaround could be to populate your document (including the products collection) as you normally would. In a second step you could take your document and generate the "junction documents":

$requests = $documentManager->getRepository(Request::class)->findAll();  
foreach ($requests as $request) {  
    foreach ($request->getProducts() as $product) {  
        $requestProduct = new RequestProduct();  
        $requestProduct->setIdentifier($request->getIdentifier() .  $product->getIdentifier());  
        $requestProduct->setRequest($request);  
        $requestProduct->setProductNumber($product->getIdentifier());  
        $documentManager->persist($requestProduct);  
    }  
}  
$documentManager->flush();

Of course this is not the best solution and should be adjusted to your needs (depending on the amount of entities it can be a bad idea to iterate all documents at once).

The next step is to generate the field configuration for our junction table product_request. It does not look that different from all examples above:

{
    "targetTableName": "s_request_product",
    "isJunctionTable": true,
    "sourceDocumentFqcn": "Synqup\\...\\RequestProduct",
    "fields": [
        {
            "targetFieldName": "s_request_id",
            "sourcePath": "request",
            "foreignKeyFieldConfig": {
                "references": {
                    "table": "s_request"
                }
            }
        },
        {
            "targetFieldName": "product_id",
            "sourcePath": "productNumber",
            "foreignKeyFieldConfig": {
                "references": {
                    "table": "product"
                },
                "allowsNullValues": false,
                "filterSearchField": "productNumber"
            }
        }
    ]
}

Please note the configuration field isJunctionTable which is set to true since the table is a junction table for a many-to-many association. Apart from that the configuration follows the same principles as before.

Configuration of the Mapping Extension

Since object-mappings are handled in sequential order, referenced entities must be configured before referencing entities. In this case: The object mapping of DemoRequestState must be added to the configuration before DemoRequest and DemoRequest must be added before DemoRequestProduct.

After all mappings are done we have to add the configuration to the main module. The following configuration contains the default settings for the mapping extension:

  • primaryKeyMode is the highly recommended primary key mode
  • referenceLoadingMode is bulk ( see reference loading modes) for details
  • batchSizes are the defaults as well. If you feel your extension is running too slow feel free to increase those numbers ( see batch-sizes)
  • deleteAnomalyThresholdPercentage is the default value of 75 ( see document deletion for details)
{
    "extensions": {
        "Synqup\\Modules\\Shopware6MappingExtensionBundle\\Subscriber\\Shopware6MappingExtensionBundleSubscriber": {
            "primaryKeyMode": "identifier",
            "referenceLoadingMode": "bulk",
            "deleteAnomalyThresholdPercentage": 75,
            "batchSizes": {
                "validate": 200,
                "upsert": 75,
                "delete": 100,
                "referenceCache": 50,
                "referenceExtract": 200
            },
            "mappings": [
                {
                    "targetTableName": "s_request_state",
                    "sourceDocumentFqcn": "Synqup\\Modules\\IntegrationBundle\\Document\\Request\\DemoRequestState",
                    "fields": [
                        {
                            "targetFieldName": "code",
                            "sourcePath": "identifier",
                            "stringFieldConfig": {
                                "maxLength": 2,
                                "allowsHtml": false,
                                "isTranslated": false,
                                "allowsNullValues": false
                            }
                        },
                        {
                            "targetFieldName": "name",
                            "sourcePath": "name",
                            "stringFieldConfig": {
                                "maxLength": 255,
                                "allowsHtml": false,
                                "isTranslated": true
                            }
                        }
                    ]
                },
                {
                    "targetTableName": "s_request",
                    "sourceDocumentFqcn": "Synqup\\Modules\\IntegrationBundle\\Document\\Request\\DemoRequest",
                    "fields": [
                        {
                            "targetFieldName": "comment",
                            "sourcePath": "comment",
                            "stringFieldConfig": {
                                "maxLength": 255,
                                "allowsHtml": false,
                                "isTranslated": false,
                                "allowsNullValues": false
                            }
                        },
                        {
                            "targetFieldName": "customer_id",
                            "sourcePath": "customer.identifier",
                            "foreignKeyFieldConfig": {
                                "references": {
                                    "table": "customer"
                                },
                                "allowsNullValues": false,
                                "filterSearchField": "customerNumber"
                            }
                        },
                        {
                            "targetFieldName": "language_id",
                            "sourcePath": "language",
                            "foreignKeyFieldConfig": {
                                "references": {
                                    "table": "language"
                                },
                                "allowsNullValues": false,
                                "filterSearchField": "locale.code"
                            }
                        },
                        {
                            "targetFieldName": "s_request_state_id",
                            "sourcePath": "state",
                            "foreignKeyFieldConfig": {
                                "references": {
                                    "table": "s_request_state"
                                },
                                "allowsNullValues": false
                            }
                        }
                    ]
                },
                {
                    "targetTableName": "s_request_product",
                    "isJunctionTable": true,
                    "sourceDocumentFqcn": "Synqup\\Modules\\IntegrationBundle\\Document\\Request\\DemoRequestProduct",
                    "fields": [
                        {
                            "targetFieldName": "s_request_id",
                            "sourcePath": "request",
                            "foreignKeyFieldConfig": {
                                "references": {
                                    "table": "s_request"
                                }
                            }
                        },
                        {
                            "targetFieldName": "product_id",
                            "sourcePath": "productNumber",
                            "foreignKeyFieldConfig": {
                                "references": {
                                    "table": "product"
                                },
                                "allowsNullValues": false,
                                "filterSearchField": "productNumber"
                            }
                        }
                    ]
                }
            ]
        }
    }
}

That's it - next time you start the output module the mapping extension will transfer your documents to the plugin tables.