All Downloads are FREE. Search and download functionalities are using the official Maven repository.

liquibase.snapshot.DatabaseSnapshot Maven / Gradle / Ivy

There is a newer version: 4.30.0
Show newest version
package liquibase.snapshot;

import liquibase.CatalogAndSchema;
import liquibase.Scope;
import liquibase.GlobalConfiguration;
import liquibase.database.Database;
import liquibase.database.DatabaseConnection;
import liquibase.database.OfflineConnection;
import liquibase.diff.compare.CompareControl;
import liquibase.diff.compare.DatabaseObjectComparatorFactory;
import liquibase.exception.DatabaseException;
import liquibase.exception.UnexpectedLiquibaseException;
import liquibase.logging.Logger;
import liquibase.parser.core.ParsedNode;
import liquibase.parser.core.ParsedNodeException;
import liquibase.resource.ResourceAccessor;
import liquibase.serializer.LiquibaseSerializable;
import liquibase.structure.DatabaseObject;
import liquibase.structure.DatabaseObjectCollection;
import liquibase.structure.core.*;
import liquibase.util.BooleanUtil;
import liquibase.util.ISODateFormat;
import liquibase.util.ObjectUtil;
import liquibase.util.StringUtil;

import java.sql.Timestamp;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

public abstract class DatabaseSnapshot implements LiquibaseSerializable {

    private static final Logger LOGGER = Scope.getCurrentScope().getLog(DatabaseSnapshot.class);
    public static final String ALL_CATALOGS_STRING_SCRATCH_KEY = "DatabaseSnapshot.allCatalogsString";

    private final DatabaseObject[] originalExamples;
    private final HashSet serializableFields;
    private final SnapshotControl snapshotControl;
    private final Database database;
    private final DatabaseObjectCollection allFound;
    private final DatabaseObjectCollection referencedObjects;
    private final Map, Set> knownNull = new ConcurrentHashMap<>();

    private final Map snapshotScratchPad = new ConcurrentHashMap<>();

    private final Map resultSetCaches = new ConcurrentHashMap<>();
    private CompareControl.SchemaComparison[] schemaComparisons;

    private Map metadata = new ConcurrentHashMap<>();

    DatabaseSnapshot(DatabaseObject[] examples, Database database, SnapshotControl snapshotControl) throws DatabaseException, InvalidExampleException {
        this.database = database;
        allFound = new DatabaseObjectCollection(database);
        referencedObjects = new DatabaseObjectCollection(database);
        this.snapshotControl = snapshotControl;

        this.originalExamples = ((examples == null) ? new DatabaseObject[0] : examples);

        init(examples);

        this.serializableFields = new HashSet<>();
        this.serializableFields.add("snapshotControl");
        this.serializableFields.add("objects");
        this.serializableFields.add("referencedObjects");
        this.serializableFields.add("database");
        this.serializableFields.add("created");
        this.serializableFields.add("metadata");
    }

    public DatabaseSnapshot(DatabaseObject[] examples, Database database) throws DatabaseException, InvalidExampleException {
        this(examples, database, new SnapshotControl(database));
    }

    protected void init(DatabaseObject[] examples) throws DatabaseException, InvalidExampleException {
        if (examples != null) {
            Set catalogs = new HashSet<>();
            for (DatabaseObject object : examples) {
                if (object instanceof Schema) {
                    catalogs.add(((Schema) object).getCatalog());
                }
            }

            this.setScratchData("DatabaseSnapshot.allCatalogs", catalogs);

            if (catalogs.size() > 1) {
                List quotedCatalogs = new ArrayList<>();
                for (Catalog catalog : catalogs) {
                    quotedCatalogs.add("'" + catalog.getName() + "'");
                }
                if (CatalogAndSchema.CatalogAndSchemaCase.ORIGINAL_CASE.equals(database.getSchemaAndCatalogCase())) {
                    this.setScratchData(ALL_CATALOGS_STRING_SCRATCH_KEY, StringUtil.join(quotedCatalogs, ", "));
                } else {
                    this.setScratchData(ALL_CATALOGS_STRING_SCRATCH_KEY, StringUtil.join(quotedCatalogs, ", ").toUpperCase());
                }
            }

            if (getDatabase().supportsCatalogs()) {
                for (Catalog catalog : catalogs) {
                    this.snapshotControl.addType(catalog.getClass(), database);
                    include(catalog);
                }
            }
            for (DatabaseObject obj : examples) {
                this.snapshotControl.addType(obj.getClass(), database);

                include(obj);
            }
        }
    }

