From 020738edc4ed0153f4d35e2c527127012a5fa5c0 Mon Sep 17 00:00:00 2001 From: Tungle Duy Date: Sun, 23 Aug 2026 20:03:10 +0700 Subject: [PATCH 1/3] Upgrade to Rails 7.2 --- kanaui.gemspec | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kanaui.gemspec b/kanaui.gemspec index 6813c42..cf35d54 100644 --- a/kanaui.gemspec +++ b/kanaui.gemspec @@ -26,6 +26,7 @@ Gem::Specification.new do |s| s.add_dependency 'js-routes', '>= 1.1', '< 3.0' s.add_dependency 'killbill-assets-ui' s.add_dependency 'killbill-client' - s.add_dependency 'rails', '~> 7.0' + s.required_ruby_version = '>= 3.1.0' + s.add_dependency 'rails', '~> 7.2' s.add_dependency 'spinjs-rails' end From 351aace8d853b17cd11e27ca358c85d5e0df9259 Mon Sep 17 00:00:00 2001 From: Tungle Duy Date: Sun, 23 Aug 2026 20:40:38 +0700 Subject: [PATCH 2/3] Fix JRuby CI: lock i18n to 1.14.x and bump JRuby to 9.4.15.0 --- .github/workflows/ci.yml | 4 ++-- Gemfile | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af5ff18..5644e6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: database-password: 'root' database-port: '3306' docker-compose-file: 'docker-compose.ci.mysql.yml' - - ruby-version: 'jruby-9.4.2.0' + - ruby-version: 'jruby-9.4.15.0' database-adapter: 'mysql2' database-user: 'root' database-password: 'root' @@ -37,7 +37,7 @@ jobs: database-password: 'postgres' database-port: '5432' docker-compose-file: 'docker-compose.ci.postgresql.yml' - - ruby-version: 'jruby-9.4.2.0' + - ruby-version: 'jruby-9.4.15.0' database-adapter: 'postgresql' database-user: 'postgres' database-password: 'postgres' diff --git a/Gemfile b/Gemfile index 4e096f7..d4178f8 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,9 @@ gemspec # Git. Remember to move these dependencies to your gemspec before releasing # your gem to rubygems.org. +# Lock i18n to 1.14.x for: https://github.com/ruby-i18n/i18n/issues/735 +gem 'i18n', '~> 1.14.0' + # Lock minitest to 5.x until Rails 7.1+ adds Minitest 6.0 support # Minitest 6.0.0 was released Dec 2024 with breaking API changes gem 'minitest', '~> 5.0' From 533ab0f0be65e5810f5084b987531cdbf7598cbc Mon Sep 17 00:00:00 2001 From: Tungle Duy Date: Sat, 5 Sep 2026 13:40:14 +0700 Subject: [PATCH 3/3] Add MariaDB JDBC connector v3.x parser bug workaround Dimension requests trigger a parser bug that mangles CASE...END expressions, returning 500 errors. Workaround: expand dimension requests to filter-only requests client-side and stitch results together. Filter-only requests don't trigger the bug. Related to Rails 7.2 upgrade with updated MariaDB JDBC drivers. Co-Authored-By: Claude Sonnet 5 --- .../kanaui/kiddo/kiddo_initialize.js | 261 +++++++++++++++--- 1 file changed, 222 insertions(+), 39 deletions(-) diff --git a/app/assets/javascripts/kanaui/kiddo/kiddo_initialize.js b/app/assets/javascripts/kanaui/kiddo/kiddo_initialize.js index 5101da9..8209985 100644 --- a/app/assets/javascripts/kanaui/kiddo/kiddo_initialize.js +++ b/app/assets/javascripts/kanaui/kiddo/kiddo_initialize.js @@ -21,57 +21,240 @@ ); } + // Workaround for a MariaDB JDBC connector v3.x parser bug in the bundled + // analytics plugin: any request that contains a `^dimension:` clause causes + // the plugin to generate a `CASE ... END AS \`field\`` expression that the + // driver mangles, returning a 500 ("near '' at line N"). + // + // The same data can be obtained by issuing one `^filter:(field=value)` + // request per dimension value and stitching the single-series payloads + // together client-side. Filter-only requests don't trigger the bug. + // + // Returns an array of { path, label } objects. When no dimension is present, + // returns a single entry with the original path and a null label. + function expandDimensionToFilters(reportsPath) { + var url; + try { + url = new URL(reportsPath, window.location.origin); + } catch (e) { + return [{ path: reportsPath, label: null }]; + } + + var nameParam = url.searchParams.get("name"); + if (!nameParam) { + return [{ path: reportsPath, label: null }]; + } + + var parts = nameParam.split("^"); + var baseName = parts[0]; + var clauses = parts.slice(1); + + var dimensionField = null; + var dimensionValues = []; + var otherClauses = []; + + clauses.forEach(function (c) { + var m = c.match(/^dimension:([^(]+)\(([^)]+)\)$/); + if (m && dimensionField === null) { + dimensionField = m[1]; + dimensionValues = m[2].split("|"); + } else { + otherClauses.push(c); + } + }); + + if (dimensionField === null || dimensionValues.length === 0) { + return [{ path: reportsPath, label: null }]; + } + + // If there is also a filter on the same field (e.g. filter:(currency=USD|EUR)), + // intersect it with the dimension values so the filter still narrows the + // displayed series. Otherwise we'd silently re-introduce values the user + // filtered out. + var sameFieldFilterValues = null; + otherClauses.forEach(function (c) { + var fm = c.match(/^filter:\((.+)\)$/); + if (!fm) { + return; + } + // Filter syntax is repeated `field=value` pairs joined by `|`, + // e.g. `currency=USD|currency=EUR` (NOT `currency=USD|EUR`). + var pairs = fm[1].split("|"); + var values = []; + var matched = false; + for (var i = 0; i < pairs.length; i++) { + var eq = pairs[i].indexOf("="); + if (eq === -1) { + continue; + } + var field = pairs[i].substring(0, eq); + var value = pairs[i].substring(eq + 1); + if (field === dimensionField) { + matched = true; + values.push(value); + } + } + if (matched) { + sameFieldFilterValues = (sameFieldFilterValues || []).concat(values); + } + }); + + if (sameFieldFilterValues) { + dimensionValues = dimensionValues.filter(function (v) { + return sameFieldFilterValues.indexOf(v) !== -1; + }); + if (dimensionValues.length === 0) { + // Filter excluded every dimension value — nothing to render. + return []; + } + } + + return dimensionValues.map(function (value) { + // Drop the same-field filter clause — we replace it with a single-value + // filter for this fan-out request. Filters on other fields are kept. + var preservedClauses = otherClauses.filter(function (c) { + var fm = c.match(/^filter:\((.+)\)$/); + if (!fm) { + return true; + } + return fm[1].indexOf(dimensionField + "=") === -1; + }); + + preservedClauses.push("filter:(" + dimensionField + "=" + value + ")"); + + var newName = baseName + "^" + preservedClauses.join("^"); + var newUrl = new URL(url.toString()); + newUrl.searchParams.set("name", newName); + + return { path: newUrl.toString(), label: value }; + }); + } + + // Merge an array of single-series plugin responses (one per dimension value) + // into a single multi-series payload compatible with the existing renderer. + function mergeFanOutResults(results) { + var firstValid = null; + for (var i = 0; i < results.length; i++) { + var r = results[i]; + if (r && r.json && r.json[0]) { + firstValid = r; + break; + } + } + if (!firstValid) { + return null; + } + + var merged = { + name: firstValid.json[0].name, + type: firstValid.json[0].type, + data: [], + }; + + results.forEach(function (r) { + if (!r || !r.json || !r.json[0] || !r.json[0].data) { + return; + } + r.json[0].data.forEach(function (series) { + merged.data.push({ + // Use the dimension value as the series name so the chart legend + // distinguishes USD vs EUR vs GBP etc. + name: r.label || series.name, + values: series.values, + value: series.value, + }); + }); + }); + + return merged; + } + + function renderPayload(data) { + var renderer = new Kiddo.Renderer("#chartAnchor"); + + if (data === undefined || data.data === undefined || data.data.length == 0) { + return renderer.noData(); + } + + try { + switch (data.type) { + case "COUNTERS": + renderer.pieChart(data); + break; + case "TIMELINE": + renderer.lineChart(data); + // Date controls only make sense for timelines + $("#date-controls").show(); + break; + case "TABLE": + renderer.table(data); + break; + default: + console.log("No such type implemented: " + data.type); + renderer.noData(); + } + } catch (ex) { + console.log(ex); + renderer.noData(); + } + } + $(document).ready(function () { if ($("#chartAnchor").length == 0) { return; } - d3.json($("#chartAnchor").data("reports-path")).then(function (json) { + var reportsPath = $("#chartAnchor").data("reports-path"); + var requests = expandDimensionToFilters(reportsPath); + + if (window.console && console.info) { + console.info( + "[kanaui] dimension fan-out:", + requests.length, + "request(s)", + requests.map(function (r) { + return r.label; + }) + ); + } + + if (requests.length === 0) { $("#loading-spinner").remove(); + return new Kiddo.Renderer("#chartAnchor").noData(); + } - var renderer = new Kiddo.Renderer("#chartAnchor"); + var fetchAll = Promise.all( + requests.map(function (req) { + return d3.json(req.path).then(function (json) { + return { label: req.label, json: json }; + }); + }) + ); - var data = json[0]; + fetchAll + .then(function (results) { + $("#loading-spinner").remove(); - if ( - data === undefined || - data.data === undefined || - data.data.length == 0 - ) { - return renderer.noData(); - } + var data; + if (results.length === 1 && results[0].label === null) { + // No dimension fan-out — preserve original single-request behavior. + data = results[0].json && results[0].json[0]; + } else { + data = mergeFanOutResults(results); + } - var render = function (type) { - switch (type) { - case "COUNTERS": - renderer.pieChart(data); - break; - case "TIMELINE": - renderer.lineChart(data); - // Date controls only make sense for timelines - $("#date-controls").show(); - break; - case "TABLE": - renderer.table(data); - break; - default: - console.log("No such type implemented: " + type); - renderer.noData(); + if (!data) { + return new Kiddo.Renderer("#chartAnchor").noData(); } - }; - try { - render(data.type); - } catch (ex) { - console.log(ex); - renderer.noData(); - } - }).catch(function (error) { - $("#loading-spinner").remove(); + renderPayload(data); + }) + .catch(function (error) { + $("#loading-spinner").remove(); - var renderer = new Kiddo.Renderer("#chartAnchor"); - renderError(errorMessage(error)); - return renderer.noData(); - }); + var renderer = new Kiddo.Renderer("#chartAnchor"); + renderError(errorMessage(error)); + return renderer.noData(); + }); }); })(d3, jQuery, window, document);