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]

Reply via email to