    /**
     * Searches the current snapshot content for the given examples. Returns a new DatabaseSnapshot
     * containing a clone of every object from the examples array that was found.
     *
     * @param examples The array of snapshot objects to search and clone
     * @return a new DatabaseSnapshot object with the clones of the desired objects. If no object is
     * found, an empty DatabaseSnapshot will be returned.
     */
    public DatabaseSnapshot clone(DatabaseObject[] examples) {
        try {
            DatabaseSnapshot returnSnapshot = new RestoredDatabaseSnapshot(this.database);

            for (DatabaseObject example : examples) {
                DatabaseObject existingObject = this.get(example);
                if (existingObject == null) {
                    continue;
                }
                if (example instanceof Schema) {
                    for (Class type : this.snapshotControl.getTypesToInclude()) {

                        for (DatabaseObject object : this.get(type)) {
                            if (object.getSchema() == null) {
                                if (object instanceof Catalog) {
                                    if (DatabaseObjectComparatorFactory.getInstance().isSameObject(object, ((Schema) example).getCatalog(), null, database)) {
                                        returnSnapshot.allFound.add(object);
                                    }
                                } else {
                                    returnSnapshot.allFound.add(object);
                                }
                            } else {
                                if (DatabaseObjectComparatorFactory.getInstance().isSameObject(object.getSchema(), example, null, database)) {
                                    returnSnapshot.allFound.add(object);
                                } else {
                                    if (object.getClass().getName().contains("Synonym")
                                            && !object.getAttribute("private", false)) {
                                        Schema objectSchema = object.getAttribute("objectSchema", Schema.class);
                                        if (DatabaseObjectComparatorFactory.getInstance().isSameObject(objectSchema, example, null, database)) {
                                            returnSnapshot.allFound.add(object);
                                        }
                                    }
                                }
                            }
                        }
                    }
                } else {
                    returnSnapshot.allFound.add(existingObject);
                }
            }

            returnSnapshot.getMetadata().putAll(this.getMetadata());

            return returnSnapshot;
        } catch (Exception e) {
            throw new UnexpectedLiquibaseException(e);
        }
    }

    /**
     *
     *  Method which merges two object snapshot models into one
     *
     *  @param  snapshotToMerge            Another object snapshot model
     *  @return DatabaseSnapshot           Merged object model
     *
     */
    public DatabaseSnapshot merge(DatabaseSnapshot snapshotToMerge) {
        DatabaseSnapshot returnSnapshot = this;
        Map, Set> allFoundMap = snapshotToMerge.allFound.toMap();
        Map, Set> referencedObjectsMap = snapshotToMerge.referencedObjects.toMap();
        for (Set setOfDatabaseObject : allFoundMap.values()) {
            for (DatabaseObject dbObject : setOfDatabaseObject) {
                returnSnapshot.allFound.add(dbObject);
            }
        }
        for (Set setOfDatabaseObject : referencedObjectsMap.values()) {
            for (DatabaseObject dbObject : setOfDatabaseObject) {
                returnSnapshot.referencedObjects.add(dbObject);
            }
        }
        return returnSnapshot;
    }

    public SnapshotControl getSnapshotControl() {
        return snapshotControl;
    }

    @Override
    public String getSerializedObjectName() {
        return "snapshot";
    }

    @Override
    public String getSerializedObjectNamespace() {
        return STANDARD_SNAPSHOT_NAMESPACE;
    }

    @Override
    public String getSerializableFieldNamespace(String field) {
        return getSerializedObjectNamespace();
    }


    @Override
    public Set getSerializableFields() {
        return serializableFields;
    }

