From 390dd535dd9b0471947e2e88bfd8b8f82be84fa4 Mon Sep 17 00:00:00 2001 From: jacob-xhio <163027616+jacob-xhio@users.noreply.github.com> Date: Fri, 22 Mar 2024 15:02:26 -0700 Subject: [PATCH 1/3] Track Service Refactor (#343) --- .../admin/TrackLogAdminController.groovy | 62 ++---- .../hoist/track/TrackLogAdminService.groovy | 65 ++++++ .../hoist/data/filter/CompoundFilter.groovy | 62 ++++++ .../xh/hoist/data/filter/FieldFilter.groovy | 205 ++++++++++++++++++ .../io/xh/hoist/data/filter/Filter.groovy | 112 ++++++++++ .../hoist/data/filter/FunctionFilter.groovy | 45 ++++ 6 files changed, 504 insertions(+), 47 deletions(-) create mode 100644 grails-app/services/io/xh/hoist/track/TrackLogAdminService.groovy create mode 100644 src/main/groovy/io/xh/hoist/data/filter/CompoundFilter.groovy create mode 100644 src/main/groovy/io/xh/hoist/data/filter/FieldFilter.groovy create mode 100644 src/main/groovy/io/xh/hoist/data/filter/Filter.groovy create mode 100644 src/main/groovy/io/xh/hoist/data/filter/FunctionFilter.groovy diff --git a/grails-app/controllers/io/xh/hoist/admin/TrackLogAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/TrackLogAdminController.groovy index 7ad6597d..a17eabb3 100644 --- a/grails-app/controllers/io/xh/hoist/admin/TrackLogAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/TrackLogAdminController.groovy @@ -7,65 +7,33 @@ package io.xh.hoist.admin -import grails.gorm.transactions.ReadOnly import io.xh.hoist.BaseController +import io.xh.hoist.data.filter.Filter import io.xh.hoist.security.Access -import io.xh.hoist.track.TrackLog -import io.xh.hoist.track.TrackService +import io.xh.hoist.track.TrackLogAdminService + +import java.time.LocalDate + +import static io.xh.hoist.util.DateTimeUtils.appDay +import static io.xh.hoist.util.DateTimeUtils.parseLocalDate -import static io.xh.hoist.util.DateTimeUtils.* -import static java.lang.Integer.parseInt @Access(['HOIST_ADMIN_READER']) class TrackLogAdminController extends BaseController { - TrackService trackService + TrackLogAdminService trackLogAdminService - @ReadOnly def index() { - if (!trackService.enabled) { - renderJSON([]) - } - - def startDay = parseLocalDate(params.startDay), - endDay = parseLocalDate(params.endDay) - - // NOTE that querying + serializing large numbers of TrackLogs below requires a significant - // allocation of memory. Be mindful if customizing maxRow-related configs above defaults! - def conf = trackService.conf, - maxDefault = conf.maxRows.default as Integer, - maxLimit = conf.maxRows.limit as Integer, - maxRows = [(params.maxRows ? parseInt(params.maxRows) : maxDefault), maxLimit].min() + def query = parseRequestJSON(), + startDay = query.startDay ? parseLocalDate(query.startDay) : LocalDate.of(1970, 1, 1), + endDay = query.endDay ? parseLocalDate(query.endDay) : appDay(), + filter = Filter.parse(query.filters), + maxRows = query.maxRows - def results = TrackLog.findAll(max: maxRows, sort: 'dateCreated', order: 'desc') { - if (startDay) dateCreated >= appStartOfDay(startDay) - if (endDay) dateCreated <= appEndOfDay(endDay) - if (params.category) category =~ "%$params.category%" - if (params.username) username =~ "%$params.username%" - if (params.browser) browser =~ "%$params.browser%" - if (params.device) device =~ "%$params.device%" - if (params.msg) msg =~ "%$params.msg%" - } - - renderJSON(results) + renderJSON(trackLogAdminService.queryTrackLog(startDay, endDay, filter, maxRows)) } def lookups() { - renderJSON([ - categories: distinctVals('category'), - browsers: distinctVals('browser'), - devices: distinctVals('device'), - usernames: distinctVals('username'), - ]) - } - - //------------------------ - // Implementation - //------------------------ - private List distinctVals(String property) { - return TrackLog.createCriteria().list { - projections { distinct(property) } - }.sort() + renderJSON(trackLogAdminService.lookups()) } - } diff --git a/grails-app/services/io/xh/hoist/track/TrackLogAdminService.groovy b/grails-app/services/io/xh/hoist/track/TrackLogAdminService.groovy new file mode 100644 index 00000000..541d25a4 --- /dev/null +++ b/grails-app/services/io/xh/hoist/track/TrackLogAdminService.groovy @@ -0,0 +1,65 @@ +package io.xh.hoist.track + +import grails.gorm.transactions.ReadOnly +import io.xh.hoist.BaseService; +import io.xh.hoist.config.ConfigService +import io.xh.hoist.data.filter.Filter +import io.xh.hoist.exception.DataNotAvailableException +import org.hibernate.Criteria +import org.hibernate.SessionFactory +import java.time.LocalDate; + +import static io.xh.hoist.util.DateTimeUtils.appEndOfDay +import static io.xh.hoist.util.DateTimeUtils.appStartOfDay +import static org.hibernate.criterion.Order.desc +import static org.hibernate.criterion.Restrictions.between + +class TrackLogAdminService extends BaseService { + ConfigService configService + SessionFactory sessionFactory + + Boolean getEnabled() { + return conf.enabled == true + } + + @ReadOnly + List queryTrackLog(LocalDate startDay, LocalDate endDay, Filter filter, Integer maxRows = null) { + if (!enabled) throw new DataNotAvailableException('TrackService not available.') + + def maxDefault = conf.maxRows.default as Integer, + maxLimit = conf.maxRows.limit as Integer + + maxRows = [(maxRows ? maxRows : maxDefault), maxLimit].min() + + def session = sessionFactory.currentSession + Criteria c = session.createCriteria(TrackLog) + c.maxResults = maxRows + c.addOrder(desc('dateCreated')) + c.add(between('dateCreated', appStartOfDay(startDay), appEndOfDay(endDay))) + if (filter) { + c.add(filter.criterion) + } + c.list() as List + } + + @ReadOnly + Map lookups() {[ + category: distinctVals('category'), + browser: distinctVals('browser'), + device: distinctVals('device'), + username: distinctVals('username') + ] } + + //------------------------ + // Implementation + //------------------------ + private List distinctVals(String property) { + TrackLog.createCriteria().list { + projections { distinct(property) } + }.sort() + } + + private Map getConf() { + configService.getMap('xhActivityTrackingConfig') + } +} diff --git a/src/main/groovy/io/xh/hoist/data/filter/CompoundFilter.groovy b/src/main/groovy/io/xh/hoist/data/filter/CompoundFilter.groovy new file mode 100644 index 00000000..a2c21c8b --- /dev/null +++ b/src/main/groovy/io/xh/hoist/data/filter/CompoundFilter.groovy @@ -0,0 +1,62 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.data.filter + +import io.xh.hoist.json.JSONFormat +import org.hibernate.criterion.Criterion + +/** + * Combines multiple filters (including other nested CompoundFilters) via an AND or OR operator. + */ +class CompoundFilter extends Filter implements JSONFormat { + + final List filters + final String op + + CompoundFilter(List filters, String op) { + op = op ? op.toUpperCase() : 'AND' + if (op != 'AND' && op != 'OR') throw new RuntimeException('CompoundFilter requires "op" value of "AND" or "OR"') + this.filters = filters.collect { parse(it) }.findAll() + this.op = op + } + + Map formatForJSON() { + return [ + filters: filters, + op: op + ] + } + + //--------------------- + // Overrides + //---------------------- + List getAllFields() { + filters.collectMany { it.allFields }.unique() + } + + Criterion getCriterion() { + op == 'AND' ? and(filters*.criterion) : or(filters*.criterion) + } + + Closure getTestFn() { + if (!filters) return { true } + def tests = filters*.testFn + return op == 'AND' ? + { tests.every { test -> test(it) } } : + { tests.any { test -> test(it) } } + } + + boolean equals(Filter other) { + if (other === this) return true; + return ( + other instanceof CompoundFilter && + other.op == op && + other.filters == filters + ) + } +} diff --git a/src/main/groovy/io/xh/hoist/data/filter/FieldFilter.groovy b/src/main/groovy/io/xh/hoist/data/filter/FieldFilter.groovy new file mode 100644 index 00000000..a4e8d592 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/data/filter/FieldFilter.groovy @@ -0,0 +1,205 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.data.filter + +import io.xh.hoist.json.JSONFormat +import org.hibernate.criterion.Criterion +import org.hibernate.criterion.Restrictions + +import static org.hibernate.criterion.MatchMode.ANYWHERE +import static org.hibernate.criterion.MatchMode.END +import static org.hibernate.criterion.MatchMode.START +import static org.hibernate.criterion.Restrictions.ge +import static org.hibernate.criterion.Restrictions.gt +import static org.hibernate.criterion.Restrictions.ilike +import static org.hibernate.criterion.Restrictions.le +import static org.hibernate.criterion.Restrictions.lt +import static org.hibernate.criterion.Restrictions.ne +import static org.hibernate.criterion.Restrictions.not + +/** + * Filters by comparing the value of a given field to one or more given candidate values using a given operator. + * Note that the comparison operators `[<,<=,>,>=]` always return false for null values (as with Excel filtering). + */ +class FieldFilter extends Filter implements JSONFormat { + + final String field + final String op + final Object value + + /** All available operators. */ + static OPERATORS = ['=', '!=', '>', '>=', '<', '<=', 'like', 'not like', 'begins', 'ends', 'includes', 'excludes'] + /** All operators that support testing multiple candidate `value`s (where this.value *can be* a collection). */ + static MULTI_VAL_OPERATORS = ['=', '!=', 'like', 'not like', 'begins', 'ends', 'includes', 'excludes'] + + FieldFilter(String field, String op, Object value) { + if (!field) { + throw new IllegalArgumentException('FieldFilter requires a field') + } + + if (!OPERATORS.contains(op)) { + throw new IllegalArgumentException("FieldFilter requires valid 'op' value. Operator '$op' not recognized.") + } + + if (!MULTI_VAL_OPERATORS.contains(op) && value instanceof Collection) { + throw new IllegalArgumentException("Operator '$op' does not support multiple values. Use a CompoundFilter instead.") + } + + this.field = field + this.op = op + this.value = value instanceof Collection ? new ArrayList(value).unique().sort() : value + } + + Map formatForJSON() { + return [ + field: field, + op: op, + value: value + ] + } + + + //--------------------- + // Overrides + //---------------------- + List getAllFields() { + return [field] + } + + Criterion getCriterion() { + def vals = MULTI_VAL_OPERATORS.contains(op) ? + (value instanceof List ? value : [value]) : + null + + switch (op) { + case '=': + Criterion c = Restrictions.in(field, vals.findAll { it != null }) + if (vals.contains(null)) + return or([Restrictions.isNull(field), c]) + return c + case '!=': + Criterion c = and(vals.findAll { it != null }.collect { ne(field, it) }) + if (vals.contains(null)) + return and([Restrictions.isNotNull(field), c]) + return c + case '>': + return gt(field, value) + case '>=': + return ge(field, value) + case '<': + return lt(field, value) + case '<=': + return le(field, value) + case 'like': + return or(vals.collect { ilike(field, it as String, ANYWHERE) }) + case 'not like': + return and(vals.collect { not(ilike(field, it as String, ANYWHERE)) }) + case 'begins': + return or(vals.collect { ilike(field, it as String, START) }) + case 'ends': + return or(vals.collect { ilike(field, it as String, END) }) + case 'includes': + case 'excludes': + throw new RuntimeException('Unsupported operator for Criteria Filter') + default: + throw new RuntimeException("Unknown operator: $op") + } + } + + + Closure getTestFn() { + def vals = MULTI_VAL_OPERATORS.contains(op) ? + (value instanceof List ? value : [value]) : + null + + switch (op) { + case '=': + return { + def v = it[field] + if (v == '') v = null + return vals.any { it == v } + } + case '!=': + return { + def v = it[field] + if (v == '') v = null + return vals.every { it != v } + } + case '>': + return { + def v = it[field] + return v != null && v > value + } + case '>=': + return { + def v = it[field] + return v != null && v >= value + } + case '<': + return { + def v = it[field] + return v != null && v < value + } + case '<=': + return { + def v = it[field] + return v != null && v <= value + } + case 'like': + def regExps = vals.collect { v -> ~/(?i)$v/ } + return { + def v = it[field] + return v != null && regExps.any { re -> re.matcher(v).find() } + } + case 'not like': + def regExps = vals.collect { v -> ~/(?i)$v/ } + return { + def v = it[field] + return v != null && !regExps.any { re -> re.matcher(v).find() } + } + case 'begins': + def regExps = vals.collect { v -> ~/(?i)^$v/ } + return { + def v = it[field] + return v != null && regExps.any { re -> re.matcher(v).find() } + } + case 'ends': + def regExps = vals.collect { v -> ~/(?i)$v$/ } + return { + def v = it[field] + return v != null && regExps.any { re -> re.matcher(v).find() } + } + case 'includes': + return { + def v = it[field] + return v != null && v.any { vv -> + vals.any { it == vv } + } + } + case 'excludes': + return { + def v = it[field] + return v == null || !v.any { vv -> + vals.any { it == vv } + } + } + default: + throw new RuntimeException("Unknown operator: $op") + } + } + + boolean equals(Filter other) { + if (other === this) return true + return ( + other instanceof FieldFilter && + other.field == field && + other.op == op && + other.value == value + ) + } +} diff --git a/src/main/groovy/io/xh/hoist/data/filter/Filter.groovy b/src/main/groovy/io/xh/hoist/data/filter/Filter.groovy new file mode 100644 index 00000000..9aeb1956 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/data/filter/Filter.groovy @@ -0,0 +1,112 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.data.filter + +import org.hibernate.criterion.Conjunction +import org.hibernate.criterion.Criterion +import org.hibernate.criterion.Disjunction + +import static org.hibernate.criterion.Restrictions.and +import static org.hibernate.criterion.Restrictions.or + +/** + * Base class for Hoist data package Filters. + * + * @see FieldFilter + * @see CompoundFilter + * @see FunctionFilter + */ +abstract class Filter { + /** True if the provided other Filter is equivalent to this instance.*/ + abstract boolean equals(Filter other) + + /** Return a Hibernate Criterion representing this filter. */ + abstract Criterion getCriterion() + + /** Return a function that can be used to test a object. */ + abstract Closure getTestFn() + + /** Get all fields used by a filter, or its sub-filters */ + abstract List getAllFields() + + + /** + * Parse/create a Filter from a map or closure, or a collection of the same. + * + * @param spec - one or more filters or specs to create one - can be: + * * A falsey value, returned as null. A null filter represents no filter at all, + * or the equivalent of a filter that always passes every record. + * * An existing Filter instance, returned directly as-is. + * * A raw Closure, returned as a {@link FunctionFilter}. + * * A Collection of nested specs, returned as a {@link CompoundFilter} with a default 'AND' operator. + * * A map, returned as an appropriate concrete Filter subclass based on its properties. + */ + static Filter parse(Object spec) { + // Degenerate cases + if (!spec) return null + if (spec instanceof Filter) return spec + + // Normalize special forms + Map specMap + if (spec instanceof Closure) { + specMap = [testFn: spec] + } else if (spec instanceof Collection) { + specMap = [filters: spec] + } else { + specMap = spec as Map + } + + // Branch on properties + if (specMap.field) { + return new FieldFilter( + specMap.field as String, + specMap.op as String, + specMap.value + ) + } + + if (specMap.testFn) { + return new FunctionFilter( + specMap.testFn as Closure + ) + } + + if (specMap.filters) { + def ret = new CompoundFilter( + specMap.filters as List, + specMap.op as String + ) + switch (ret.filters.size()) { + case 0: return null + case 1: return ret.filters[0] + default: return ret + } + } + + throw new RuntimeException("Unable to identify filter type: $spec") + } + + + //------------------------- + // Implementation + //------------------------- + protected static Criterion and(List criteria) { + if (criteria.size() == 1) return criteria[0] + + def conjunction = new Conjunction() + criteria.each { conjunction.add(it) } + return conjunction + } + protected static Criterion or(List criteria) { + if (criteria.size() == 1) return criteria[0] + + def disjunction = new Disjunction() + criteria.each { disjunction.add(it) } + return disjunction + } +} diff --git a/src/main/groovy/io/xh/hoist/data/filter/FunctionFilter.groovy b/src/main/groovy/io/xh/hoist/data/filter/FunctionFilter.groovy new file mode 100644 index 00000000..5986dc7d --- /dev/null +++ b/src/main/groovy/io/xh/hoist/data/filter/FunctionFilter.groovy @@ -0,0 +1,45 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.data.filter + +import org.hibernate.criterion.Criterion + +/** + * Filters via a custom function (closure) specified by the developer. + */ +class FunctionFilter extends Filter { + + final private Closure _testFn + + FunctionFilter(Closure testFn) { + _testFn = testFn + } + + //--------------------- + // Overrides + //---------------------- + List getAllFields() { + return [] + } + + Criterion getCriterion() { + throw new RuntimeException('Criterion generation not supported for a Function Filter') + } + + Closure getTestFn() { + return _testFn + } + + boolean equals(Filter other) { + if (other === this) return true + return ( + other instanceof FunctionFilter && + other._testFn == this._testFn + ) + } +} From c5085e8452e3ba3611f1c49659508d3c7f60df0d Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 26 Mar 2024 09:56:35 -0700 Subject: [PATCH 2/3] Github action for Gradle build + dependency submission --- .github/workflows/gradle.yml | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/gradle.yml diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 00000000..363b9959 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,67 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle + +name: Java CI with Gradle + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + + # Configure Gradle for optimal use in GiHub Actions, including caching of downloaded dependencies. + # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md + - name: Setup Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Build with Gradle Wrapper + run: ./gradlew build + + # NOTE: The Gradle Wrapper is the default and recommended way to run Gradle (https://docs.gradle.org/current/userguide/gradle_wrapper.html). + # If your project does not have the Gradle Wrapper configured, you can use the following configuration to run Gradle with a specified version. + # + # - name: Setup Gradle + # uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + # with: + # gradle-version: '8.5' + # + # - name: Build with Gradle 8.5 + # run: gradle build + + dependency-submission: + + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + + # Generates and submits a dependency graph, enabling Dependabot Alerts for all project dependencies. + # See: https://github.com/gradle/actions/blob/main/dependency-submission/README.md + - name: Generate and submit dependency graph + uses: gradle/actions/dependency-submission@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 From e1c11ffcb5104778a345c5eb1c8fa48d57198d67 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 26 Mar 2024 11:24:15 -0700 Subject: [PATCH 3/3] Remove H2 DB dependency + We provide a convenience config to spec use of in-memory h2 database as a datasource during initial / trial development. + H2 had been a dependency that was resolved @ outdated 1.x version with security vulnerabilities. + Remove dependency from hoist-core, replace with updated comment re. need to add back in at app-level using v2.x. + Update JDBC URL to workaround `value` as reserved word in H2 v2.x. --- build.gradle | 1 - .../hoist/configuration/RuntimeConfig.groovy | 21 ++++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 2b0f2ed9..9a5432cc 100644 --- a/build.gradle +++ b/build.gradle @@ -50,7 +50,6 @@ dependencies { runtimeOnly "org.glassfish.web:el-impl:2.2.1-b05" runtimeOnly "javax.xml.bind:jaxb-api:2.3.1" - runtimeOnly "com.h2database:h2" //-------------------- // Hoist Additions diff --git a/src/main/groovy/io/xh/hoist/configuration/RuntimeConfig.groovy b/src/main/groovy/io/xh/hoist/configuration/RuntimeConfig.groovy index 981ef6c6..96565bc8 100644 --- a/src/main/groovy/io/xh/hoist/configuration/RuntimeConfig.groovy +++ b/src/main/groovy/io/xh/hoist/configuration/RuntimeConfig.groovy @@ -7,6 +7,8 @@ package io.xh.hoist.configuration +import org.hibernate.dialect.H2Dialect + import static io.xh.hoist.util.InstanceConfigUtils.getInstanceConfig import static io.xh.hoist.util.Utils.withDelegate @@ -18,8 +20,7 @@ import static io.xh.hoist.util.Utils.withDelegate class RuntimeConfig { /** - * All apps should call this from runtime.groovy - * to setup necessary default configurations + * All apps should call this from runtime.groovy to setup necessary default configurations */ static void defaultConfig(Script script) { withDelegate(script) { @@ -28,11 +29,12 @@ class RuntimeConfig { } /** - * Call this from runtime.groovy - * to setup an in memory H2 DB instead of - * a MySQL or SQL Server DB. This H2 DB option - * is intended only for early stages of development, - * before a production ready DB has been set up. + * Call this from runtime.groovy to setup an in memory H2 DB instead of MySQL or SQL Server. + * This option is intended only for early stages of development, before a production-ready + * database has been provisioned. Data is transient, NOT intended for actual deployments! + * + * Note you will need to add a dependency to your app's build.gradle file: + * `runtimeOnly "com.h2database:h2:2.2.224"` (check and use latest/suitable version). */ static void h2Config(Script script) { withDelegate(script) { @@ -40,6 +42,7 @@ class RuntimeConfig { pooled = true jmxExport = true driverClassName = "org.h2.Driver" + dialect = H2Dialect username = "sa" password = "" } @@ -47,7 +50,9 @@ class RuntimeConfig { development { dataSource { dbCreate = "create-drop" - url = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE" + // `value` is a reserved word in H2 v2.x but used by Hoist AppConfig. + // We can workaround with NON_KEYWORDS=VALUE in the JDBC URL below. + url = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE;NON_KEYWORDS=VALUE" } } }