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 javax.annotation.PostConstruct;
038import javax.annotation.PreDestroy;
039import javax.enterprise.context.RequestScoped;
040import javax.enterprise.inject.spi.CDI;
041import org.jooq.DSLContext;
042import org.jooq.Select;
043import org.jooq.impl.DSL;
044import org.slf4j.Logger;
045import org.slf4j.LoggerFactory;
046
047@RequestScoped
048public class Session {
049    
050    private static final String GET_INSTANCE = "getInstance";
051    private static final String GET_ENTITY_TYPE_NAME = "getEntityTypeName";
052    
053    private static final Map<Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>>, String> entityNameCache = new ConcurrentHashMap<>();
054
055    protected final Logger log = LoggerFactory.getLogger(this.getClass());
056    
057    private DSLContext dslContext;
058    private Connection connection;
059
060    private ValueCache valueCache = ValueCacheProviderImpl.getInstance().getValueCache();
061    private SessionEntityCache sessionEntityCache = new SessionEntityCache(this);
062
063    private final Map<EntityInstancePK, Integer> eventTimeSequences = new HashMap<>();
064
065    private Map<String, PreparedStatement> preparedStatementCache;
066    
067    private MimeType preferredClobMimeType;
068    private Set<String> options;
069    private TransferProperties transferProperties;
070    private Map<String, Limit> limits;
071    
072    public static final long MAX_TIME = Long.MAX_VALUE;
073    private final long START_TIME;
074
075    public long getStartTime() {
076        return START_TIME;
077    }
078
079    /**
080     * Creates a new instance of Session
081     */
082    public Session() {
083        START_TIME = System.currentTimeMillis();
084    }
085
086    @PostConstruct
087    public void init() {
088        if(PersistenceDebugFlags.LogSessions) {
089            log.info("Session()");
090        }
091
092        dslContext = DslContextFactory.getInstance().getDslContext();
093        connection = dslContext.parsingConnection();
094
095        if(PersistenceDebugFlags.LogConnections) {
096            log.info("new connection is " + connection);
097        }
098    }
099
100    public Integer getNextEventTimeSequence(final EntityInstancePK entityInstancePK) {
101        var value = eventTimeSequences.get(entityInstancePK);
102
103        if(value == null) {
104            value = 1;
105        } else {
106            value++;
107        }
108
109        eventTimeSequences.put(entityInstancePK, value);
110
111        return value;
112    }
113
114    public ValueCache getValueCache() {
115        return valueCache;
116    }
117
118    public void pushSessionEntityCache() {
119        sessionEntityCache = new SessionEntityCache(sessionEntityCache);
120    }
121
122    public void popSessionEntityCache() {
123        sessionEntityCache = sessionEntityCache.popSessionEntityCache();
124    }
125
126    public DSLContext getDslContext() {
127        return dslContext;
128    }
129
130    public Connection getConnection() {
131        return connection;
132    }
133    
134    public static <T extends BaseModelControl> T getModelController(Class<T> modelController) {
135        return ThreadSession.currentSession().getSessionModelController(modelController);
136    }
137    
138    public <T extends BaseModelControl> T getSessionModelController(Class<T> modelController) {
139        return CDI.current().select(modelController).get();
140    }
141    
142    private String getEntityTypeNameFromFactory(final Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>> entityFactory) {
143        var result = Session.entityNameCache.get(entityFactory);
144        
145        if(result == null) {
146            try {
147                var entityInstance = entityFactory.getDeclaredMethod(GET_INSTANCE).invoke(entityFactory);
148
149                if(entityInstance != null) {
150                    result = (String)entityFactory.getDeclaredMethod(Session.GET_ENTITY_TYPE_NAME).invoke(entityInstance);
151                }
152            } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
153                throw new RuntimeException(e);
154            }
155            
156            Session.entityNameCache.put(entityFactory, result);
157        }
158        
159        return result;
160    }
161
162    public boolean hasLimits() {
163        return limits != null;
164    }
165
166    public boolean hasLimit(final String entityName) {
167        return hasLimits() && limits.get(entityName) != null;
168    }
169    
170    public boolean hasLimit(final Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>> entityFactory) {
171        return hasLimits() && limits.get(getEntityTypeNameFromFactory(entityFactory)) != null;
172    }
173    
174    public void copyLimit(final String sourceEntityName, final String destinationEntityName) {
175        if(hasLimits()) {
176            var limit = limits.get(sourceEntityName);
177            
178            if(limit != null) {
179                limits.put(destinationEntityName, limit);
180            }
181        }
182    }
183    
184    public Select<?> applyLimit(final Select<?> query,
185            final Class<? extends BaseFactory<? extends BasePK, ? extends BaseEntity>> entityFactory) {
186        var result = query;
187
188        if(hasLimits()) {
189            var limit = limits.get(getEntityTypeNameFromFactory(entityFactory));
190
191            if(limit != null && limit.getCount() != null) {
192                var count = Long.valueOf(limit.getCount());
193                var rawOffset = limit.getOffset();
194
195                // The QOM methods preserve the completed Select<?> type while adding
196                // optional pagination. Keep this isolated here because the API is experimental.
197                result = result.$limit(DSL.val(count));
198
199                if(rawOffset != null) {
200                    result = result.$offset(DSL.val(Long.valueOf(rawOffset)));
201                }
202            }
203        }
204
205        return result;
206    }
207
208    /**
209     * Creates a <code>PreparedStatement</code> object for sending
210     * parameterized SQL statements to the database.
211     * @param sql SQL statement to use for the PreparedStatement
212     * @return Returns a PreparedStatement
213     * @throws PersistenceDatabaseException Thrown if the PreparedStatement was unable to be created
214     */
215    public PreparedStatement prepareStatement(final String sql) {
216        PreparedStatement preparedStatement = null;
217
218        if(sql != null) {
219            // Attempt to get a PreparedStatement from preparedStatementCache...
220            if(preparedStatementCache == null) {
221                preparedStatementCache = new HashMap<>();
222            } else {
223                preparedStatement = preparedStatementCache.get(sql);
224            }
225
226            if(preparedStatement == null) {
227                // If it hasn't been cached before, go ahead and cache it for future use...
228                try {
229                    preparedStatement = connection.prepareStatement(sql);
230                    preparedStatementCache.put(sql, preparedStatement);
231                } catch(SQLException se) {
232                    throw new PersistenceDatabaseException(se);
233                }
234            } else {
235                // Cached PreparedStatement was found, call clearParameters() to clean out any previous usage of it.
236                // Clearing of batch parameters happens after executing of each batch.
237                try {
238                    preparedStatement.clearParameters();
239                } catch(SQLException se) {
240                    throw new PersistenceDatabaseException(se);
241                }
242            }
243        }
244
245        return preparedStatement;
246    }
247    
248    public static void setQueryParams(final PreparedStatement ps, final Object... params) {
249        try {
250            for(var param = 0; param < params.length; param++) {
251                switch(params[param]) {
252                    case BaseEntity baseEntity -> ps.setLong(param + 1, baseEntity.getPrimaryKey().getEntityId());
253                    case BasePK basePK -> ps.setLong(param + 1, basePK.getEntityId());
254                    case Long l -> ps.setLong(param + 1, l);
255                    case Integer i -> ps.setInt(param + 1, i);
256                    case String s -> ps.setString(param + 1, s);
257                    case byte[] bytes -> ps.setBytes(param + 1, bytes);
258                    case Boolean b -> ps.setBoolean(param + 1, b);
259                    case null -> throw new PersistenceDatabaseException("null Object in setQueryParams, index = " + param);
260                    default -> throw new PersistenceDatabaseException("unsupported Object in setQueryParams, " + params[param].getClass().getCanonicalName() + ", index = " + param);
261                }
262            }
263        } catch (SQLException se) {
264            throw new PersistenceDatabaseException(se);
265        }
266    }
267    
268    public void query(final String sql, final Object... params) {
269        try {
270            var ps = prepareStatement(sql);
271
272            setQueryParams(ps, params);
273
274            ps.execute();
275        } catch (SQLException se) {
276            throw new PersistenceDatabaseException(se);
277        }
278    }
279    
280    private void freePreparedStatementCache() {
281        var preparedStatements = preparedStatementCache.values();
282
283        preparedStatements.forEach((preparedStatement) -> {
284            try {
285                preparedStatement.close();
286            } catch (SQLException se) {
287                // not much to do to recover from this problem, connection is closing soon.
288                throw new PersistenceDatabaseException(se);
289            }
290        });
291        
292        preparedStatementCache = null;
293    }
294
295    @SuppressWarnings("Finally")
296    @PreDestroy
297    public void close() {
298        if(PersistenceDebugFlags.LogSessions) {
299            log.info("close()");
300        }
301
302        if(connection != null) {
303            if(PersistenceDebugFlags.LogConnections) {
304                log.info("closing connection " + connection);
305            }
306            
307            try {
308                if(PersistenceDebugFlags.LogConnections) {
309                    log.info("flushing entities for " + connection);
310                }
311
312                sessionEntityCache = sessionEntityCache.popLastSessionEntityCache();
313
314                if(PersistenceDebugFlags.LogValueCaches) {
315                    log.info("discarding valueCache " + valueCache);
316                }
317
318                if(valueCache != null) {
319                    valueCache = null;
320                }
321                
322                if(PersistenceDebugFlags.LogConnections) {
323                    log.info("freeing prepared statement cache " + connection);
324                }
325
326                if(preparedStatementCache != null) {
327                    freePreparedStatementCache();
328                }
329            } finally {
330                try {
331                    if(PersistenceDebugFlags.LogConnections) {
332                        log.info("closing connection " + connection);
333                    }
334
335                    connection.close();
336                    connection = null;
337                    dslContext = null;
338                } catch(SQLException se) {
339                    throw new PersistenceDatabaseException(se);
340                }
341            }
342        }
343    }
344
345    public void putReadOnlyEntity(BasePK basePK, BaseEntity baseEntity) {
346        sessionEntityCache.putReadOnlyEntity(basePK, baseEntity);
347    }
348    
349    public void putReadWriteEntity(BasePK basePK, BaseEntity baseEntity) {
350        sessionEntityCache.putReadWriteEntity(basePK, baseEntity);
351    }
352    
353    public BaseEntity getEntity(BasePK basePK) {
354        return sessionEntityCache.getEntity(basePK);
355    }
356    
357    public void removed(BasePK basePK, boolean missingPermitted) {
358        sessionEntityCache.removed(basePK, missingPermitted);
359    }
360    
361    public void setPreferredClobMimeType(MimeType preferredClobMimeType) {
362        this.preferredClobMimeType = preferredClobMimeType;
363    }
364    
365    public MimeType getPreferredClobMimeType() {
366        return preferredClobMimeType;
367    }
368    
369    public void setOptions(Set<String> options) {
370        this.options = options;
371    }
372    
373    public Set<String> getOptions() {
374        if(options == null) {
375            options = new HashSet<>();
376        }
377        
378        return options;
379    }
380
381    public void setTransferProperties(TransferProperties transferProperties) {
382        this.transferProperties = transferProperties;
383    }
384    
385    public TransferProperties getTransferProperties() {
386        return transferProperties;
387    }
388    
389    public void setLimits(Map<String, Limit> limits) {
390        this.limits = limits;
391    }
392    
393    public Map<String, Limit> getLimits() {
394        if(limits == null) {
395            limits = new HashMap<>();
396        }
397
398        return limits;
399    }
400    
401}