    @Override
    public Object getSerializableFieldValue(String field) {
        switch (field) {
            case "snapshotControl":
                return snapshotControl;
            case "objects":
                return allFound;
            case "referencedObjects":
                return referencedObjects;
            case "metadata":
                return metadata;
            case "created":
                return new ISODateFormat().format(new Timestamp(new Date().getTime()));
            case "database":
                Map map = new HashMap<>();
                map.put("shortName", database.getShortName());
                map.put("productName", database.getDatabaseProductName());
                map.put("url", database.getConnection().getURL());
                try {
                    map.put("majorVersion", String.valueOf(database.getDatabaseMajorVersion()));
                    map.put("minorVersion", String.valueOf(database.getDatabaseMinorVersion()));
                    map.put("productVersion", database.getDatabaseProductVersion());
                    map.put("user", database.getConnection().getConnectionUserName());
                } catch (DatabaseException e) {
                    //ok
                }
                return map;
            default:
                throw new UnexpectedLiquibaseException("Unknown field: " + field);
        }
    }

    @Override
    public SerializationType getSerializableFieldType(String field) {
        switch (field) {
            case "snapshotControl":
            case "objects":
            case "referencedObjects":
                return SerializationType.NESTED_OBJECT;
            default:
                throw new UnexpectedLiquibaseException("Unknown field: " + field);
        }
    }

    public Database getDatabase() {
        return database;
    }

    public ResultSetCache getResultSetCache(String key) {
        if (!resultSetCaches.containsKey(key)) {
            resultSetCaches.put(key, new ResultSetCache());
        }
        return resultSetCaches.get(key);
    }

    /**
     * Include the object described by the passed example object in this snapshot. Returns the object snapshot or null
     * if the object does not exist in the database. If the same object was returned by an earlier include() call,
     * the same object instance will be returned.
     */
    protected  T include(T example) throws DatabaseException, InvalidExampleException {
        if (example == null) {
            return null;
        }

        if (database.isSystemObject(example)) {
            return null;
        }

        if ((example instanceof Schema) && (example.getName() == null) && ((((Schema) example).getCatalog() == null)
                || (((Schema) example).getCatalogName() == null))) {
            CatalogAndSchema catalogAndSchema = ((Schema) example).toCatalogAndSchema().customize(database);
            example = (T) new Schema(catalogAndSchema.getCatalogName(), catalogAndSchema.getSchemaName());
        }

        if (!snapshotControl.shouldInclude(example)) {
            LOGGER.fine("Excluding " + example);
            return example;
        }

        T existing = get(example);
        if (existing != null) {
            return existing;
        }
        if (isKnownNull(example)) {
            return null;
        }

        SnapshotListener snapshotListener = snapshotControl.getSnapshotListener();

        SnapshotGeneratorChain chain = createGeneratorChain(example.getClass(), database);
        if (snapshotListener != null) {
            snapshotListener.willSnapshot(example, database);
        }

        T object = chain.snapshot(example, this);

        if (object == null) {
            Set collection = knownNull.computeIfAbsent(example.getClass(), k -> new HashSet<>());
            collection.add(example);

            if (example instanceof Schema) {
                if (snapshotControl.isWarnIfObjectNotFound())
                    Scope.getCurrentScope().getLog(getClass()).warning("Did not find schema '" + example + "' to snapshot");
            }
            if (example instanceof Catalog) {
                if (snapshotControl.isWarnIfObjectNotFound())
                    Scope.getCurrentScope().getLog(getClass()).warning("Did not find catalog '" + example + "' to snapshot");
            }

        } else {
            allFound.add(object);

            try {
                includeNestedObjects(object);
            } catch (ReflectiveOperationException e) {
                throw new UnexpectedLiquibaseException(e);
            }
        }

        if (snapshotListener != null) {
            snapshotListener.finishedSnapshot(example, object, database);
        }

        return object;
    }

