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