001// --------------------------------------------------------------------------------
002// Copyright 2002-2026 Echo Three, LLC
003//
004// Licensed under the Apache License, Version 2.0 (the "License");
005// you may not use this file except in compliance with the License.
006// You may obtain a copy of the License at
007//
008//     http://www.apache.org/licenses/LICENSE-2.0
009//
010// Unless required by applicable law or agreed to in writing, software
011// distributed under the License is distributed on an "AS IS" BASIS,
012// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013// See the License for the specific language governing permissions and
014// limitations under the License.
015// --------------------------------------------------------------------------------
016
017package com.echothree.util.server.persistence;
018
019import com.echothree.model.data.core.common.pk.EntityInstancePK;
020import com.echothree.model.data.core.server.entity.MimeType;
021import com.echothree.util.common.exception.PersistenceDatabaseException;
022import com.echothree.util.common.form.TransferProperties;
023import com.echothree.util.common.persistence.BasePK;
024import com.echothree.util.common.transfer.Limit;
025import com.echothree.util.server.control.BaseModelControl;
026import com.echothree.util.server.persistence.valuecache.ValueCache;
027import com.echothree.util.server.persistence.valuecache.ValueCacheProviderImpl;
028import java.lang.reflect.InvocationTargetException;
029import java.sql.Connection;
030import java.sql.PreparedStatement;
031import java.sql.SQLException;
032import java.util.HashMap;
033import java.util.HashSet;
034import java.util.Map;
035import java.util.Set;
036import java.util.concurrent.ConcurrentHashMap;
037import java.util.regex.Pattern;
038import javax.annotation.PostConstruct;
039import javax.annotation.PreDestroy;
040import javax.enterprise.context.RequestScoped;
041import javax.enterprise.inject.spi.CDI;
042import org.apache.commons.logging.Log;
043import org.apache.commons.logging.LogFactory;
044import org.jooq.DSLContext;
045import org.jooq.Select;
046import org.jooq.impl.DSL;
047
048@RequestScoped
049public class Session {
050    
051    private static final String GET_INSTANCE = "getInstance";
052    private static final String GET_ALL_COLUMNS = "getAllColumns";
053    private static final String GET_PK_COLUMN = "getPKColumn";
054    private static final String GET_ENTITY_TYPE_NAME = "getEntityTypeName";
055    
056    private static final Pattern PK_FIELD_PATTERN = Pattern.compile("_PK_");
057    private static final Pattern ALL_FIELDS_PATTERN = Pattern.compile("_ALL_");
058    private static final Pattern LIMIT_PATTERN = Pattern.compile("_LIMIT_");
059    
060    private static final Map<Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>>, String> allColumnsCache = new ConcurrentHashMap<>();
061    private static final Map<Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>>, String> pkColumnCache = new ConcurrentHashMap<>();
062    private static final Map<Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>>, String> entityNameCache = new ConcurrentHashMap<>();
063    
064    private Log log;
065    
066    private DSLContext dslContext;
067    private Connection connection;
068
069    private ValueCache valueCache = ValueCacheProviderImpl.getInstance().getValueCache();
070    private SessionEntityCache sessionEntityCache = new SessionEntityCache(this);
071
072    private final Map<EntityInstancePK, Integer> eventTimeSequences = new HashMap<>();
073
074    private Map<String, PreparedStatement> preparedStatementCache;
075    
076    private MimeType preferredClobMimeType;
077    private Set<String> options;
078    private TransferProperties transferProperties;
079    private Map<String, Limit> limits;
080    
081    public static final long MAX_TIME = Long.MAX_VALUE;
082    private final long START_TIME;
083
084    public long getStartTime() {
085        return START_TIME;
086    }
087
088    /**
089     * Creates a new instance of Session
090     */
091    public Session() {
092        START_TIME = System.currentTimeMillis();
093    }
094
095    @PostConstruct
096    public void init() {
097        if(PersistenceDebugFlags.LogSessions) {
098            getLog().info("Session()");
099        }
100
101        dslContext = DslContextFactory.getInstance().getDslContext();
102        connection = dslContext.parsingConnection();
103
104        if(PersistenceDebugFlags.LogConnections) {
105            getLog().info("new connection is " + connection);
106        }
107    }
108
109    public Integer getNextEventTimeSequence(final EntityInstancePK entityInstancePK) {
110        var value = eventTimeSequences.get(entityInstancePK);
111
112        if(value == null) {
113            value = 1;
114        } else {
115            value++;
116        }
117
118        eventTimeSequences.put(entityInstancePK, value);
119
120        return value;
121    }
122
123    public ValueCache getValueCache() {
124        return valueCache;
125    }
126
127    public void pushSessionEntityCache() {
128        sessionEntityCache = new SessionEntityCache(sessionEntityCache);
129    }
130
131    public void popSessionEntityCache() {
132        sessionEntityCache = sessionEntityCache.popSessionEntityCache();
133    }
134
135    protected Log getLog() {
136        if(log == null) {
137            log = LogFactory.getLog(this.getClass());
138        }
139        
140        return log;
141    }
142
143    public DSLContext getDslContext() {
144        return dslContext;
145    }
146
147    public Connection getConnection() {
148        return connection;
149    }
150    
151    public static <T extends BaseModelControl> T getModelController(Class<T> modelController) {
152        return ThreadSession.currentSession().getSessionModelController(modelController);
153    }
154    
155    public <T extends BaseModelControl> T getSessionModelController(Class<T> modelController) {
156        return CDI.current().select(modelController).get();
157    }
158    
159    private String getStringFromBaseFactory(final Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>> entityFactory,
160            final Map<Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>>, String> cache, final String methodName) {
161        var result = cache.get(entityFactory);
162        
163        if(result == null) {
164            try {
165                var entityInstance = entityFactory.getDeclaredMethod(GET_INSTANCE).invoke(entityFactory);
166
167                if(entityInstance != null) {
168                    result = (String)entityFactory.getDeclaredMethod(methodName).invoke(entityInstance);
169                }
170            } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
171                throw new RuntimeException(e);
172            }
173            
174            cache.put(entityFactory, result);
175        }
176        
177        return result;
178    }
179
180    public boolean hasLimits() {
181        return limits != null;
182    }
183
184    public boolean hasLimit(final String entityName) {
185        return hasLimits() && limits.get(entityName) != null;
186    }
187    
188    public boolean hasLimit(final Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>> entityFactory) {
189        return hasLimits() && limits.get(getStringFromBaseFactory(entityFactory, entityNameCache, GET_ENTITY_TYPE_NAME)) != null;
190    }
191    
192    public void copyLimit(final String sourceEntityName, final String destinationEntityName) {
193        if(hasLimits()) {
194            var limit = limits.get(sourceEntityName);
195            
196            if(limit != null) {
197                limits.put(destinationEntityName, limit);
198            }
199        }
200    }
201    
202    private String getLimit(final Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>> entityFactory) {
203        String result = null;
204
205        if(hasLimits()) {
206            var limit = limits.get(getStringFromBaseFactory(entityFactory, entityNameCache, GET_ENTITY_TYPE_NAME));
207
208            if(limit != null) {
209                var rawCount = limit.getCount();
210
211                if(rawCount != null) {
212                    var count = Long.valueOf(rawCount);
213                    var limitBuilder = new StringBuilder(" LIMIT ").append(count);
214                    var rawOffset = limit.getOffset();
215
216                    if(rawOffset != null) {
217                        var offset = Long.valueOf(rawOffset);
218
219                        limitBuilder.append(" OFFSET ").append(offset);
220                    }
221
222                    result = limitBuilder.append(' ').toString();
223                }
224            }
225        }
226
227        return result == null ? "" : result;
228    }
229
230    public Select<?> applyLimit(final Select<?> query,
231            final Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>> entityFactory) {
232        var result = query;
233
234        if(hasLimits()) {
235            var limit = limits.get(getStringFromBaseFactory(entityFactory, entityNameCache, GET_ENTITY_TYPE_NAME));
236
237            if(limit != null && limit.getCount() != null) {
238                var count = Long.valueOf(limit.getCount());
239                var rawOffset = limit.getOffset();
240
241                // The QOM methods preserve the completed Select<?> type while adding
242                // optional pagination. Keep this isolated here because the API is experimental.
243                result = result.$limit(DSL.val(count));
244
245                if(rawOffset != null) {
246                    result = result.$offset(DSL.val(Long.valueOf(rawOffset)));
247                }
248            }
249        }
250
251        return result;
252    }
253
254    /**
255     * Creates a <code>PreparedStatement</code> object for sending
256     * parameterized SQL statements to the database.
257     * @param sql SQL statement to use for the PreparedStatement
258     * @return Returns a PreparedStatement
259     * @throws PersistenceDatabaseException Thrown if the PreparedStatement was unable to be created
260     */
261    public PreparedStatement prepareStatement(final Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>> entityFactory,
262            final String sql) {
263        PreparedStatement preparedStatement = null;
264
265        if(sql != null) {
266            // Perform replacements on specific patterns that may be in the SQL...
267            var replacedSql = sql;
268            if(entityFactory != null) {
269                // _LIMIT_ expands to any limit passed in by the client
270                var matcher = LIMIT_PATTERN.matcher(replacedSql);
271                replacedSql = matcher.replaceAll(getLimit(entityFactory));
272
273                // _ALL_ expands to all columns in table
274                matcher = ALL_FIELDS_PATTERN.matcher(replacedSql);
275                replacedSql = matcher.replaceAll(getStringFromBaseFactory(entityFactory, allColumnsCache, GET_ALL_COLUMNS));
276
277                // _PK_ expands to PK column in table
278                matcher = PK_FIELD_PATTERN.matcher(replacedSql);
279                replacedSql = matcher.replaceAll(getStringFromBaseFactory(entityFactory, pkColumnCache, GET_PK_COLUMN));
280            }
281
282            // Attempt to get a PreparedStatement from preparedStatementCache...
283            if(preparedStatementCache == null) {
284                preparedStatementCache = new HashMap<>();
285            } else {
286                preparedStatement = preparedStatementCache.get(replacedSql);
287            }
288
289            if(preparedStatement == null) {
290                // If it hasn't been cached before, go ahead and cache it for future use...
291                try {
292                    preparedStatement = connection.prepareStatement(replacedSql);
293                    preparedStatementCache.put(sql, preparedStatement);
294                } catch(SQLException se) {
295                    throw new PersistenceDatabaseException(se);
296                }
297            } else {
298                // Cached PreparedStatement was found, call clearParameters() to clean out any previous usage of it.
299                // Clearing of batch parameters happens after executing of each batch.
300                try {
301                    preparedStatement.clearParameters();
302                } catch(SQLException se) {
303                    throw new PersistenceDatabaseException(se);
304                }
305            }
306        }
307
308        return preparedStatement;
309    }
310    
311    /**
312     * Creates a <code>PreparedStatement</code> object for sending
313     * parameterized SQL statements to the database.
314     * @param sql SQL statement to use for the PreparedStatement
315     * @return Returns a PreparedStatement
316     * @throws PersistenceDatabaseException Thrown if the PreparedStatement was unable to be created
317     */
318    public PreparedStatement prepareStatement(final String sql) {
319        return prepareStatement(null, sql);
320    }
321
322    public static void setQueryParams(final PreparedStatement ps, final Object... params) {
323        try {
324            for(var param = 0; param < params.length; param++) {
325                switch(params[param]) {
326                    case BaseEntity baseEntity -> ps.setLong(param + 1, baseEntity.getPrimaryKey().getEntityId());
327                    case BasePK basePK -> ps.setLong(param + 1, basePK.getEntityId());
328                    case Long l -> ps.setLong(param + 1, l);
329                    case Integer i -> ps.setInt(param + 1, i);
330                    case String s -> ps.setString(param + 1, s);
331                    case Boolean b -> ps.setBoolean(param + 1, b);
332                    case null -> throw new PersistenceDatabaseException("null Object in setQueryParams, index = " + param);
333                    default -> throw new PersistenceDatabaseException("unsupported Object in setQueryParams, " + params[param].getClass().getCanonicalName() + ", index = " + param);
334                }
335            }
336        } catch (SQLException se) {
337            throw new PersistenceDatabaseException(se);
338        }
339    }
340    
341    public void query(final String sql, final Object... params) {
342        try {
343            var ps = prepareStatement(sql);
344
345            setQueryParams(ps, params);
346
347            ps.execute();
348        } catch (SQLException se) {
349            throw new PersistenceDatabaseException(se);
350        }
351    }
352
353    public Integer queryForInteger(final String sql, final Object... params) {
354        Integer result = null;
355
356        try {
357            var ps = prepareStatement(sql);
358
359            setQueryParams(ps, params);
360
361            ps.executeQuery();
362            
363            try(var rs = ps.getResultSet()) {
364                if(rs.next()) {
365                    result = rs.getInt(1);
366                }
367
368                if(rs.wasNull()) {
369                    result = null;
370                }
371
372                if(rs.next()) {
373                    throw new PersistenceDatabaseException("queryForInteger result contains multiple ints");
374                }
375            } catch (SQLException se) {
376                throw new PersistenceDatabaseException(se);
377            }
378        } catch (SQLException se) {
379            throw new PersistenceDatabaseException(se);
380        } 
381
382        return result;
383    }
384
385    public Long queryForLong(final String sql, final Object... params) {
386        Long result = null;
387        
388        try {
389            var ps = prepareStatement(sql);
390            
391            setQueryParams(ps, params);
392            
393            ps.executeQuery();
394            try(var rs = ps.getResultSet()) {
395                if(rs.next()) {
396                    result = rs.getLong(1);
397
398                    if(rs.wasNull()) {
399                        result = null;
400                    }
401
402                    if(rs.next()) {
403                        throw new PersistenceDatabaseException("queryForLong result contains multiple longs");
404                    }
405                }
406            } catch (SQLException se) {
407                throw new PersistenceDatabaseException(se);
408            }
409        } catch (SQLException se) {
410            throw new PersistenceDatabaseException(se);
411        }
412        
413        return result;
414    }
415    
416    private void freePreparedStatementCache() {
417        var preparedStatements = preparedStatementCache.values();
418
419        preparedStatements.forEach((preparedStatement) -> {
420            try {
421                preparedStatement.close();
422            } catch (SQLException se) {
423                // not much to do to recover from this problem, connection is closing soon.
424                throw new PersistenceDatabaseException(se);
425            }
426        });
427        
428        preparedStatementCache = null;
429    }
430
431    @SuppressWarnings("Finally")
432    @PreDestroy
433    public void close() {
434        if(PersistenceDebugFlags.LogSessions) {
435            getLog().info("close()");
436        }
437
438        if(connection != null) {
439            if(PersistenceDebugFlags.LogConnections) {
440                getLog().info("closing connection " + connection);
441            }
442            
443            try {
444                if(PersistenceDebugFlags.LogConnections) {
445                    getLog().info("flushing entities for " + connection);
446                }
447
448                sessionEntityCache = sessionEntityCache.popLastSessionEntityCache();
449
450                if(PersistenceDebugFlags.LogValueCaches) {
451                    getLog().info("discarding valueCache " + valueCache);
452                }
453
454                if(valueCache != null) {
455                    valueCache = null;
456                }
457                
458                if(PersistenceDebugFlags.LogConnections) {
459                    getLog().info("freeing prepared statement cache " + connection);
460                }
461
462                if(preparedStatementCache != null) {
463                    freePreparedStatementCache();
464                }
465            } finally {
466                try {
467                    if(PersistenceDebugFlags.LogConnections) {
468                        getLog().info("closing connection " + connection);
469                    }
470
471                    connection.close();
472                    connection = null;
473                    dslContext = null;
474                } catch(SQLException se) {
475                    throw new PersistenceDatabaseException(se);
476                }
477            }
478        }
479    }
480
481    public void putReadOnlyEntity(BasePK basePK, BaseEntity baseEntity) {
482        sessionEntityCache.putReadOnlyEntity(basePK, baseEntity);
483    }
484    
485    public void putReadWriteEntity(BasePK basePK, BaseEntity baseEntity) {
486        sessionEntityCache.putReadWriteEntity(basePK, baseEntity);
487    }
488    
489    public BaseEntity getEntity(BasePK basePK) {
490        return sessionEntityCache.getEntity(basePK);
491    }
492    
493    public void removed(BasePK basePK, boolean missingPermitted) {
494        sessionEntityCache.removed(basePK, missingPermitted);
495    }
496    
497    public void setPreferredClobMimeType(MimeType preferredClobMimeType) {
498        this.preferredClobMimeType = preferredClobMimeType;
499    }
500    
501    public MimeType getPreferredClobMimeType() {
502        return preferredClobMimeType;
503    }
504    
505    public void setOptions(Set<String> options) {
506        this.options = options;
507    }
508    
509    public Set<String> getOptions() {
510        if(options == null) {
511            options = new HashSet<>();
512        }
513        
514        return options;
515    }
516
517    public void setTransferProperties(TransferProperties transferProperties) {
518        this.transferProperties = transferProperties;
519    }
520    
521    public TransferProperties getTransferProperties() {
522        return transferProperties;
523    }
524    
525    public void setLimits(Map<String, Limit> limits) {
526        this.limits = limits;
527    }
528    
529    public Map<String, Limit> getLimits() {
530        if(limits == null) {
531            limits = new HashMap<>();
532        }
533
534        return limits;
535    }
536    
537}