    private void includeNestedObjects(DatabaseObject object) throws DatabaseException, InvalidExampleException, ReflectiveOperationException {
        for (String field : new HashSet<>(object.getAttributes())) {
            Object fieldValue = object.getAttribute(field, Object.class);
            //
            // Make sure we always assign a snapshot ID when we handle index columns
            // in the case where one of the columns is descending
            //
            if ("columns".equals(field) && ((object.getClass() == PrimaryKey.class) || (object.getClass() == Index
                    .class) || (object.getClass() == UniqueConstraint.class))) {
                if ((fieldValue != null) && !((Collection) fieldValue).isEmpty()) {
                    if (descendingColumnExists((Collection)fieldValue)) {
                        List columns = (List)fieldValue;
                        for (Column col : columns) {
                            if (col.getSnapshotId() != null) {
                                continue;
                            }
                            col.setSnapshotId(SnapshotIdService.getInstance().generateId());
                            includeNestedObjects(col);
                            referencedObjects.add(col);
                        }
                        continue;
                    }
                }
            }
            Object newFieldValue = replaceObject(fieldValue);
            if (newFieldValue == null) { //sometimes an object references a non-snapshotted object. Leave it with the unsnapshotted example
                if (((object instanceof UniqueConstraint) || (object instanceof PrimaryKey) || (object instanceof
                        ForeignKey)) && "backingIndex".equals(field)) { //unless it is the backing index, that is handled a bit strange and we need to handle the case where there is no backing index (disabled PK on oracle)
                    object.setAttribute(field, null);
                }
            } else if (fieldValue != newFieldValue) {
                object.setAttribute(field, newFieldValue);
            }
        }
    }

    //
    // Check for existence of a descending column within the Collection
    //
    private boolean descendingColumnExists(Collection fieldValue) {
        for (Column column : fieldValue) {
            String columnName = column.getName().toLowerCase();
            if (BooleanUtil.isTrue(column.getDescending()) ||
                    columnName.endsWith(" asc") ||
                    columnName.endsWith(" desc") ||
                    columnName.endsWith(" random")) {
                return true;
            }
        }
        return false;
    }
    private Object replaceObject(Object fieldValue) throws DatabaseException, InvalidExampleException, ReflectiveOperationException {
        if (fieldValue == null) {
            return null;
        }
        if (fieldValue instanceof DatabaseObject) {
            if (((DatabaseObject) fieldValue).getSnapshotId() != null) { //already been replaced
                return fieldValue;
            }

            if (!snapshotControl.shouldInclude(((DatabaseObject) fieldValue).getClass())) {
                return fieldValue;
            }

            if (!(fieldValue instanceof Catalog) && isWrongSchema(((DatabaseObject) fieldValue))) {
                DatabaseObject savedFieldValue = referencedObjects.get((DatabaseObject) fieldValue, schemaComparisons);
                if (savedFieldValue == null) {
                    savedFieldValue = (DatabaseObject) fieldValue;
                    savedFieldValue.setSnapshotId(SnapshotIdService.getInstance().generateId());
                    includeNestedObjects(savedFieldValue);

                    referencedObjects.add(savedFieldValue);
                }

                return savedFieldValue;
            }
            if ((fieldValue instanceof Catalog) && isWrongCatalog(((DatabaseObject) fieldValue))) {
                DatabaseObject savedFieldValue = referencedObjects.get((DatabaseObject) fieldValue, schemaComparisons);
                if (savedFieldValue == null) {
                    savedFieldValue = (DatabaseObject) fieldValue;
                    savedFieldValue.setSnapshotId(SnapshotIdService.getInstance().generateId());

                    referencedObjects.add(savedFieldValue);
                }

                return savedFieldValue;
            }


            if (((DatabaseObject) fieldValue).getSnapshotId() == null) {
                return include((DatabaseObject) fieldValue);
            } else {
                return fieldValue;
            }
            //            } else if (Set.class.isAssignableFrom(field.getType())) {
            //                field.setAccessible(true);
            //                Set fieldValue = field.get(object);
            //                for (Object val : fieldValue) {
            //
            //                }
        } else if (fieldValue instanceof Collection) {
            Iterator fieldValueIterator = new CopyOnWriteArrayList((Collection) fieldValue).iterator();
            List newValues = new ArrayList<>();
            while (fieldValueIterator.hasNext()) {
                Object obj = fieldValueIterator.next();
                if ((fieldValue instanceof DatabaseObject) && !snapshotControl.shouldInclude(((DatabaseObject)
                        fieldValue).getClass())) {
                    return fieldValue;
                }

                if ((obj instanceof DatabaseObject) && (((DatabaseObject) obj).getSnapshotId() == null)) {
                    obj = include((DatabaseObject) obj);
                }
                if (obj != null) {
                    newValues.add(obj);
                }
            }
            Collection newCollection = null;
            try {
                Class collectionClass = fieldValue.getClass();
                if (List.class.isAssignableFrom(collectionClass)) {
                    collectionClass = ArrayList.class;
                }
                newCollection = (Collection) collectionClass.getConstructor().newInstance();
            } catch (InstantiationException e) {
                throw e;
            }
            newCollection.addAll(newValues);
            return newCollection;
        } else if (fieldValue instanceof Map) {
            Map newMap = (Map) fieldValue.getClass().getConstructor().newInstance();
            for (Map.Entry entry : new HashSet<>((Set) ((Map) fieldValue).entrySet())) {
                Object key = replaceObject(entry.getKey());
                Object value = replaceObject(entry.getValue());

                if (key != null) {
                    newMap.put(key, value);
                }
            }

            return newMap;

        }

        return fieldValue;
    }

