It's already possible to with Default Content to Easily add content with update hooks: use default content module exports to create content that needs to be in sync with configuration:

An approach for keeping content that is added as part of development synchronized with test and production environments is to use the Default Content module to export the content. It's built for the content to be exported to an installation profile's 'content' folder, and then the module, if enabled, automatically brings the content in when the site is installed. Presuming that reinstalling the site would be a drastic step for getting one block's content (aside: we do find it useful to use installation profiles with default content for automated testing and initial development), it's also possible to import the content one item at a time, such as in an update hook, with the below code in your example.install or example.profile:

/**
* Import a piece of content exported by default content module.
*/
function example_import_default_content($path_to_content_json) {
  list($entity_type_id, $filename) = explode('/', $path_to_content_json);
  $p = drupal_get_path('profile', 'guts');
  $encoded_content = file_get_contents($p . '/content/' . $path_to_content_json);
  $serializer = \Drupal::service('serializer');
  $content = $serializer->decode($encoded_content, 'hal_json');
  global $base_url;
  $url = $base_url . base_path();
  $content['_links']['type']['href'] = str_replace('http://drupal.org/', $url, $content['_links']['type']['href']);
  $contents = $serializer->encode($content, 'hal_json');
  $class = 'Drupal\\' . $entity_type_id . '\Entity\\' . str_replace(' ', '', ucwords(str_replace('_', ' ', $entity_type_id)));
  $entity = $serializer->deserialize($contents, $class, 'hal_json', array('request_method' => 'POST'));
  $entity->enforceIsNew(TRUE);
  $entity->save();
}

Export a custom block with an ID of 8:

drush dcer block_content 8

And used in your example.install file:

/**
* Add the footer block content.
*
* Implements hook_update_N().
*/
function example_update_8001() {
  example_import_default_content('block_content/136efd63-021e-42ea-8202-8b97305cc07f.json');
}

Many people are looking for the solution to exporting block configuration in their local environment and getting broken/missing block errors, and i've posted this answers like on Drupal.StackExchange, but a helper function would make this approach much less intimidating!

Not in any way endorsing my code above for making this possible; indeed I think pretty much all the underlying functionality would be shared with #2640734: [PP-1] Allow manual imports

Comments

mlncn created an issue. See original summary.

mlncn’s picture

We've found we have to special-case the import of taxonomy terms like this, replacing the "$class = ..." line:

  // The class structure for taxonomy terms is different.
  if ($entity_type_id == 'taxonomy_term') {
    $class = 'Drupal\taxonomy\Entity\term';
  } else {
    $class = 'Drupal\\' . $entity_type_id . '\Entity\\' . str_replace(' ', '', ucwords(str_replace('_', ' ', $entity_type_id)));
  }

If there's no opposition, we'll submit a patch.

mlncn’s picture

Issue #2640734: [PP-1] Allow manual imports ought to cover this (non-Drush) use case if implemented well.

larowlan’s picture

This is the wrong approach, something like \Drupal::entityTypeManager()->getDefinition('taxonomy_term')->getClass() is the correct approach.

andypost’s picture

mlncn’s picture

Here's the code improved with larowlan's correction:

/**
* Import a piece of content exported by default content module.
*/
function example_import_default_content($path_to_content_json) {
  list($entity_type_id, $filename) = explode('/', $path_to_content_json);
  $p = drupal_get_path('profile', 'example');
  $encoded_content = file_get_contents($p . '/content/' . $path_to_content_json);
  $serializer = \Drupal::service('serializer');
  $content = $serializer->decode($encoded_content, 'hal_json');
  global $base_url;
  $url = $base_url . base_path();
  $content['_links']['type']['href'] = str_replace('http://drupal.org/', $url, $content['_links']['type']['href']);
  $contents = $serializer->encode($content, 'hal_json');
  $class = \Drupal::entityTypeManager()->getDefinition($entity_type_id)->getClass();
  $entity = $serializer->deserialize($contents, $class, 'hal_json', array('request_method' => 'POST'));
  $entity->enforceIsNew(TRUE);
  $entity->save();
}

And we use it like this:

/**
* Add the Past board member taxonomy term.
*
* Implements hook_update_N().
*/
function example_update_8009() {
  example_import_default_content('taxonomy_term/841c0050-5194-464a-848f-a3895c37b735.json');
}

I don't consider the way #2640734: [PP-1] Allow manual imports has been implemented nor #2617714: Revert content to code version to replace this feature request: There's a big difference to me between importing one specific piece of content and (re?) importing all default content in all modules, which is the way they are implemented. I had really wanted to just RTBC the patch in 2640734 and mark all this duplicate, but it seems to be different use cases.

I'm coming from the perspective of control-freak-sitebuilder, who wants for instance to build a view that depends on a new taxonomy term in a controlled environment, be able to test it with automated tests and on a test site, and then deploy to the live site. I don't want to risk re-creating random other content that had been default content but has since been deleted on the live site, or risk reverting live content to default originals, depending how those requests get implemented. Nor do i want a thousand pieces of default content evaluated to make the one change i know i want. Nor do i want to have to create a whole module just to introduce a single piece of default content without dragging older default content into the equation.

But maybe i just have to be shown the light in how best to use default content in site building?

nedjo’s picture

A use case for this approach is when default configuration is added that depends on specific default content.

An example is this issue on the Drutopia Site project. We added a footer copyright/copyleft block that consisted of (a) a block content entity, provided as default content and (b) a block configuration entity, provided as default configuration. Our workflow for an existing site is to update the code, run any updates, and then run the configuration update provided by Config Distro in combination with Configuration Synchronizer.

This however results in a broken site since the block configuration entity is created but it requires the corresponding block content entity, which hasn't been created.

