diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/DefaultLiquibaseConnectionProvider.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/DefaultLiquibaseConnectionProvider.java
new file mode 100644
index 0000000000..8b66d27b3f
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/DefaultLiquibaseConnectionProvider.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase;
+
+import java.sql.Connection;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import liquibase.Liquibase;
+import liquibase.change.ChangeFactory;
+import liquibase.database.Database;
+import liquibase.database.DatabaseFactory;
+import liquibase.database.jvm.JdbcConnection;
+import liquibase.datatype.DataTypeFactory;
+import liquibase.exception.LiquibaseException;
+import liquibase.resource.ClassLoaderResourceAccessor;
+import liquibase.resource.ResourceAccessor;
+import liquibase.sqlgenerator.SqlGeneratorFactory;
+import org.jboss.logging.Logger;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.map.storage.jpa.liquibase.extension.GeneratedColumnSqlGenerator;
+import org.keycloak.models.map.storage.jpa.liquibase.extension.CreateJsonIndexChange;
+import org.keycloak.models.map.storage.jpa.liquibase.extension.CreateJsonIndexGenerator;
+import org.keycloak.models.map.storage.jpa.liquibase.extension.GeneratedColumnChange;
+import org.keycloak.models.map.storage.jpa.liquibase.extension.JsonDataType;
+
+/**
+ * A {@link MapLiquibaseConnectionProvider} implementation for the map-jpa module. This provider registers the custom {@code Liquibase}
+ * changes and data types that were developed to better support working with data stored as JSON in the database.
+ *
+ * An instance of this provider can be obtained via {@link KeycloakSession#getProvider(Class)} as follows:
+ *
+ * MapLiquibaseConnectionProvider liquibaseProvider = session.getProvider(MapLiquibaseConnectionProvider.class);
+ *
+ *
+ * @author Stefan Guilhen
+ */
+public class DefaultLiquibaseConnectionProvider implements MapLiquibaseConnectionProvider {
+
+ private static final Logger logger = Logger.getLogger(DefaultLiquibaseConnectionProvider.class);
+
+ private static final AtomicBoolean INITIALIZED = new AtomicBoolean(false);
+
+ public DefaultLiquibaseConnectionProvider(final KeycloakSession session) {
+ if (! INITIALIZED.get()) {
+ // TODO: all liquibase providers should probably synchronize on the same object.
+ synchronized (INITIALIZED) {
+ if (! INITIALIZED.get()) {
+ initializeLiquibase();
+ INITIALIZED.set(true);
+ }
+ }
+ }
+ }
+
+ /**
+ * Registers the custom changes/types so we can work with data stored in JSON format.
+ */
+ protected void initializeLiquibase() {
+
+ // Add custom JSON data type
+ DataTypeFactory.getInstance().register(JsonDataType.class);
+
+ // Add custom change to generate columns from properties in JSON files stored in the DB.
+ ChangeFactory.getInstance().register(GeneratedColumnChange.class);
+ SqlGeneratorFactory.getInstance().register(new GeneratedColumnSqlGenerator());
+
+ // Add custom change to create indexes for properties in JSON files stored in the DB.
+ ChangeFactory.getInstance().register(CreateJsonIndexChange.class);
+ SqlGeneratorFactory.getInstance().register(new CreateJsonIndexGenerator());
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public Liquibase getLiquibaseForCustomUpdate(final Connection connection, final String defaultSchema, final String changelogLocation,
+ final ClassLoader classloader, final String changelogTableName) throws LiquibaseException {
+
+ Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
+ if (defaultSchema != null) {
+ database.setDefaultSchemaName(defaultSchema);
+ }
+ ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(classloader);
+ database.setDatabaseChangeLogTableName(changelogTableName);
+
+ logger.debugf("Using changelog file %s and changelogTableName %s", changelogLocation, database.getDatabaseChangeLogTableName());
+ return new Liquibase(changelogLocation, resourceAccessor, database);
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/DefaultLiquibaseConnectionProviderFactory.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/DefaultLiquibaseConnectionProviderFactory.java
new file mode 100644
index 0000000000..ea6b1f84a3
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/DefaultLiquibaseConnectionProviderFactory.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase;
+
+import org.keycloak.Config;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+
+/**
+ * {@link MapLiquibaseConnectionProviderFactory} implementation for the map-jpa module. It produces an instance of
+ * {@link DefaultLiquibaseConnectionProvider} when {@link #create(KeycloakSession)} is called.
+ *
+ * @author Stefan Guilhen
+ */
+public class DefaultLiquibaseConnectionProviderFactory implements MapLiquibaseConnectionProviderFactory {
+
+ public static final String PROVIDER_ID = "default";
+
+ @Override
+ public MapLiquibaseConnectionProvider create(KeycloakSession session) {
+ return new DefaultLiquibaseConnectionProvider(session);
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/updater/liquibase/MapJpaLiquibaseUpdaterProvider.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapJpaLiquibaseUpdaterProvider.java
similarity index 93%
rename from model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/updater/liquibase/MapJpaLiquibaseUpdaterProvider.java
rename to model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapJpaLiquibaseUpdaterProvider.java
index 743a39456d..04e3044b51 100644
--- a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/updater/liquibase/MapJpaLiquibaseUpdaterProvider.java
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapJpaLiquibaseUpdaterProvider.java
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-package org.keycloak.models.map.storage.jpa.updater.liquibase;
+package org.keycloak.models.map.storage.jpa.liquibase;
import java.io.File;
import java.io.FileWriter;
@@ -33,12 +33,10 @@ import liquibase.changelog.RanChangeSet;
import liquibase.exception.LiquibaseException;
import org.jboss.logging.Logger;
import org.keycloak.common.util.reflections.Reflections;
-import org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProvider;
import org.keycloak.connections.jpa.updater.liquibase.ThreadLocalSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.map.storage.ModelEntityUtil;
import org.keycloak.models.map.storage.jpa.updater.MapJpaUpdaterProvider;
-import org.keycloak.models.map.storage.jpa.updater.MapJpaUpdaterProvider.Status;
public class MapJpaLiquibaseUpdaterProvider implements MapJpaUpdaterProvider {
@@ -163,12 +161,12 @@ public class MapJpaLiquibaseUpdaterProvider implements MapJpaUpdaterProvider {
}
private Liquibase getLiquibase(Class modelType, Connection connection, String defaultSchema) throws LiquibaseException {
- LiquibaseConnectionProvider liquibaseProvider = session.getProvider(LiquibaseConnectionProvider.class);
- String changelog = "META-INF/jpa-" + ModelEntityUtil.getModelName(modelType) + "-changelog.xml";
- if (changelog == null) {
+ MapLiquibaseConnectionProvider liquibaseProvider = session.getProvider(MapLiquibaseConnectionProvider.class);
+ String modelName = ModelEntityUtil.getModelName(modelType);
+ if (modelName == null) {
throw new IllegalStateException("Cannot find changlelog for modelClass " + modelType.getName());
}
-
+ String changelog = "META-INF/jpa-" + modelName + "-changelog.xml";
return liquibaseProvider.getLiquibaseForCustomUpdate(connection, defaultSchema, changelog, this.getClass().getClassLoader(), "databasechangelog");
}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/updater/liquibase/MapJpaLiquibaseUpdaterProviderFactory.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapJpaLiquibaseUpdaterProviderFactory.java
similarity index 96%
rename from model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/updater/liquibase/MapJpaLiquibaseUpdaterProviderFactory.java
rename to model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapJpaLiquibaseUpdaterProviderFactory.java
index ca79063c3a..0e1f95cb4e 100644
--- a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/updater/liquibase/MapJpaLiquibaseUpdaterProviderFactory.java
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapJpaLiquibaseUpdaterProviderFactory.java
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-package org.keycloak.models.map.storage.jpa.updater.liquibase;
+package org.keycloak.models.map.storage.jpa.liquibase;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapLiquibaseConnectionProvider.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapLiquibaseConnectionProvider.java
new file mode 100644
index 0000000000..0c94c05a9e
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapLiquibaseConnectionProvider.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase;
+
+import java.sql.Connection;
+
+import liquibase.Liquibase;
+import liquibase.exception.LiquibaseException;
+import org.keycloak.provider.Provider;
+
+public interface MapLiquibaseConnectionProvider extends Provider {
+
+ Liquibase getLiquibaseForCustomUpdate(Connection connection, String defaultSchema, String changelogLocation, ClassLoader classloader, String changelogTableName) throws LiquibaseException;
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapLiquibaseConnectionProviderFactory.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapLiquibaseConnectionProviderFactory.java
new file mode 100644
index 0000000000..f813eb2c2d
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapLiquibaseConnectionProviderFactory.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase;
+
+import org.keycloak.provider.ProviderFactory;
+
+public interface MapLiquibaseConnectionProviderFactory extends ProviderFactory {
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapLiquibaseConnectionSpi.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapLiquibaseConnectionSpi.java
new file mode 100644
index 0000000000..621ab6e0ed
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/MapLiquibaseConnectionSpi.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase;
+
+import org.keycloak.provider.Provider;
+import org.keycloak.provider.ProviderFactory;
+import org.keycloak.provider.Spi;
+
+public class MapLiquibaseConnectionSpi implements Spi {
+
+ public final static String SPI_NAME = "mapLiquibaseConnection";
+
+ @Override
+ public boolean isInternal() {
+ return true;
+ }
+
+ @Override
+ public String getName() {
+ return SPI_NAME;
+ }
+
+ @Override
+ public Class extends Provider> getProviderClass() {
+ return MapLiquibaseConnectionProvider.class;
+ }
+
+ @Override
+ public Class extends ProviderFactory> getProviderFactoryClass() {
+ return MapLiquibaseConnectionProviderFactory.class;
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/CreateJsonIndexChange.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/CreateJsonIndexChange.java
new file mode 100644
index 0000000000..a98e878b4c
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/CreateJsonIndexChange.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase.extension;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import liquibase.change.AbstractChange;
+import liquibase.change.Change;
+import liquibase.change.ChangeMetaData;
+import liquibase.change.ChangeStatus;
+import liquibase.change.ChangeWithColumns;
+import liquibase.change.DatabaseChange;
+import liquibase.change.DatabaseChangeProperty;
+import liquibase.change.core.CreateIndexChange;
+import liquibase.change.core.DropIndexChange;
+import liquibase.database.Database;
+import liquibase.statement.SqlStatement;
+
+/**
+ * Extension used to create an index for properties of JSON files stored in the database. Some databases, like {@code Postgres},
+ * have native support for these indexes while other databases may require different constructs to achieve this (like creation
+ * of a separate column based on the JSON property and subsequent indexing of that column).
+ *
+ * Example configuration in the changelog:
+ *
+ * <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ * xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
+ * xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+ * http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd
+ * http://www.liquibase.org/xml/ns/dbchangelog-ext
+ * http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">
+ *
+ * <changeSet author="keycloak" id="some_id">
+ * ...
+ * <ext:createJsonIndex tableName="test" indexName="some_index_name">
+ * <ext:column jsonColumn="metadata" jsonProperty="name"/>
+ * </ext:createJsonIndex>
+ * </changeSet>
+ *
+ * The above configuration is creating an index for the {@code name} property of JSON files stored in column {@code metadata} in
+ * table {@code test}.
+ *
+ * @author Stefan Guilhen
+ */
+@DatabaseChange(name="createJsonIndex", description = "Creates an index for one or more JSON properties",
+ priority = ChangeMetaData.PRIORITY_DEFAULT, appliesTo = "index")
+public class CreateJsonIndexChange extends AbstractChange implements ChangeWithColumns {
+
+ private final CreateIndexChange delegate;
+
+ public CreateJsonIndexChange() {
+ this.delegate = new CreateIndexChange();
+ }
+
+ @DatabaseChangeProperty
+ public String getCatalogName() {
+ return delegate.getCatalogName();
+ }
+
+ public void setCatalogName(String catalogName) {
+ this.delegate.setCatalogName(catalogName);
+ }
+
+ @DatabaseChangeProperty(mustEqualExisting ="index.schema")
+ public String getSchemaName() {
+ return delegate.getSchemaName();
+ }
+
+ public void setSchemaName(String schemaName) {
+ this.delegate.setSchemaName(schemaName);
+ }
+
+ @DatabaseChangeProperty(mustEqualExisting = "index.table", description = "Name of the table to add the index to", exampleValue = "person")
+ public String getTableName() {
+ return this.delegate.getTableName();
+ }
+
+ public void setTableName(String tableName) {
+ this.delegate.setTableName(tableName);
+ }
+
+ @DatabaseChangeProperty(mustEqualExisting = "index", description = "Name of the index to create")
+ public String getIndexName() {
+ return this.delegate.getIndexName();
+ }
+
+ public void setIndexName(String indexName) {
+ this.delegate.setIndexName(indexName);
+ }
+
+ @DatabaseChangeProperty(description = "Tablepace to create the index in.")
+ public String getTablespace() {
+ return this.delegate.getTablespace();
+ }
+
+ public void setTablespace(String tablespace) {
+ this.delegate.setTablespace(tablespace);
+ }
+
+ @DatabaseChangeProperty(description = "Unique values index", since = "1.8")
+ public Boolean isUnique() {
+ return this.delegate.isUnique();
+ }
+
+ public void setUnique(Boolean isUnique) {
+ this.delegate.setUnique(isUnique);
+ }
+
+ @DatabaseChangeProperty(isChangeProperty = false)
+ public String getAssociatedWith() {
+ return delegate.getAssociatedWith();
+ }
+
+ public void setAssociatedWith(String associatedWith) {
+ this.delegate.setAssociatedWith(associatedWith);
+ }
+
+ @DatabaseChangeProperty
+ public Boolean getClustered() {
+ return this.delegate.getClustered();
+ }
+
+ public void setClustered(Boolean clustered) {
+ this.delegate.setClustered(clustered);
+ }
+
+ @Override
+ public void addColumn(JsonEnabledColumnConfig column) {
+ delegate.addColumn(column);
+ }
+
+ @Override
+ @DatabaseChangeProperty(mustEqualExisting = "index.column", description = "Column(s) to add to the index", requiredForDatabase = "all")
+ public List getColumns() {
+ return this.delegate.getColumns().stream().map(JsonEnabledColumnConfig.class::cast).collect(Collectors.toList());
+ }
+
+ @Override
+ public void setColumns(List columns) {
+ columns.forEach(this.delegate::addColumn);
+ }
+
+ @Override
+ public String getConfirmationMessage() {
+ return delegate.getConfirmationMessage();
+ }
+
+ @Override
+ public SqlStatement[] generateStatements(Database database) {
+ return new SqlStatement[]{new CreateJsonIndexStatement(this.getCatalogName(), this.getSchemaName(), this.getTableName(),
+ this.getIndexName(), this.isUnique(), this.getAssociatedWith(), this.getTablespace(), this.getClustered(),
+ this.getColumns().toArray(new JsonEnabledColumnConfig[0]))};
+ }
+
+ @Override
+ protected Change[] createInverses() {
+ DropIndexChange inverse = new DropIndexChange();
+ inverse.setSchemaName(getSchemaName());
+ inverse.setTableName(getTableName());
+ inverse.setIndexName(getIndexName());
+ return new Change[]{inverse};
+ }
+
+ @Override
+ public ChangeStatus checkStatus(Database database) {
+ return delegate.checkStatus(database);
+ }
+
+ @Override
+ public String getSerializedObjectNamespace() {
+ return GENERIC_CHANGELOG_EXTENSION_NAMESPACE;
+ }
+
+ @Override
+ public Object getSerializableFieldValue(String field) {
+ return delegate.getSerializableFieldValue(field);
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/CreateJsonIndexGenerator.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/CreateJsonIndexGenerator.java
new file mode 100644
index 0000000000..021fade57d
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/CreateJsonIndexGenerator.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase.extension;
+
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+import liquibase.database.Database;
+import liquibase.database.core.PostgresDatabase;
+import liquibase.exception.ValidationErrors;
+import liquibase.sql.Sql;
+import liquibase.sql.UnparsedSql;
+import liquibase.sqlgenerator.SqlGenerator;
+import liquibase.sqlgenerator.SqlGeneratorChain;
+import liquibase.sqlgenerator.core.AbstractSqlGenerator;
+import liquibase.statement.core.CreateIndexStatement;
+import liquibase.structure.core.Index;
+import liquibase.structure.core.Table;
+import liquibase.util.StringUtils;
+
+/**
+ * A {@link SqlGenerator} implementation that supports {@link CreateJsonIndexStatement}s. It generates the SQL required
+ * to create an index for properties of JSON files stored in one of the table columns.
+ *
+ * @author Stefan Guilhen
+ */
+public class CreateJsonIndexGenerator extends AbstractSqlGenerator {
+
+ /**
+ * Override the priority. This is needed because {@link CreateJsonIndexStatement} is a subtype of {@link CreateIndexStatement}
+ * and is thus a match for the standard index generators. By increasing the priority we ensure this is processed before
+ * the other generators.
+ *
+ * @return this generator's priority.
+ */
+ @Override
+ public int getPriority() {
+ return SqlGenerator.PRIORITY_DATABASE + 1;
+ }
+
+ @Override
+ public ValidationErrors validate(CreateJsonIndexStatement createIndexStatement, Database database, SqlGeneratorChain sqlGeneratorChain) {
+ ValidationErrors validationErrors = new ValidationErrors();
+ validationErrors.checkRequiredField("tableName", createIndexStatement.getTableName());
+ validationErrors.checkRequiredField("columns", createIndexStatement.getColumns());
+ Arrays.stream(createIndexStatement.getColumns()).map(JsonEnabledColumnConfig.class::cast)
+ .forEach(config -> {
+ validationErrors.checkRequiredField("jsonColumn", config.getJsonColumn());
+ validationErrors.checkRequiredField("jsonProperty", config.getJsonProperty());
+ });
+ return validationErrors;
+ }
+
+ @Override
+ public Sql[] generateSql(CreateJsonIndexStatement statement, Database database, SqlGeneratorChain sqlGeneratorChain) {
+
+ if (!(database instanceof PostgresDatabase)) {
+ // for now return an empty SQL for DBs that don't support JSON indexes natively.
+ return new Sql[0];
+ }
+
+ StringBuilder builder = new StringBuilder();
+ builder.append("CREATE ");
+ if (statement.isUnique() != null && statement.isUnique()) {
+ builder.append("UNIQUE ");
+ }
+ builder.append("INDEX ");
+
+ if (statement.getIndexName() != null) {
+ builder.append(database.escapeObjectName(statement.getIndexName(), Index.class)).append(" ");
+ }
+
+ builder.append("ON ").append(database.escapeTableName(statement.getTableCatalogName(), statement.getTableSchemaName(),
+ statement.getTableName()));
+ this.handleJsonIndex(statement, database, builder);
+ if (StringUtils.trimToNull(statement.getTablespace()) != null && database.supportsTablespaces()) {
+ builder.append(" TABLESPACE ").append(statement.getTablespace());
+ }
+
+ return new Sql[]{new UnparsedSql(builder.toString(), getAffectedIndex(statement))};
+ }
+
+ protected void handleJsonIndex(final CreateJsonIndexStatement statement, final Database database, final StringBuilder builder) {
+ if (database instanceof PostgresDatabase) {
+ builder.append(" USING gin (");
+ builder.append(Arrays.stream(statement.getColumns()).map(JsonEnabledColumnConfig.class::cast)
+ .map(c -> "(" + c.getJsonColumn() + "->'" + c.getJsonProperty() + "') jsonb_path_ops")
+ .collect(Collectors.joining(", ")))
+ .append(")");
+ }
+ }
+
+ protected Index getAffectedIndex(CreateIndexStatement statement) {
+ return new Index().setName(statement.getIndexName()).setTable((Table) new Table().setName(statement.getTableName())
+ .setSchema(statement.getTableCatalogName(), statement.getTableSchemaName()));
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/CreateJsonIndexStatement.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/CreateJsonIndexStatement.java
new file mode 100644
index 0000000000..311df28249
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/CreateJsonIndexStatement.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase.extension;
+
+import liquibase.change.AddColumnConfig;
+import liquibase.statement.core.CreateIndexStatement;
+
+/**
+ * A {@link liquibase.statement.SqlStatement} that holds the information needed to create JSON indexes. Having a specific
+ * subtype allows for easier selection of the respective {@link liquibase.sqlgenerator.SqlGenerator}, since Liquibase
+ * selects the generators based on the statement type they are capable of handling.
+ *
+ * @author Stefan Guilhen
+ */
+public class CreateJsonIndexStatement extends CreateIndexStatement {
+
+ public CreateJsonIndexStatement(final String tableCatalogName, final String tableSchemaName, final String tableName,
+ final String indexName, final Boolean isUnique, final String associatedWith,
+ final String tablespace, final Boolean clustered, final AddColumnConfig... columns) {
+ super(indexName, tableCatalogName, tableSchemaName, tableName, isUnique, associatedWith, columns);
+ super.setTablespace(tablespace);
+ super.setClustered(clustered);
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/GeneratedColumnChange.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/GeneratedColumnChange.java
new file mode 100644
index 0000000000..048adca783
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/GeneratedColumnChange.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase.extension;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import liquibase.change.AbstractChange;
+import liquibase.change.AddColumnConfig;
+import liquibase.change.Change;
+import liquibase.change.ChangeMetaData;
+import liquibase.change.ChangeStatus;
+import liquibase.change.ChangeWithColumns;
+import liquibase.change.ColumnConfig;
+import liquibase.change.DatabaseChange;
+import liquibase.change.DatabaseChangeProperty;
+import liquibase.change.core.AddColumnChange;
+import liquibase.database.Database;
+import liquibase.database.core.PostgresDatabase;
+import liquibase.exception.ValidationErrors;
+import liquibase.statement.SqlStatement;
+import liquibase.statement.core.AddColumnStatement;
+
+/**
+ * Extension used to a column whose values are generated from a property of a JSON file stored in one of the table's columns.
+ *
+ * Example configuration in the changelog:
+ *
+ * <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ * xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
+ * xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+ * http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd
+ * http://www.liquibase.org/xml/ns/dbchangelog-ext
+ * http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">
+ *
+ * <changeSet author="keycloak" id="some_id">
+ * ...
+ * <ext:addGeneratedColumn tableName="test">
+ * <ext:column name="new_column" type="VARCHAR(36)" jsonColumn="metadata" jsonProperty="alias"/>
+ * </ext:addGeneratedColumn>
+ * </changeSet>
+ *
+ * The above configuration is adding a new column, named {@code new_column}, whose values are generated from the {@code alias} property
+ * of the JSON file stored in column {@code metadata}. If, for example, a particular entry in the table contains the JSON
+ * {@code {"name":"duke","alias":"jduke"}} in column {@code metadata}, the value generated for the new column will be {@code jduke}.
+ *
+ * @author Stefan Guilhen
+ */
+@DatabaseChange(name = "addGeneratedColumn", description = "Adds new generated columns to a table. The columns must reference a JSON property inside an existing JSON column.",
+ priority = ChangeMetaData.PRIORITY_DEFAULT, appliesTo = "table")
+public class GeneratedColumnChange extends AbstractChange implements ChangeWithColumns {
+
+ private final ExtendedAddColumnChange delegate;
+ private Map configMap = new HashMap<>();
+
+ public GeneratedColumnChange() {
+ this.delegate = new ExtendedAddColumnChange();
+ }
+
+ @DatabaseChangeProperty(mustEqualExisting ="relation.catalog")
+ public String getCatalogName() {
+ return this.delegate.getCatalogName();
+ }
+
+ public void setCatalogName(final String catalogName) {
+ this.delegate.setCatalogName(catalogName);
+ }
+
+ @DatabaseChangeProperty(mustEqualExisting ="relation.schema")
+ public String getSchemaName() {
+ return this.delegate.getSchemaName();
+ }
+
+ public void setSchemaName(final String schemaName) {
+ this.delegate.setSchemaName(schemaName);
+ }
+
+ @DatabaseChangeProperty(mustEqualExisting ="table", description = "Name of the table to add the generated column to")
+ public String getTableName() {
+ return this.delegate.getTableName();
+ }
+
+ public void setTableName(final String tableName) {
+ this.delegate.setTableName(tableName);
+ }
+
+ @Override
+ public void addColumn(final JsonEnabledColumnConfig column) {
+ this.delegate.addColumn(column);
+ this.configMap.put(column.getName(), column);
+ }
+
+ @Override
+ @DatabaseChangeProperty(description = "Generated columns information", requiredForDatabase = "all")
+ public List getColumns() {
+ return this.delegate.getColumns().stream().map(JsonEnabledColumnConfig.class::cast).collect(Collectors.toList());
+ }
+
+ @Override
+ public void setColumns(final List columns) {
+ columns.forEach(this.delegate::addColumn);
+ this.configMap = this.getColumns().stream()
+ .collect(Collectors.toMap(ColumnConfig::getName, Function.identity()));
+ }
+
+ @Override
+ public SqlStatement[] generateStatements(Database database) {
+ if (database instanceof PostgresDatabase) {
+ for (AddColumnConfig config : delegate.getColumns()) {
+ String columnType = config.getType();
+ // if postgres, change JSON type to JSONB before generating the statements as JSONB is more efficient.
+ if (columnType.equalsIgnoreCase("JSON")) {
+ config.setType("JSONB");
+ }
+ }
+ }
+
+ // AddColumnChange always produces an AddColumnStatement in the first position of the returned array.
+ AddColumnStatement delegateStatement = (AddColumnStatement) Arrays.stream(this.delegate.generateStatements(database))
+ .findFirst().get();
+
+ // convert the regular AddColumnStatements into GeneratedColumnStatements, adding the extension properties.
+ if (!delegateStatement.isMultiple()) {
+ // single statement - convert it directly.
+ JsonEnabledColumnConfig config = configMap.get(delegateStatement.getColumnName());
+ if (config != null) {
+ return new SqlStatement[] {new GeneratedColumnStatement(delegateStatement, config.getJsonColumn(),
+ config.getJsonProperty())};
+ }
+ }
+ else {
+ // multiple statement - convert all sub-statements.
+ List generatedColumnStatements = delegateStatement.getColumns().stream()
+ .filter(c -> configMap.containsKey(c.getColumnName()))
+ .map(c -> new GeneratedColumnStatement(c, configMap.get(c.getColumnName()).getJsonColumn(),
+ configMap.get(c.getColumnName()).getJsonProperty()))
+ .collect(Collectors.toList());
+
+ // add all GeneratedColumnStatements into a composite statement and return the composite.
+ return new SqlStatement[]{new GeneratedColumnStatement(generatedColumnStatements)};
+ }
+ return new SqlStatement[0];
+ }
+
+ @Override
+ protected Change[] createInverses() {
+ return this.delegate.createInverses();
+ }
+
+ @Override
+ public ChangeStatus checkStatus(Database database) {
+ return delegate.checkStatus(database);
+ }
+
+ @Override
+ public String getConfirmationMessage() {
+ return delegate.getConfirmationMessage();
+ }
+
+ @Override
+ public String getSerializedObjectNamespace() {
+ return GENERIC_CHANGELOG_EXTENSION_NAMESPACE;
+ }
+
+ @Override
+ public ValidationErrors validate(Database database) {
+ ValidationErrors validationErrors = new ValidationErrors();
+ validationErrors.checkRequiredField("columns", this.delegate.getColumns());
+ // validate each generated column.
+ this.delegate.getColumns().stream().map(JsonEnabledColumnConfig.class::cast).forEach(
+ config -> {
+ if (config.isAutoIncrement() != null && config.isAutoIncrement()) {
+ validationErrors.addError("Generated column " + config.getName() + " cannot be auto-incremented");
+ } else if (config.getValueObject() != null) {
+ validationErrors.addError("Generated column " + config.getName() + " cannot be configured with a value");
+ } else if (config.getDefaultValueObject() != null) {
+ validationErrors.addError("Generated column " + config.getName() + " cannot be configured with a default value");
+ }
+ // we can expand this check if we decide to allow other types of generated columns in the future - for now
+ // ensure the column can be properly generated from a json property stored on a json column.
+ validationErrors.checkRequiredField("jsonColumn", config.getJsonColumn());
+ validationErrors.checkRequiredField("jsonProperty", config.getJsonProperty());
+ });
+ validationErrors.addAll(super.validate(database));
+ return validationErrors;
+ }
+
+ /**
+ * Simple extension that makes protected methods public so they can be accessed as a delegate.
+ */
+ private static class ExtendedAddColumnChange extends AddColumnChange {
+ @Override
+ public Change[] createInverses() {
+ return super.createInverses();
+ }
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/GeneratedColumnSqlGenerator.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/GeneratedColumnSqlGenerator.java
new file mode 100644
index 0000000000..b953db726b
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/GeneratedColumnSqlGenerator.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase.extension;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import liquibase.database.Database;
+import liquibase.database.core.PostgresDatabase;
+import liquibase.sql.Sql;
+import liquibase.sql.UnparsedSql;
+import liquibase.sqlgenerator.SqlGenerator;
+import liquibase.sqlgenerator.core.AddColumnGenerator;
+import liquibase.statement.core.AddColumnStatement;
+
+/**
+ * A {@link SqlGenerator} implementation that supports {@link GeneratedColumnStatement}s. It generates the SQL required
+ * to add a column whose values are generated from a property of a JSON file stored in one of the table columns.
+ *
+ * @author Stefan Guilhen
+ */
+public class GeneratedColumnSqlGenerator extends AddColumnGenerator {
+
+ /**
+ * Override the priority. This is needed because {@link GeneratedColumnStatement} is a subtype of {@link AddColumnStatement}
+ * and is thus a match for the standard column generators. By increasing the priority we ensure this is processed before
+ * the other generators.
+ *
+ * @return this generator's priority.
+ */
+ @Override
+ public int getPriority() {
+ return SqlGenerator.PRIORITY_DEFAULT + 1;
+ }
+
+ /**
+ * Implement {@link #supports(AddColumnStatement, Database)} to return {@code true} only if the statement type is an instance
+ * of {@link GeneratedColumnStatement}.
+ *
+ * This is needed because this generator is a sub-class of {@link AddColumnGenerator} and is thus registered as being
+ * able to handle statements of type {@link AddColumnStatement}. Due to the increased priority, this generator ends up
+ * being selected to handle standard {@code addColumn} changes, which is not desirable. By returning {@code true} only
+ * when the statement is a {@link GeneratedColumnStatement} we ensure this implementation is selected only when a generated
+ * column is being added, allowing liquibase to continue iterating through the chain of generators in order to select the
+ * right generator to handle the standard {@code addColumn} changes.
+ *
+ * @param statement the {@link liquibase.statement.SqlStatement} to be processed.
+ * @param database a reference to the database.
+ * @return {@code true} if an only if the statement is a {@link GeneratedColumnStatement}; {@code false} otherwise.
+ */
+ @Override
+ public boolean supports(AddColumnStatement statement, Database database) {
+ // use this implementation for generated columns only.
+ return statement instanceof GeneratedColumnStatement;
+ }
+
+ @Override
+ protected Sql[] generateSingleColumn(final AddColumnStatement statement, final Database database) {
+
+ StringBuilder sqlBuilder = new StringBuilder();
+ sqlBuilder.append(super.generateSingleColumBaseSQL(statement, database));
+ sqlBuilder.append(super.generateSingleColumnSQL(statement, database));
+ this.handleGeneratedColumn((GeneratedColumnStatement) statement, database, sqlBuilder);
+
+ List returnSql = new ArrayList<>();
+ returnSql.add(new UnparsedSql(sqlBuilder.toString(), super.getAffectedColumn(statement)));
+
+ super.addUniqueConstrantStatements(statement, database, returnSql);
+ super.addForeignKeyStatements(statement, database, returnSql);
+
+ return returnSql.toArray(new Sql[0]);
+ }
+
+ protected void handleGeneratedColumn(final GeneratedColumnStatement statement, final Database database, final StringBuilder sqlBuilder) {
+ if (database instanceof PostgresDatabase) {
+ // assemble the GENERATED ALWAYS AS section of the query using the json property selection function.
+ sqlBuilder.append(" GENERATED ALWAYS AS ((").append(statement.getJsonColumn()).append("->>'").append(statement.getJsonProperty())
+ .append("')::").append(statement.getColumnType()).append(") stored");
+ }
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/GeneratedColumnStatement.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/GeneratedColumnStatement.java
new file mode 100644
index 0000000000..3a645b2185
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/GeneratedColumnStatement.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase.extension;
+
+import java.util.List;
+
+import liquibase.statement.ColumnConstraint;
+import liquibase.statement.core.AddColumnStatement;
+
+/**
+ * A {@link liquibase.statement.SqlStatement} that extends the standard {@link AddColumnStatement} to include properties
+ * to identify the JSON column and JSON property that are to be used to generated the values for the column being added.
+ *
+ * @author Stefan Guilhen
+ */
+public class GeneratedColumnStatement extends AddColumnStatement {
+
+ private String jsonColumn;
+ private String jsonProperty;
+
+ public GeneratedColumnStatement(final AddColumnStatement statement, final String jsonColumn, final String jsonProperty) {
+ super(statement.getCatalogName(), statement.getSchemaName(), statement.getTableName(), statement.getColumnName(),
+ statement.getColumnType(), statement.getDefaultValue(), statement.getRemarks(),
+ statement.getConstraints().toArray(new ColumnConstraint[0]));
+ this.jsonColumn = jsonColumn;
+ this.jsonProperty = jsonProperty;
+ }
+
+ public GeneratedColumnStatement(final List statements) {
+ super(statements.toArray(new GeneratedColumnStatement[0]));
+ }
+
+ /**
+ * Obtains the name of the column that holds JSON files.
+ *
+ * @return the name of the JSON column.
+ */
+ public String getJsonColumn() {
+ return this.jsonColumn;
+ }
+
+ /**
+ * Obtains the name of the property in the JSON file whose value is to be used as the generated value for the new column.
+ *
+ * @return the name of the JSON property.
+ */
+ public String getJsonProperty() {
+ return this.jsonProperty;
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/JsonDataType.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/JsonDataType.java
new file mode 100644
index 0000000000..8e6a10796f
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/JsonDataType.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase.extension;
+
+import liquibase.database.Database;
+import liquibase.database.core.PostgresDatabase;
+import liquibase.datatype.DataTypeInfo;
+import liquibase.datatype.DatabaseDataType;
+import liquibase.datatype.LiquibaseDataType;
+
+/**
+ * A {@link LiquibaseDataType} to handle the JSON column type.
+ *
+ * @author Stefan Guilhen
+ */
+@DataTypeInfo(name="json", minParameters = 0, maxParameters = 1, priority = LiquibaseDataType.PRIORITY_DEFAULT + 1)
+public class JsonDataType extends LiquibaseDataType {
+
+ @Override
+ public DatabaseDataType toDatabaseDataType(Database database) {
+ if (database instanceof PostgresDatabase) {
+ // on Postgres switch the columns of type JSON to JSONB as JSONB is a more efficient type to handle JSON contents.
+ return new DatabaseDataType("JSONB", super.getParameters());
+ }
+ return super.toDatabaseDataType(database);
+ }
+}
diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/JsonEnabledColumnConfig.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/JsonEnabledColumnConfig.java
new file mode 100644
index 0000000000..2b261ba910
--- /dev/null
+++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/extension/JsonEnabledColumnConfig.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2021 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.map.storage.jpa.liquibase.extension;
+
+import liquibase.change.AddColumnConfig;
+import liquibase.parser.core.ParsedNode;
+import liquibase.parser.core.ParsedNodeException;
+import liquibase.resource.ResourceAccessor;
+
+/**
+ * A {@link liquibase.change.ColumnConfig} extension that contains attributes to specify a JSON column and the property
+ * to be selected from the JSON file.
+ *
+ * This config is used by extensions that need to operated on data stored in JSON columns.
+ *
+ * @author Stefan Guilhen
+ */
+public class JsonEnabledColumnConfig extends AddColumnConfig {
+
+ private String jsonColumn;
+ private String jsonProperty;
+
+ /**
+ * Obtains the name of the column that contains JSON files.
+ *
+ * @return the JSON column name.
+ */
+ public String getJsonColumn() {
+ return this.jsonColumn;
+ }
+
+ /**
+ * Sets the name of the column that contains JSON files.
+ *
+ * @param jsonColumn the name of the JSON column.
+ */
+ public void setJsonColumn(final String jsonColumn) {
+ this.jsonColumn = jsonColumn;
+ }
+
+ /**
+ * Obtains the name of the property inside the JSON file.
+ *
+ * @return the name of the JSON property.
+ */
+ public String getJsonProperty() {
+ return this.jsonProperty;
+ }
+
+ /**
+ * Sets the name of the property inside the JSON file.
+ *
+ * @param jsonProperty the name of the JSON property.
+ */
+ public void setJsonProperty(final String jsonProperty) {
+ this.jsonProperty = jsonProperty;
+ }
+
+ @Override
+ public void load(ParsedNode parsedNode, ResourceAccessor resourceAccessor) throws ParsedNodeException {
+ // load the standard column attributs and then load the JSON attributes.
+ super.load(parsedNode, resourceAccessor);
+ this.jsonColumn = parsedNode.getChildValue(null, "jsonColumn", String.class);
+ this.jsonProperty = parsedNode.getChildValue(null, "jsonProperty", String.class);
+ }
+}
diff --git a/model/map-jpa/src/main/resources/META-INF/jpa-clients-changelog-1.xml b/model/map-jpa/src/main/resources/META-INF/jpa-clients-changelog-1.xml
index bcdd0299e8..fe1271093d 100644
--- a/model/map-jpa/src/main/resources/META-INF/jpa-clients-changelog-1.xml
+++ b/model/map-jpa/src/main/resources/META-INF/jpa-clients-changelog-1.xml
@@ -17,37 +17,58 @@ limitations under the License.
-->
-
+
-
- create table client (
- id uuid primary key not null,
- entityVersion integer generated always as ((metadata->>'entityVersion')::int) stored,
- realmId varchar(36) generated always as (metadata->>'fRealmId') stored,
- clientId varchar(255) generated always as (metadata->>'fClientId') stored,
- protocol varchar(36) generated always as (metadata->>'fProtocol') stored,
- enabled boolean generated always as ((metadata->>'fEnabled')::boolean) stored,
- metadata jsonb
- );
-
- create index client_entityVersion on client(entityVersion);
- create index client_realmId_clientId on client(realmId, clientId);
- create index client_scopeMappings on client using gin ((metadata->'fScopeMappings') jsonb_path_ops);
-
- create table client_attribute (
- id uuid primary key not null,
- fk_client uuid references client(id) on delete cascade,
- name varchar(255),
- value text
- );
-
- create index client_attr_fk_client on client_attribute(fk_client);
- create index client_attr_name_value on client_attribute(name, (value::varchar(250)));
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.models.map.storage.jpa.liquibase.MapLiquibaseConnectionProviderFactory b/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.models.map.storage.jpa.liquibase.MapLiquibaseConnectionProviderFactory
new file mode 100644
index 0000000000..b2a8631e9f
--- /dev/null
+++ b/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.models.map.storage.jpa.liquibase.MapLiquibaseConnectionProviderFactory
@@ -0,0 +1,18 @@
+#
+# Copyright 2016 Red Hat, Inc. and/or its affiliates
+# and other contributors as indicated by the @author tags.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+org.keycloak.models.map.storage.jpa.liquibase.DefaultLiquibaseConnectionProviderFactory
\ No newline at end of file
diff --git a/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.models.map.storage.jpa.updater.MapJpaUpdaterProviderFactory b/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.models.map.storage.jpa.updater.MapJpaUpdaterProviderFactory
index 84cb2376bd..d6d2dfb6d6 100644
--- a/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.models.map.storage.jpa.updater.MapJpaUpdaterProviderFactory
+++ b/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.models.map.storage.jpa.updater.MapJpaUpdaterProviderFactory
@@ -15,4 +15,4 @@
# limitations under the License.
#
-org.keycloak.models.map.storage.jpa.updater.liquibase.MapJpaLiquibaseUpdaterProviderFactory
+org.keycloak.models.map.storage.jpa.liquibase.MapJpaLiquibaseUpdaterProviderFactory
diff --git a/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.provider.Spi
index a6ae8657b7..9c0a2fe0dd 100644
--- a/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.provider.Spi
+++ b/model/map-jpa/src/main/resources/META-INF/services/org.keycloak.provider.Spi
@@ -16,3 +16,4 @@
#
org.keycloak.models.map.storage.jpa.updater.MapJpaUpdaterSpi
+org.keycloak.models.map.storage.jpa.liquibase.MapLiquibaseConnectionSpi