    protected boolean isWrongSchema(DatabaseObject fieldValue) {
        boolean isSchemaExamples = true;
        if (originalExamples == null) {
            return false;
        }
        for (DatabaseObject obj : originalExamples) {
            if (!(obj instanceof Schema)) {
                isSchemaExamples = false;
                break;
            }
        }

        if (!isSchemaExamples) {
            return false;
        }

        for (DatabaseObject obj : originalExamples) {
            if (DatabaseObjectComparatorFactory.getInstance().isSameObject(fieldValue.getSchema(), obj, schemaComparisons, database)) {
                return false;
            }
        }
        return true;
    }

    protected boolean isWrongCatalog(DatabaseObject fieldValue) {
        String fieldCatalog;
        if (fieldValue instanceof Catalog) {
            fieldCatalog = fieldValue.getName();
        } else if (fieldValue instanceof Schema) {
            fieldCatalog = ((Schema) fieldValue).getCatalogName();
        } else {
            Schema fieldSchema = fieldValue.getSchema();
            if (fieldSchema == null) {
                return false;
            }
            fieldCatalog = fieldSchema.getCatalogName();
        }
        if (fieldCatalog == null) {
            return false;
        }

        Set catalogNames = new HashSet<>();
        for (DatabaseObject obj : originalExamples) {
            String catalogName = null;
            if (obj instanceof Schema) {
                catalogName = ((Schema) obj).getCatalogName();
            } else if (obj instanceof Catalog) {
                catalogName = obj.getName();
            }
            if (catalogName != null) {
                if (CatalogAndSchema.CatalogAndSchemaCase.ORIGINAL_CASE.equals(database.getSchemaAndCatalogCase())) {
                    catalogNames.add(catalogName);
                } else {
                    catalogNames.add(catalogName.toLowerCase());
                }
            }
        }
        if (CatalogAndSchema.CatalogAndSchemaCase.ORIGINAL_CASE.equals(database.getSchemaAndCatalogCase())) {
            return !catalogNames.contains(fieldCatalog);
        }

        return !catalogNames.contains(fieldCatalog.toLowerCase());
    }

    /**
     * Returns the object described by the passed example if it is already included in this snapshot.
     */
    public  DatabaseObjectType get(DatabaseObjectType example) {
        return allFound.get(example, schemaComparisons);
    }

    /**
     * Returns all objects of the given type that are already included in this snapshot.
     */
    public  Set get(Class type) {
        return allFound.get(type);
    }


    protected SnapshotGeneratorChain createGeneratorChain(Class databaseObjectType, Database database) {
        SortedSet generators = SnapshotGeneratorFactory.getInstance().getGenerators(databaseObjectType, database);
        if ((generators == null) || generators.isEmpty()) {
            return null;
        }
        //noinspection unchecked
        return new SnapshotGeneratorChain(generators);
    }