To avoid this breakage, we would need to create this specific default content entity in an update. One of the approaches that imports all available default content entities, however, wouldn't necessarily apply here. For example, our distribution's module set creates certain other default content entities as sample content that should be created at module install time but not subsequently.

andypost’s picture

nedjo’s picture

@andypost Thx! that indeed looks like what we need.

flyke’s picture

Existing code examples sometimes gave some errors, like when content already existed etc.
So based on the existing examples, I created custom import code.

Just to be clear: each content exported via drush dcer command, will automatically be imported when enabling the module that has the content inside. For this, a custom import function / update hook is not needed.
Example:
lets say on local dev environment you create (and enable) a custom mymodule_default_content.
all that the module needs is an empty content folder (mymodule_default_content/content) and a mymodule_default_content.info.yml file:

name: 'Mymodule Default Content'
type: module
description: 'Used to export and import content via config.'
core: 8.x
dependencies:
  - default_content:default_content

On your staging/production this module does not exist yet / is not enabled yet.
If you create a block or a menu link on your local dev environment, you can then export that content into your new module:

drush dcer block_content 11 --folder=modules/custom/mymodule_default_content/content
drush dcer menu_link_content 45 --folder=modules/custom/mymodule_default_content/content

This will generate the content as a json file inside mymodule_default_content/content, like:
modules/custom/mymodule_default_content/content/menu_link_content/55489d08-4eac-4818-9cea-e3cb38b70195.json
Alhough not needed, I strongly suggest renaming it to something descriptive, like:
modules/custom/mymodule_default_content/content/menu_link_content/menulink-contact.json

Now if you export your config on your dev environment (which would enable the mymodule_default_content) and deploy and import config on your staging/production environment, the content inside mymodule_default_content/content will be created on that environment because the module is enabled.

Great!
So some time later on your dev you need to create another block or another menulink or something that needs to be deployed.
So you create it inside your mymodule_default_content/content using:

drush dcer menu_link_content 50 --folder=modules/custom/mymodule_default_content/content

(and you rename the json file to menulink-privacy-policy.json for example)

Problem: module is already enabled on your staging/production environment, so noting will happen after deployment when you just add your new exported content json files.

Now you need an import function and an update hook:

mymodule_default_content/mymodule_default_content.module:

/**
 * @file
 * Import a piece of content exported by default content module.
 */

$module_handler = \Drupal::service('module_handler');

/**
 * Custom function to import content that was exported via default_content.
 */
function _import_default_content($path_to_content_json) {
  list($entity_type_id, $filename) = explode('/', $path_to_content_json);
  $module_handler = \Drupal::service('module_handler');
  $module_path = $module_handler->getModule('mymodule_default_content')->getPath();

  $encoded_content = file_get_contents($module_path . '/content/' . $path_to_content_json);

  $serializer = \Drupal::service('serializer');
  $content = $serializer->decode($encoded_content, 'hal_json');

  // If entity already exists, do nothing.
  $entity_id = FALSE;
  if (isset($content["id"])){
    $entity_id = $content["id"][0]["value"];
  }
  if (isset($content["nid"])){
    $entity_id = $content["nid"][0]["value"];
  }
  if (isset($content["tid"])){
    $entity_id = $content["tid"][0]["value"];
  }
  if ($entity_id) {
    $entity_storage = \Drupal::entityTypeManager()->getStorage($entity_type_id);
    $existing_entity = $entity_storage->load($entity_id);
    if ($existing_entity !== NULL) {
      echo 'Skipping importing content because it already exists: ' . $existing_entity->label();
      return;
    }
  }

  // If entity does not exist, make sure there are no revisions of it either, otherwise we still get error when saving.
  unset($content["revision_id"]);
  unset($content["revision_created"]);
  unset($content["revision_translation_affected"]);

  global $base_url;
  $url = $base_url . base_path();
  $content['_links']['type']['href'] = str_replace('http://drupal.org/', $url, $content['_links']['type']['href']);
  $contents = $serializer->encode($content, 'hal_json');
  $class = \Drupal::entityTypeManager()->getDefinition($entity_type_id)->getClass();
  $entity = $serializer->deserialize($contents, $class, 'hal_json', ['request_method' => 'POST']);

  $entity->enforceIsNew(TRUE);
  $entity->save();
}

/**
 * Add our later created menulink.
 *
 * Implements hook_update_N().
 */
function mymodule_default_content_update_8001() {
  // Import a menulink (that was previously exported).
  _import_default_content('menu_link_content/menulink-privacy-policy.json');
  // Import a node (that was previously exported).
  _import_default_content('node/homepage.json');
  // Import a block (that was previously exported).
  _import_default_content('block_content/footer.json');
  // Import a taxonomy term (that was previously exported).
  _import_default_content('taxonomy_term/label-car.json');
}

And that is what works for me.
Question:
is there a way to replace our custom _import_default_content function with whatever the module default_content does when enabling a module that has default content ? So we do not need to re write code that is already written somewhere ?

UPDATED above code on 05/03/2020 so it also works for taxonomy terms.

flyke’s picture

Not tested yet, but looking into the default_content code, you could theoretically just put this inside your .module file:

/**
 * Add our later created menulink.
 *
 * Implements hook_update_N().
 */
function mymodule_default_content_update_8001() {
  $count = count(\Drupal::service('default_content.importer')->importContent('mymodule_default_content'));
  \Drupal::logger('mymodule_default_content')->notice('@count: items imported.',
        array(
            '@count' => $count,
        )
  );
}

So you would not need the custom _import_default_content() function from above example.
But it seems that could giver errors on existing items, so you might still need my custom function from above after all if you are having errors.

berdir’s picture

Status: Active » Closed (duplicate)

Closing as a duplicate of #2860958: Decompose Importer::importContent() method, that will offer methods to import single entities from what I understand.