jdaugherty commented on code in PR #16631: URL: https://github.com/apache/grails-core/pull/16631#discussion_r4372207120
########## grails-data-hibernate7/core/src/main/groovy/org/apache/grails/data/hibernate7/core/querytail/QueryTailStatementInspector.groovy: ########## @@ -0,0 +1,319 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * https://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.apache.grails.data.hibernate7.core.querytail + +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import org.hibernate.SessionFactory +import org.hibernate.SessionFactoryObserver +import org.hibernate.cfg.AvailableSettings +import org.hibernate.cfg.Configuration +import org.hibernate.dialect.AbstractTransactSQLDialect +import org.hibernate.dialect.CockroachDialect +import org.hibernate.dialect.DB2Dialect +import org.hibernate.dialect.Dialect +import org.hibernate.dialect.H2Dialect +import org.hibernate.dialect.HANADialect +import org.hibernate.dialect.HSQLDialect +import org.hibernate.dialect.MySQLDialect +import org.hibernate.dialect.OracleDialect +import org.hibernate.dialect.PostgreSQLDialect +import org.hibernate.dialect.SpannerDialect +import org.hibernate.dialect.SpannerPostgreSQLDialect +import org.hibernate.engine.spi.SessionFactoryImplementor +import org.hibernate.resource.jdbc.spi.StatementInspector +import org.springframework.beans.BeanUtils +import org.springframework.util.ClassUtils + +import org.apache.grails.data.core.querytail.QueryTailCapture +import org.apache.grails.data.core.querytail.QueryTailSettings +import org.apache.grails.data.core.querytail.QueryTailSyntax +import org.grails.datastore.mapping.core.exceptions.ConfigurationException + +/** + * The statement inspector GORM installs on a session factory when query-tail capture is enabled. It + * records every statement Hibernate is about to run to the {@link QueryTailCapture} of the + * application, without its values, and returns the statement as it found it: the database runs + * it unchanged. An inspector the application configured itself through + * {@code hibernate.session_factory.statement_inspector} keeps running: it sees the statement + * first, and the statement it returns is the one run and recorded. + * + * <p>The session factory owns the capture. The inspector observes it: it opens the capture when + * the session factory is created, takes from its dialect how the database quotes values, and + * closes the capture when the session factory closes, so a session factory that fails to start + * leaves no capture open either. An observer the application set on the configuration keeps + * being called.</p> + * + * <p>Nothing of this is installed unless {@link QueryTailSettings#SETTING_ENABLED} is set, so an + * application that does not capture pays nothing per statement.</p> + * + * @since 8.1 + */ +@CompileStatic +class QueryTailStatementInspector implements StatementInspector, SessionFactoryObserver { + + @Serial + private static final long serialVersionUID = 1L + + /** The community dialects, which are not a dependency, that the inspector tells apart by name. */ + private static final String COMMUNITY_INFORMIX = 'org.hibernate.community.dialect.InformixDialect' + private static final String COMMUNITY_SQL_ANYWHERE = 'org.hibernate.community.dialect.SybaseAnywhereDialect' + + /** The connection source name statements are recorded under. */ + final String dataSourceName + + /** The file statements are recorded to. */ + final File file + + /** The inspector the application configured, or null. */ + final StatementInspector delegate + + /** The session factory observer the application configured, or null. */ + final SessionFactoryObserver observer + + private transient volatile QueryTailCapture capture + private transient boolean closed + + /** + * @param dataSourceName the connection source the inspected session factory belongs to + * @param file the capture file statements are recorded to + * @param delegate the inspector the application configured, or null + * @param observer the session factory observer the application configured, or null + */ + QueryTailStatementInspector(String dataSourceName, File file, StatementInspector delegate, + SessionFactoryObserver observer) { + this.dataSourceName = dataSourceName + this.file = file + this.delegate = delegate + this.observer = observer + } + + /** + * Installs the inspector on the configuration of a session factory when capture is enabled, as + * its statement inspector and as the observer of the session factory it builds. + * + * @param dataSourceName the connection source the session factory is built for + * @param settings the query-tail settings of the connection source + * @param configuration the configuration the session factory is built from + * @return the installed inspector, or null when capture is not enabled + * @throws ConfigurationException when the inspector the application configured cannot be created + */ + static QueryTailStatementInspector install(String dataSourceName, QueryTailSettings settings, + Configuration configuration) { + if (settings == null || !settings.enabled) { + return null + } + Properties properties = configuration.properties + StatementInspector configured = resolve(properties.get(AvailableSettings.STATEMENT_INSPECTOR)) Review Comment: Fixed in b8c346b48c. On Hibernate 7, `Configuration.buildSessionFactory` applies an inspector set with `setStatementInspector` over the one the property names, so a `configClass` that set one replaced the capture. `install` now wraps the inspector already set on the configuration, and falls back to the one the property names when none is set. It installs the wrapper with `setStatementInspector` and as the property. When both are set, the wrapper runs the one set on the configuration, which is what Hibernate runs without the capture. Hibernate 5.6's `Configuration` has no `setStatementInspector`, and no field for one either. Its `buildSessionFactory` applies only the interceptor, the observer, the entity-not-found delegate, the tuplizer factory and the tenant resolver. On Hibernate 5, a `configClass` can therefore set an inspector only through the configuration's properties, which `install` reads after the `configClass` is constructed. I added a Hibernate 5 test for that case. Tests: - **Hibernate 7:** a `configClass` that calls `setStatementInspector` in its constructor. Its inspector runs, the query returns its row, and the statement the inspector returns is recorded. - **Hibernate 7:** the same `configClass` while the property names another inspector. Only the one set on the configuration runs. - **Hibernate 5:** a `configClass` that puts an inspector in its properties. The inspector keeps running and its statement is recorded. Both Hibernate 7 tests fail on the previous installer. ########## grails-datamapping-core/src/cli/groovy/org/apache/grails/data/core/cli/querytail/StatementCollapser.groovy: ########## @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * https://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.apache.grails.data.core.cli.querytail + +import java.util.regex.Pattern + +import groovy.transform.CompileStatic + +/** + * Shortens a statement to the part that identifies it: the table a select reads and its where + * clause, the table an update changes and its where clause, the table an insert writes to. The + * column lists Hibernate generates are long and say nothing about which code ran the statement, + * and dropping them lets the same query count as one statement wherever it is run from. A Cypher + * statement is shown whole: its pattern names what it reads or writes, and GORM generates nothing + * long in it. So is a MongoDB command, which is recorded as its name, its collection and the + * document that shapes it. + * + * @since 8.1 + */ +@CompileStatic +class StatementCollapser { + + private static final Pattern COMMENT = Pattern.compile('/\\*.*?\\*/', Pattern.DOTALL) + private static final Pattern WHITESPACE = Pattern.compile('\\s+') + private static final String ELLIPSIS = '...' + + /** + * @param sql a statement + * @return the shortened statement, or the statement with its comments and extra whitespace + * removed when it is not a SQL select, update or insert + */ + static String collapse(String sql) { + String text = normalize(sql) + if (text.isEmpty() || StatementType.isCypher(text)) { + return text + } + switch (StatementType.of(text)) { + case StatementType.SELECT: + return collapseSelect(text) + case StatementType.UPDATE: + return collapseUpdate(text) + case StatementType.INSERT: + return collapseInsert(text) + default: + return text + } + } + + /** + * @param sql a statement + * @return the statement without comments, its whitespace collapsed to single spaces + */ + static String normalize(String sql) { + if (sql == null) { + return '' + } + WHITESPACE.matcher(COMMENT.matcher(sql).replaceAll(' ')).replaceAll(' ').trim() + } + + private static String collapseSelect(String text) { + int from = topLevelIndexOf(text, ' from ') + from < 0 ? text : "select ${ELLIPSIS}${text.substring(from)}".toString() + } + + private static String collapseUpdate(String text) { + int set = topLevelIndexOf(text, ' set ') + if (set < 0) { + return text + } + int where = topLevelIndexOf(text, ' where ') + String head = text.substring(0, set) + where > set ? "${head} ${ELLIPSIS}${text.substring(where)}".toString() : "${head} ${ELLIPSIS}".toString() + } + + private static String collapseInsert(String text) { + int columns = topLevelIndexOf(text, '(') + int values = topLevelIndexOf(text, ' values ') + int cut = columns < 0 ? values : (values < 0 ? columns : Math.min(columns, values)) + cut < 0 ? text : "${text.substring(0, cut).trim()} ${ELLIPSIS}".toString() + } + + /** + * Finds a keyword outside parentheses and string literals, so that a subquery or a value + * never stands in for the outer statement's clause. + */ + static int topLevelIndexOf(String text, String needle) { + String haystack = text.toLowerCase(Locale.ROOT) + if (haystack.length() != text.length()) { + haystack = text + } + int depth = 0 + boolean quoted = false + for (int i = 0; i < haystack.length(); i++) { + char c = haystack.charAt(i) + if (c == ('\'' as char)) { Review Comment: Fixed in b8c346b48c. The clause scan now skips quoted names the same way it skips string literals. Names can be quoted with double quotes, backticks or square brackets, and a closing quote written twice stands for itself (`""`, ` `` `, `]]`, and `''` in a literal). Your two updates now collapse to `update "order set lines" ... where id=?` and `update "order set headers" ... where id=?`. - **Parentheses:** a parenthesis inside a quoted name no longer counts, so `insert into "order (lines)" (id,title) values (?,?)` keeps its table. - **Comments and whitespace:** these are removed only outside quoted names, so a name that holds two spaces or `/* */` is kept as written. - **Unclosed quotes:** a statement with a name left open is shown whole. New rows cover each quote style, doubled quotes, and clause words and parentheses inside names in `select`, `update` and `insert`. Every row that used to collapse wrongly fails on the previous scanner. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