    private boolean isKnownNull(DatabaseObject example) {
        Set databaseObjects = knownNull.get(example.getClass());
        if (databaseObjects == null) {
            return false;
        }
        for (DatabaseObject obj : databaseObjects) {
            if (DatabaseObjectComparatorFactory.getInstance().isSameObject(obj, example, schemaComparisons, database)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public void load(ParsedNode parsedNode, ResourceAccessor resourceAccessor) throws ParsedNodeException {
        try {
            Map referencedObjects = new HashMap<>();
            Map objects = new HashMap<>();
            Map allObjects = new HashMap<>();
            ParsedNode databaseNode = parsedNode.getChild(null, "database");
            DatabaseConnection connection = getDatabase().getConnection();
            if ((databaseNode != null) && (connection instanceof OfflineConnection)) {
                ((OfflineConnection) connection).setDatabaseMajorVersion(databaseNode.getChildValue(null, "majorVersion", Integer.class));
                ((OfflineConnection) connection).setDatabaseMinorVersion(databaseNode.getChildValue(null, "minorVersion", Integer.class));
                ((OfflineConnection) connection).setProductVersion(databaseNode.getChildValue(null, "productVersion", String.class));
                ((OfflineConnection) connection).setConnectionUserName(databaseNode.getChildValue(null, "user", String.class));
            }

            loadObjects(referencedObjects, allObjects, parsedNode.getChild(null, "referencedObjects"), resourceAccessor);
            loadObjects(objects, allObjects, parsedNode.getChild(null, "objects"), resourceAccessor);

            for (DatabaseObject object : allObjects.values()) {
                for (String attr : new ArrayList<>(object.getAttributes())) {
                    Object value = object.getAttribute(attr, Object.class);
                    if ((value instanceof String) && allObjects.containsKey(value)) {
                        if (ObjectUtil.hasProperty(object, attr)) {
                            ObjectUtil.setProperty(object, attr, allObjects.get(value));
                        } else {
                            object.setAttribute(attr, allObjects.get(value));
                        }
                    } else if ((value instanceof Collection) && !((Collection) value).isEmpty() && allObjects
                            .containsKey(((Collection) value).iterator().next())) {
                        //
                        // This collection may contain both references (String with snapshotId)
                        // or an actual DatabaseObject. If the element is a reference String then
                        // we go retrieve the actual object, else we just add the object
                        //
                        List newList = new ArrayList<>();
                        for (Object element : (Collection)value) {
                            if (element instanceof String) {
                                newList.add(allObjects.get(element));
                            } else {
                                newList.add((DatabaseObject)element);
                            }
                        }
                        if (ObjectUtil.hasProperty(object, attr)) {
                            ObjectUtil.setProperty(object, attr, newList);
                        } else {
                            object.setAttribute(attr, newList);
                        }
                    } else {
                        if ((value != null) && ObjectUtil.hasProperty(object, attr)) {
                            if ((value instanceof byte[]) && ObjectUtil.getPropertyType(object, attr).equals(String
                                    .class)) {
                                value = new String((byte[]) value, GlobalConfiguration.OUTPUT_FILE_ENCODING.getCurrentValue());
                            }
                            object.setAttribute(attr, null);
                            ObjectUtil.setProperty(object, attr, value);
                        }
                    }
                }
            }

            for (DatabaseObject object : objects.values()) {
                this.allFound.add(object);
            }
            for (DatabaseObject object : referencedObjects.values()) {
                this.referencedObjects.add(object);
            }
        } catch (Exception e) {
            throw new ParsedNodeException(e);
        }
    }

    protected void loadObjects(Map objectMap, Map allObjects, ParsedNode node, ResourceAccessor resourceAccessor) throws ReflectiveOperationException, ParsedNodeException {
        if (node == null) {
            return;
        }
        for (ParsedNode typeNode : node.getChildren()) {
            Class objectType = (Class) Class.forName(typeNode.getName());
            for (ParsedNode objectNode : typeNode.getChildren()) {
                DatabaseObject databaseObject = objectType.getConstructor().newInstance();
                databaseObject.load(objectNode, resourceAccessor);
                String key = objectType.getName() + "#" + databaseObject.getSnapshotId();
                objectMap.put(key, databaseObject);
                allObjects.put(key, databaseObject);
            }
        }
    }

    @Override
    public ParsedNode serialize() {
        throw new RuntimeException("TODO");
    }

    /**
     * Used to get and store misc data that should be scoped to the snapshot. Helpful for caching snapshot results.
     *
     * @deprecated Will be removed with 4.0
     */
    public Object getScratchData(String key) {
        return snapshotScratchPad.get(key);
    }

    public Object setScratchData(String key, Object data) {
        return snapshotScratchPad.put(key, data);
    }

    public CompareControl.SchemaComparison[] getSchemaComparisons() {
        return schemaComparisons;
    }

    public void setSchemaComparisons(CompareControl.SchemaComparison[] schemaComparisons) {
        this.schemaComparisons = schemaComparisons;
    }

    public Map getMetadata() {
        return metadata;
    }

    public void setMetadata(Map metadata) {
        this.metadata = metadata;
    }
}