diff --git a/payment/src/main/java/org/killbill/billing/payment/core/PaymentProcessor.java b/payment/src/main/java/org/killbill/billing/payment/core/PaymentProcessor.java index 3acf35feb8..f4a0491299 100644 --- a/payment/src/main/java/org/killbill/billing/payment/core/PaymentProcessor.java +++ b/payment/src/main/java/org/killbill/billing/payment/core/PaymentProcessor.java @@ -87,9 +87,11 @@ import org.slf4j.LoggerFactory; import com.google.common.base.Function; +import com.google.common.base.Optional; import com.google.common.base.Preconditions; import com.google.common.base.Predicate; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Iterators; import com.google.common.collect.Lists; @@ -241,17 +243,45 @@ public Payment getPaymentByExternalKey(final String paymentExternalKey, final bo public Pagination getPayments(final Long offset, final Long limit, final boolean withPluginInfo, final boolean withAttempts, final Iterable properties, final TenantContext tenantContext, final InternalTenantContext internalTenantContext) { - return getEntityPaginationFromPlugins(true, - getAvailablePlugins(), - offset, - limit, - new EntityPaginationBuilder() { - @Override - public Pagination build(final Long offset, final Long limit, final String pluginName) throws PaymentApiException { - return getPayments(offset, limit, pluginName, withPluginInfo, withAttempts, properties, tenantContext, internalTenantContext); - } - } - ); + final Map> paymentMethodIdToPaymentPluginApi = new HashMap>(); + + try { + return getEntityPagination(limit, + new SourcePaginationBuilder() { + @Override + public Pagination build() { + // Find all payments for all accounts + return paymentDao.get(offset, limit, internalTenantContext); + } + }, + new Function() { + @Override + public Payment apply(final PaymentModelDao paymentModelDao) { + final PaymentPluginApi pluginApi; + if (!withPluginInfo) { + pluginApi = null; + } else { + if (paymentMethodIdToPaymentPluginApi.get(paymentModelDao.getPaymentMethodId()) == null) { + try { + final PaymentPluginApi paymentProviderPlugin = getPaymentProviderPlugin(paymentModelDao.getPaymentMethodId(), internalTenantContext); + paymentMethodIdToPaymentPluginApi.put(paymentModelDao.getPaymentMethodId(), Optional.of(paymentProviderPlugin)); + } catch (final PaymentApiException e) { + log.warn("Unable to retrieve PaymentPluginApi for paymentMethodId='{}'", paymentModelDao.getPaymentMethodId(), e); + // We use Optional to avoid printing the log line for each result + paymentMethodIdToPaymentPluginApi.put(paymentModelDao.getPaymentMethodId(), Optional.absent()); + } + } + pluginApi = paymentMethodIdToPaymentPluginApi.get(paymentModelDao.getPaymentMethodId()).orNull(); + } + final List pluginInfo = getPaymentTransactionInfoPluginsIfNeeded(pluginApi, paymentModelDao, tenantContext); + return toPayment(paymentModelDao.getId(), pluginInfo, withAttempts, internalTenantContext); + } + } + ); + } catch (final PaymentApiException e) { + log.warn("Unable to get payments", e); + return new DefaultPagination(offset, limit, null, null, ImmutableSet.of().iterator()); + } } public Pagination getPayments(final Long offset, final Long limit, final String pluginName, final boolean withPluginInfo, final boolean withAttempts, final Iterable properties, final TenantContext tenantContext, final InternalTenantContext internalTenantContext) throws PaymentApiException { diff --git a/payment/src/main/java/org/killbill/billing/payment/core/sm/PaymentStateMachineHelper.java b/payment/src/main/java/org/killbill/billing/payment/core/sm/PaymentStateMachineHelper.java index 5ed95a9eb7..7e010b7489 100644 --- a/payment/src/main/java/org/killbill/billing/payment/core/sm/PaymentStateMachineHelper.java +++ b/payment/src/main/java/org/killbill/billing/payment/core/sm/PaymentStateMachineHelper.java @@ -78,6 +78,35 @@ public class PaymentStateMachineHelper { private final StateMachineConfigCache stateMachineConfigCache; + public static final String[] STATE_NAMES = {AUTH_ERRORED, + AUTHORIZE_FAILED, + AUTHORIZE_PENDING, + AUTHORIZE_SUCCESS, + CAPTURE_ERRORED, + CAPTURE_FAILED, + CAPTURE_PENDING, + CAPTURE_SUCCESS, + CHARGEBACK_ERRORED, + CHARGEBACK_FAILED, + CHARGEBACK_PENDING, + CHARGEBACK_SUCCESS, + CREDIT_ERRORED, + CREDIT_FAILED, + CREDIT_PENDING, + CREDIT_SUCCESS, + PURCHASE_ERRORED, + PURCHASE_FAILED, + PURCHASE_PENDING, + PURCHASE_SUCCESS, + REFUND_ERRORED, + REFUND_FAILED, + REFUND_PENDING, + REFUND_SUCCESS, + VOID_ERRORED, + VOID_FAILED, + VOID_PENDING, + VOID_SUCCESS}; + @Inject public PaymentStateMachineHelper(final StateMachineConfigCache stateMachineConfigCache) { this.stateMachineConfigCache = stateMachineConfigCache; diff --git a/payment/src/main/java/org/killbill/billing/payment/dao/DefaultPaymentDao.java b/payment/src/main/java/org/killbill/billing/payment/dao/DefaultPaymentDao.java index 0e6770451c..bed7b5bf9d 100644 --- a/payment/src/main/java/org/killbill/billing/payment/dao/DefaultPaymentDao.java +++ b/payment/src/main/java/org/killbill/billing/payment/dao/DefaultPaymentDao.java @@ -19,16 +19,19 @@ package org.killbill.billing.payment.dao; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.UUID; +import java.util.regex.Pattern; import javax.annotation.Nullable; import javax.inject.Inject; import org.joda.time.DateTime; +import org.killbill.billing.ErrorCode; import org.killbill.billing.callcontext.InternalCallContext; import org.killbill.billing.callcontext.InternalTenantContext; import org.killbill.billing.catalog.api.Currency; @@ -38,10 +41,12 @@ import org.killbill.billing.payment.api.DefaultPaymentInfoEvent; import org.killbill.billing.payment.api.DefaultPaymentPluginErrorEvent; import org.killbill.billing.payment.api.Payment; +import org.killbill.billing.payment.api.PaymentApiException; import org.killbill.billing.payment.api.PaymentMethod; import org.killbill.billing.payment.api.PaymentTransaction; import org.killbill.billing.payment.api.TransactionStatus; import org.killbill.billing.payment.api.TransactionType; +import org.killbill.billing.payment.core.sm.PaymentStateMachineHelper; import org.killbill.billing.util.cache.CacheControllerDispatcher; import org.killbill.billing.util.callcontext.InternalCallContextFactory; import org.killbill.billing.util.dao.NonEntityDao; @@ -49,6 +54,7 @@ import org.killbill.billing.util.entity.Pagination; import org.killbill.billing.util.entity.dao.DefaultPaginationSqlDaoHelper; import org.killbill.billing.util.entity.dao.DefaultPaginationSqlDaoHelper.PaginationIteratorBuilder; +import org.killbill.billing.util.entity.dao.EntityDaoBase; import org.killbill.billing.util.entity.dao.EntitySqlDaoTransactionWrapper; import org.killbill.billing.util.entity.dao.EntitySqlDaoTransactionalJdbiWrapper; import org.killbill.billing.util.entity.dao.EntitySqlDaoWrapperFactory; @@ -66,11 +72,10 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; -public class DefaultPaymentDao implements PaymentDao { +public class DefaultPaymentDao extends EntityDaoBase implements PaymentDao { private static final Logger log = LoggerFactory.getLogger(DefaultPaymentDao.class); - private final EntitySqlDaoTransactionalJdbiWrapper transactionalSqlDao; private final DefaultPaginationSqlDaoHelper paginationHelper; private final PersistentBus eventBus; private final Clock clock; @@ -78,7 +83,7 @@ public class DefaultPaymentDao implements PaymentDao { @Inject public DefaultPaymentDao(final IDBI dbi, final Clock clock, final CacheControllerDispatcher cacheControllerDispatcher, final NonEntityDao nonEntityDao, final InternalCallContextFactory internalCallContextFactory, final PersistentBus eventBus) { - this.transactionalSqlDao = new EntitySqlDaoTransactionalJdbiWrapper(dbi, clock, cacheControllerDispatcher, nonEntityDao, internalCallContextFactory); + super(new EntitySqlDaoTransactionalJdbiWrapper(dbi, clock, cacheControllerDispatcher, nonEntityDao, internalCallContextFactory), PaymentSqlDao.class); this.paginationHelper = new DefaultPaginationSqlDaoHelper(transactionalSqlDao); this.eventBus = eventBus; this.clock = clock; @@ -248,16 +253,20 @@ public Iterator build(final PaymentSqlDao paymentSqlDao, final @Override public Pagination searchPayments(final String searchKey, final Long offset, final Long limit, final InternalTenantContext context) { + // Optimization: if the search key looks like a state name (e.g. _ERRORED), assume the user is searching by state only + final List paymentStates = shouldSearchByStateNameOnly(searchKey); + + final String likeSearchKey = String.format("%%%s%%", searchKey); return paginationHelper.getPagination(PaymentSqlDao.class, new PaginationIteratorBuilder() { @Override public Long getCount(final PaymentSqlDao paymentSqlDao, final InternalTenantContext context) { - return paymentSqlDao.getSearchCount(searchKey, String.format("%%%s%%", searchKey), context); + return !paymentStates.isEmpty() ? paymentSqlDao.getSearchByStateCount(paymentStates, context) : paymentSqlDao.getSearchCount(searchKey, likeSearchKey, context); } @Override public Iterator build(final PaymentSqlDao paymentSqlDao, final Long limit, final InternalTenantContext context) { - return paymentSqlDao.search(searchKey, String.format("%%%s%%", searchKey), offset, limit, context); + return !paymentStates.isEmpty() ? paymentSqlDao.searchByState(paymentStates, offset, limit, context) : paymentSqlDao.search(searchKey, likeSearchKey, offset, limit, context); } }, offset, @@ -265,6 +274,20 @@ public Iterator build(final PaymentSqlDao paymentSqlDao, final context); } + private List shouldSearchByStateNameOnly(final String searchKey) { + final Pattern pattern = Pattern.compile(".*" + searchKey + ".*"); + + // Note that technically, we should look at all of the available state names in the database instead since the state machine is configurable. The common use-case + // is to override transitions though, not to introduce new states, and since some of it is already hardcoded in PaymentStateMachineHelper anyways, it's probably good enough for now. + final List stateNames = new ArrayList(); + for (final String stateName : PaymentStateMachineHelper.STATE_NAMES) { + if (pattern.matcher(stateName).matches()) { + stateNames.add(stateName); + } + } + return stateNames; + } + @Override public PaymentModelDao insertPaymentWithFirstTransaction(final PaymentModelDao payment, final PaymentTransactionModelDao paymentTransaction, final InternalCallContext context) { @@ -650,4 +673,9 @@ private void postPaymentEventFromTransaction(final UUID accountId, private InternalCallContext contextWithUpdatedDate(final InternalCallContext input) { return new InternalCallContext(input, clock.getUTCNow()); } + + @Override + protected PaymentApiException generateAlreadyExistsException(final PaymentModelDao entity, final InternalCallContext context) { + return new PaymentApiException(ErrorCode.PAYMENT_INTERNAL_ERROR, "Payment already exists"); + } } diff --git a/payment/src/main/java/org/killbill/billing/payment/dao/PaymentDao.java b/payment/src/main/java/org/killbill/billing/payment/dao/PaymentDao.java index dc1f8f71c3..2c46e15cfe 100644 --- a/payment/src/main/java/org/killbill/billing/payment/dao/PaymentDao.java +++ b/payment/src/main/java/org/killbill/billing/payment/dao/PaymentDao.java @@ -24,11 +24,14 @@ import org.killbill.billing.callcontext.InternalCallContext; import org.killbill.billing.callcontext.InternalTenantContext; import org.killbill.billing.catalog.api.Currency; +import org.killbill.billing.payment.api.Payment; +import org.killbill.billing.payment.api.PaymentApiException; import org.killbill.billing.payment.api.TransactionStatus; import org.killbill.billing.payment.api.TransactionType; import org.killbill.billing.util.entity.Pagination; +import org.killbill.billing.util.entity.dao.EntityDao; -public interface PaymentDao { +public interface PaymentDao extends EntityDao { public Pagination getByTransactionStatusAcrossTenants(final Iterable transactionStatuses, DateTime createdBeforeDate, DateTime createdAfterDate, final Long offset, final Long limit); diff --git a/payment/src/main/java/org/killbill/billing/payment/dao/PaymentSqlDao.java b/payment/src/main/java/org/killbill/billing/payment/dao/PaymentSqlDao.java index 0f5475b415..23dcd3f3e2 100644 --- a/payment/src/main/java/org/killbill/billing/payment/dao/PaymentSqlDao.java +++ b/payment/src/main/java/org/killbill/billing/payment/dao/PaymentSqlDao.java @@ -33,7 +33,6 @@ import org.skife.jdbi.v2.sqlobject.BindBean; import org.skife.jdbi.v2.sqlobject.SqlQuery; import org.skife.jdbi.v2.sqlobject.SqlUpdate; -import org.skife.jdbi.v2.sqlobject.customizers.Define; @EntitySqlDaoStringTemplate public interface PaymentSqlDao extends EntitySqlDao { @@ -67,6 +66,17 @@ public List getPaymentsByStatesAcrossTenants(@StateCollectionBi @Bind("createdAfterDate") final Date createdAfterDate, @Bind("limit") final int limit); + @SqlQuery + @SmartFetchSize(shouldStream = true) + public Iterator searchByState(@PaymentStateCollectionBinder final Collection paymentStates, + @Bind("offset") final Long offset, + @Bind("rowCount") final Long rowCount, + @BindBean final InternalTenantContext context); + + @SqlQuery + public Long getSearchByStateCount(@PaymentStateCollectionBinder final Collection paymentStates, + @BindBean final InternalTenantContext context); + @SqlQuery @SmartFetchSize(shouldStream = true) public Iterator getByPluginName(@Bind("pluginName") final String pluginName, diff --git a/payment/src/main/java/org/killbill/billing/payment/dao/PaymentStateCollectionBinder.java b/payment/src/main/java/org/killbill/billing/payment/dao/PaymentStateCollectionBinder.java new file mode 100644 index 0000000000..addde2a3e7 --- /dev/null +++ b/payment/src/main/java/org/killbill/billing/payment/dao/PaymentStateCollectionBinder.java @@ -0,0 +1,57 @@ +/* + * Copyright 2014-2017 Groupon, Inc + * Copyright 2014-2017 The Billing Project, LLC + * + * The Billing Project licenses this file to you 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.killbill.billing.payment.dao; + +import java.lang.annotation.Annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Collection; + +import org.killbill.billing.payment.dao.PaymentStateCollectionBinder.PaymentStateCollectionBinderFactory; +import org.skife.jdbi.v2.SQLStatement; +import org.skife.jdbi.v2.sqlobject.Binder; +import org.skife.jdbi.v2.sqlobject.BinderFactory; +import org.skife.jdbi.v2.sqlobject.BindingAnnotation; + +@BindingAnnotation(PaymentStateCollectionBinderFactory.class) +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER}) +public @interface PaymentStateCollectionBinder { + + public static class PaymentStateCollectionBinderFactory implements BinderFactory { + + @Override + public Binder build(final Annotation annotation) { + return new Binder>() { + + @Override + public void bind(final SQLStatement query, final PaymentStateCollectionBinder bind, final Collection allPaymentState) { + query.define("states", allPaymentState); + + int idx = 0; + for (final String paymentState : allPaymentState) { + query.bind("state_" + idx, paymentState); + idx++; + } + } + }; + } + } +} diff --git a/payment/src/main/resources/org/killbill/billing/payment/dao/PaymentSqlDao.sql.stg b/payment/src/main/resources/org/killbill/billing/payment/dao/PaymentSqlDao.sql.stg index 6b110e9e54..bf30477e0e 100644 --- a/payment/src/main/resources/org/killbill/billing/payment/dao/PaymentSqlDao.sql.stg +++ b/payment/src/main/resources/org/killbill/billing/payment/dao/PaymentSqlDao.sql.stg @@ -80,7 +80,33 @@ searchQuery(prefix) ::= << or account_id = :searchKey or payment_method_id = :searchKey or external_key like :likeSearchKey - or state_name like :likeSearchKey +>> + +searchByState(states) ::= << +select + +from t +join ( + select + from + where state_name in (}; separator="," >) + + + order by + limit :rowCount offset :offset +) optimization on = +order by +; +>> + +getSearchByStateCount(states) ::= << +select + count(1) as count +from t +where t.state_name in (}; separator="," >) + + +; >> getByPluginName() ::= << diff --git a/payment/src/main/resources/org/killbill/billing/payment/ddl.sql b/payment/src/main/resources/org/killbill/billing/payment/ddl.sql index 019e3cfd9e..e994fa90fb 100644 --- a/payment/src/main/resources/org/killbill/billing/payment/ddl.sql +++ b/payment/src/main/resources/org/killbill/billing/payment/ddl.sql @@ -122,6 +122,7 @@ CREATE UNIQUE INDEX payments_id ON payments(id); CREATE UNIQUE INDEX payments_key ON payments(external_key, tenant_record_id); CREATE INDEX payments_accnt ON payments(account_id); CREATE INDEX payments_tenant_account_record_id ON payments(tenant_record_id, account_record_id); +CREATE INDEX payments_tenant_record_id_state_name ON payments(tenant_record_id, state_name); DROP TABLE IF EXISTS payment_history; diff --git a/payment/src/main/resources/org/killbill/billing/payment/migration/V20170123023324__add_payments_tenant_record_id_state_name_index.sql b/payment/src/main/resources/org/killbill/billing/payment/migration/V20170123023324__add_payments_tenant_record_id_state_name_index.sql new file mode 100644 index 0000000000..11ba386dc1 --- /dev/null +++ b/payment/src/main/resources/org/killbill/billing/payment/migration/V20170123023324__add_payments_tenant_record_id_state_name_index.sql @@ -0,0 +1 @@ +alter table payments add index payments_tenant_record_id_state_name(tenant_record_id, state_name); diff --git a/payment/src/test/java/org/killbill/billing/payment/dao/MockPaymentDao.java b/payment/src/test/java/org/killbill/billing/payment/dao/MockPaymentDao.java index 079853524e..12ce2be23a 100644 --- a/payment/src/test/java/org/killbill/billing/payment/dao/MockPaymentDao.java +++ b/payment/src/test/java/org/killbill/billing/payment/dao/MockPaymentDao.java @@ -34,16 +34,19 @@ import org.killbill.billing.callcontext.InternalTenantContext; import org.killbill.billing.catalog.api.Currency; import org.killbill.billing.dao.MockNonEntityDao; +import org.killbill.billing.payment.api.Payment; +import org.killbill.billing.payment.api.PaymentApiException; import org.killbill.billing.payment.api.TransactionStatus; import org.killbill.billing.payment.api.TransactionType; import org.killbill.billing.util.entity.DefaultPagination; import org.killbill.billing.util.entity.Pagination; +import org.killbill.billing.util.entity.dao.MockEntityDaoBase; import com.google.common.base.Predicate; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; -public class MockPaymentDao implements PaymentDao { +public class MockPaymentDao extends MockEntityDaoBase implements PaymentDao { private final Map payments = new HashMap(); private final Map transactions = new HashMap(); diff --git a/payment/src/test/java/org/killbill/billing/payment/dao/TestDefaultPaymentDao.java b/payment/src/test/java/org/killbill/billing/payment/dao/TestDefaultPaymentDao.java index 4d6a938616..35d2c42e0a 100644 --- a/payment/src/test/java/org/killbill/billing/payment/dao/TestDefaultPaymentDao.java +++ b/payment/src/test/java/org/killbill/billing/payment/dao/TestDefaultPaymentDao.java @@ -27,6 +27,7 @@ import org.killbill.billing.payment.PaymentTestSuiteWithEmbeddedDB; import org.killbill.billing.payment.api.TransactionStatus; import org.killbill.billing.payment.api.TransactionType; +import org.killbill.billing.payment.core.sm.PaymentStateMachineHelper; import org.testng.Assert; import org.testng.annotations.Test; @@ -37,11 +38,11 @@ public class TestDefaultPaymentDao extends PaymentTestSuiteWithEmbeddedDB { @Test(groups = "slow") public void testPaymentCRUD() throws Exception { for (int i = 0; i < 3; i++) { - testPaymentCRUDForAccount(); + testPaymentCRUDForAccount(i + 1); } } - public void testPaymentCRUDForAccount() throws Exception { + private void testPaymentCRUDForAccount(final int runNb) throws Exception { final Account account = testHelper.createTestAccount(UUID.randomUUID().toString(), true); final UUID accountId = account.getId(); @@ -68,8 +69,8 @@ public void testPaymentCRUDForAccount() throws Exception { specifiedSecondPaymentTransactionModelDao.getAttemptId(), specifiedSecondPaymentTransactionModelDao.getPaymentId(), specifiedFirstPaymentTransactionModelDao.getTransactionType(), - "SOME_ERRORED_STATE", - "SOME_ERRORED_STATE", + PaymentStateMachineHelper.STATE_NAMES[0], + PaymentStateMachineHelper.STATE_NAMES[0], specifiedSecondPaymentTransactionModelDao.getId(), TransactionStatus.PAYMENT_FAILURE, processedAmount, @@ -99,6 +100,7 @@ public void testPaymentCRUDForAccount() throws Exception { // Verify search APIs Assert.assertEquals(ImmutableList.copyOf(paymentDao.searchPayments(accountId.toString(), 0L, 100L, internalCallContext).iterator()).size(), 4); + Assert.assertEquals(ImmutableList.copyOf(paymentDao.searchPayments("_ERRORED", 0L, 100L, internalCallContext).iterator()).size(), runNb); } private void verifyPaymentAndTransactions(final InternalCallContext accountCallContext, final PaymentModelDao specifiedFirstPaymentModelDao, final PaymentTransactionModelDao... specifiedFirstPaymentTransactionModelDaos) { diff --git a/util/src/main/resources/org/killbill/billing/util/entity/dao/EntitySqlDao.sql.stg b/util/src/main/resources/org/killbill/billing/util/entity/dao/EntitySqlDao.sql.stg index d05e54ed2e..6f3545015c 100644 --- a/util/src/main/resources/org/killbill/billing/util/entity/dao/EntitySqlDao.sql.stg +++ b/util/src/main/resources/org/killbill/billing/util/entity/dao/EntitySqlDao.sql.stg @@ -151,10 +151,15 @@ get(offset, rowCount, orderBy) ::= << select from t -where - +join ( + select + from + where + + order by + limit :rowCount offset :offset +) optimization on = order by t. -limit :rowCount offset :offset ; >> diff --git a/util/src/test/java/org/killbill/billing/util/dao/TestStringTemplateInheritance.java b/util/src/test/java/org/killbill/billing/util/dao/TestStringTemplateInheritance.java index 7f1627c4b1..cc50c48582 100644 --- a/util/src/test/java/org/killbill/billing/util/dao/TestStringTemplateInheritance.java +++ b/util/src/test/java/org/killbill/billing/util/dao/TestStringTemplateInheritance.java @@ -123,9 +123,14 @@ public void testCheckQueries() throws Exception { ", t.account_record_id\r?\n" + ", t.tenant_record_id\r?\n" + "from kombucha t\r?\n" + - "where t.tenant_record_id = :tenantRecordId\r?\n" + + "join \\(\r?\n" + + " select record_id\r?\n" + + " from kombucha\r?\n" + + " where tenant_record_id = :tenantRecordId\r?\n" + + " order by record_id\r?\n" + + " limit :rowCount offset :offset\r?\n" + + "\\) optimization on optimization.record_id = t.record_id\r?\n" + "order by t.record_id\r?\n" + - "limit :rowCount offset :offset\r?\n" + ";"); assertPattern(kombucha.getInstanceOf("test").toString(), "select\r?\n" + " t.record_id\r?\n" +