From 9b60a437572a02871cd78894d8a3ca88c3d108f1 Mon Sep 17 00:00:00 2001 From: eval Date: Fri, 7 Aug 2026 21:00:18 -1000 Subject: [PATCH 01/11] fix(ci): stabilize nightly verification lanes --- .github/workflows/load-soak-nightly.yml | 13 ++- .../client-compat/run-client-compat-smoke.sh | 17 +++- .../conformance/cite/run-cite-wms11-tests.sh | 11 ++- .../FeatureStore/OracleFeatureStore.cs | 12 +-- .../Services/OracleFeatureQueryBuilder.cs | 91 +++++++++++++++---- .../LocalSubstrateMigrationGateTests.cs | 21 +++-- .../OracleFeatureQueryBuilderTests.cs | 10 ++ 7 files changed, 137 insertions(+), 38 deletions(-) diff --git a/.github/workflows/load-soak-nightly.yml b/.github/workflows/load-soak-nightly.yml index 758317d5e7..c9c0bc5e9b 100644 --- a/.github/workflows/load-soak-nightly.yml +++ b/.github/workflows/load-soak-nightly.yml @@ -156,7 +156,18 @@ jobs: Security__DisableHttpsRedirection: "true" Security__ConnectionEncryption__MasterKey: "test-master-key-32-chars-long-000000" ConnectionStrings__redis: "localhost:6379" - ConnectionStrings__DefaultConnection: Host=localhost;Port=5432;Database=${{ env.DB_NAME }};Username=${{ env.DB_USER }};Password=${{ env.DB_PASSWORD }};Maximum Pool Size=50 + # PostgresDataSourceFactory applies Limits:Connections to the data source after + # parsing the connection string. Keep both settings aligned: setting only + # Maximum Pool Size here is otherwise overridden by the production default + # (200), which exceeds the GitHub service container's PostgreSQL client + # budget and produces "too many clients already" under the nightly load. + # Reserve ample capacity for the health check, migrations, and maintenance + # connections while keeping the application below the container limit. + Limits__Connections__MaxConcurrentQueries: "40" + Limits__Connections__MaxConnectionPoolSize: "40" + Limits__Connections__MinConnectionPoolSize: "0" + Limits__Connections__ConnectionAcquisitionTimeoutSeconds: "15" + ConnectionStrings__DefaultConnection: Host=localhost;Port=5432;Database=${{ env.DB_NAME }};Username=${{ env.DB_USER }};Password=${{ env.DB_PASSWORD }};Maximum Pool Size=40 run: | dotnet run --project src/Honua.Server -c Release --no-build --no-launch-profile > server.log 2>&1 & echo $! > server.pid diff --git a/scripts/client-compat/run-client-compat-smoke.sh b/scripts/client-compat/run-client-compat-smoke.sh index 185888b527..7739952634 100755 --- a/scripts/client-compat/run-client-compat-smoke.sh +++ b/scripts/client-compat/run-client-compat-smoke.sh @@ -421,6 +421,17 @@ request_json_4xx() { [[ "$status" == "pass" ]] } +# GeoServices REST serializes operation errors in an Esri JSON `error` envelope +# with an HTTP 200 response. This is a protocol-level wire-format convention, +# unlike OGC API and OData, which use HTTP 4xx problem responses. +request_esri_json_error() { + local check_id="$1" + local url="$2" + local jq_expr="$3" + + request_json "$check_id" "$url" "200" "$jq_expr" +} + append_cert_result() { local protocol="$1" cert_id="$2" status="$3" duration_ms="$4" measured_count="$5" measured_delta="$6" notes="${7:-}" evidence_ref="${8:-}" printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ @@ -724,14 +735,14 @@ run_full_featureserver() { append_cert_result "$proto" "CERT-GEOM-02" "$LAST_STATUS" "$LAST_DURATION_MS" "" "" "" # CERT-ERRH-01: Invalid endpoint returns structured error - request_json_4xx \ + request_esri_json_error \ "fs-error-invalid" \ "${fs_base}/99999?f=json" \ '.error != null' || failed=1 append_cert_result "$proto" "CERT-ERRH-01" "$LAST_STATUS" "$LAST_DURATION_MS" "" "" "" # CERT-ERRH-02: Malformed where returns structured error - request_json_4xx \ + request_esri_json_error \ "fs-error-malformed" \ "${fs_base}/${LAYER_ID}/query?where=INVALID%21%21%21&f=json" \ '.error != null' || failed=1 @@ -947,7 +958,7 @@ run_full_mapserver() { record_na_with_lane "ms-geom-02" "$proto" "CERT-GEOM-02" "Rendering-only lane; query not exercised" # CERT-ERRH-01: Invalid layer - request_json_4xx \ + request_esri_json_error \ "ms-error-invalid" \ "${ms_base}/99999?f=json" \ '.error != null' || failed=1 diff --git a/scripts/conformance/cite/run-cite-wms11-tests.sh b/scripts/conformance/cite/run-cite-wms11-tests.sh index e83f74f412..bf9d4cb11d 100755 --- a/scripts/conformance/cite/run-cite-wms11-tests.sh +++ b/scripts/conformance/cite/run-cite-wms11-tests.sh @@ -289,7 +289,16 @@ WMS_RECOMMENDED="$WMS_RECOMMENDED" \ WMS_GETFEATUREINFO="$WMS_GETFEATUREINFO" \ WMS_FEESCONSTRAINTS="$WMS_FEESCONSTRAINTS" \ WMS_BBOXCONSTRAINTS="$WMS_BBOXCONSTRAINTS" \ - $COMPOSE_CMD -f "$CITE_COMPOSE_FILE" --profile test up --force-recreate cite-runner + $COMPOSE_CMD -f "$CITE_COMPOSE_FILE" --profile test up --force-recreate cite-runner || runner_exit=$? + +# The TeamEngine container can return a non-zero process status after writing +# complete result artifacts. The normalized result files below are the +# authoritative outcome: preserve the raw status for diagnostics, then decide +# success only after parsing those artifacts. +runner_exit=${runner_exit:-0} +if [[ $runner_exit -ne 0 ]]; then + echo -e "${YELLOW}CITE runner exited with status ${runner_exit}; validating extracted results before failing.${NC}" +fi echo -e "${YELLOW}Waiting for CITE tests to complete...${NC}" start_time=$(date +%s) diff --git a/src/Honua.Oracle/Features/FeatureStore/OracleFeatureStore.cs b/src/Honua.Oracle/Features/FeatureStore/OracleFeatureStore.cs index 1b3e627d55..f80fec19ba 100644 --- a/src/Honua.Oracle/Features/FeatureStore/OracleFeatureStore.cs +++ b/src/Honua.Oracle/Features/FeatureStore/OracleFeatureStore.cs @@ -157,8 +157,8 @@ public async Task> QueryAsync(int layerId, FeatureQuery que public async Task> QueryObjectIdsAsync(int layerId, FeatureQuery query, CancellationToken cancellationToken = default) { await EnsureNoPermanentFilterAsync(layerId, cancellationToken).ConfigureAwait(false); - var (mapping, _) = await ResolveLayerAsync(layerId, cancellationToken).ConfigureAwait(false); - var sql = OracleFeatureQueryBuilder.BuildObjectIdsQuery(mapping, query); + var (mapping, attributeColumns) = await ResolveLayerAsync(layerId, cancellationToken).ConfigureAwait(false); + var sql = OracleFeatureQueryBuilder.BuildObjectIdsQuery(mapping, query, attributeColumns); return await _dataAccess.ExecuteObjectIdsAsync(mapping, sql, _boundConnection, cancellationToken).ConfigureAwait(false); } @@ -166,8 +166,8 @@ public async Task> QueryObjectIdsAsync(int layerId, Feature public async Task CountAsync(int layerId, FeatureQuery query, CancellationToken cancellationToken = default) { await EnsureNoPermanentFilterAsync(layerId, cancellationToken).ConfigureAwait(false); - var (mapping, _) = await ResolveLayerAsync(layerId, cancellationToken).ConfigureAwait(false); - var sql = OracleFeatureQueryBuilder.BuildCountQuery(mapping, query); + var (mapping, attributeColumns) = await ResolveLayerAsync(layerId, cancellationToken).ConfigureAwait(false); + var sql = OracleFeatureQueryBuilder.BuildCountQuery(mapping, query, attributeColumns); return await _dataAccess.ExecuteCountAsync(mapping, sql, _boundConnection, cancellationToken).ConfigureAwait(false); } @@ -175,8 +175,8 @@ public async Task CountAsync(int layerId, FeatureQuery query, Cancellation public async Task GetExtentAsync(int layerId, FeatureQuery? query = null, CancellationToken cancellationToken = default) { await EnsureNoPermanentFilterAsync(layerId, cancellationToken).ConfigureAwait(false); - var (mapping, _) = await ResolveLayerAsync(layerId, cancellationToken).ConfigureAwait(false); - var sql = OracleFeatureQueryBuilder.BuildExtentQuery(mapping, query); + var (mapping, attributeColumns) = await ResolveLayerAsync(layerId, cancellationToken).ConfigureAwait(false); + var sql = OracleFeatureQueryBuilder.BuildExtentQuery(mapping, query, attributeColumns); return await _dataAccess.ExecuteExtentAsync(mapping, sql, _boundConnection, cancellationToken).ConfigureAwait(false); } diff --git a/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs b/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs index d9c30fa2d0..127cc90981 100644 --- a/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs +++ b/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs @@ -36,6 +36,7 @@ public static ParameterizedQuery BuildSelectQuery(OracleLayerMapping mapping, Fe var sb = new StringBuilder(); var parameters = new List(); + var resolveColumnName = CreateColumnNameResolver(mapping, attributeColumns); sb.Append("SELECT ").Append(mapping.QuotedPrimaryKeyColumn).Append(" AS \"__objectid\""); @@ -46,10 +47,10 @@ public static ParameterizedQuery BuildSelectQuery(OracleLayerMapping mapping, Fe sb.Append(" FROM ").Append(mapping.QuotedTableReference); sb.Append(" WHERE 1=1"); - AppendWhereClause(sb, query, parameters); + AppendWhereClause(sb, query, parameters, resolveColumnName); AppendObjectIdsFilter(sb, mapping, query, parameters); AppendSpatialFilter(sb, mapping, query, parameters); - AppendOrderByClause(sb, query); + AppendOrderByClause(sb, query, resolveColumnName); AppendPagination(sb, mapping, query, parameters); return new ParameterizedQuery(sb.ToString(), parameters); @@ -58,18 +59,22 @@ public static ParameterizedQuery BuildSelectQuery(OracleLayerMapping mapping, Fe /// /// Builds a SELECT COUNT(*) for the same query envelope as . /// - public static ParameterizedQuery BuildCountQuery(OracleLayerMapping mapping, FeatureQuery query) + public static ParameterizedQuery BuildCountQuery( + OracleLayerMapping mapping, + FeatureQuery query, + IReadOnlyList? attributeColumns = null) { ArgumentNullException.ThrowIfNull(mapping); GuardUnsupportedTemporalFilter(query); var sb = new StringBuilder(); var parameters = new List(); + var resolveColumnName = CreateColumnNameResolver(mapping, attributeColumns); sb.Append("SELECT COUNT(*) FROM ").Append(mapping.QuotedTableReference); sb.Append(" WHERE 1=1"); - AppendWhereClause(sb, query, parameters); + AppendWhereClause(sb, query, parameters, resolveColumnName); AppendObjectIdsFilter(sb, mapping, query, parameters); AppendSpatialFilter(sb, mapping, query, parameters); @@ -80,22 +85,26 @@ public static ParameterizedQuery BuildCountQuery(OracleLayerMapping mapping, Fea /// Builds a SELECT that returns the primary key for matching rows. Used for object-id /// listings when only identifiers are needed. /// - public static ParameterizedQuery BuildObjectIdsQuery(OracleLayerMapping mapping, FeatureQuery query) + public static ParameterizedQuery BuildObjectIdsQuery( + OracleLayerMapping mapping, + FeatureQuery query, + IReadOnlyList? attributeColumns = null) { ArgumentNullException.ThrowIfNull(mapping); GuardUnsupportedTemporalFilter(query); var sb = new StringBuilder(); var parameters = new List(); + var resolveColumnName = CreateColumnNameResolver(mapping, attributeColumns); sb.Append("SELECT ").Append(mapping.QuotedPrimaryKeyColumn).Append(" AS \"__objectid\""); sb.Append(" FROM ").Append(mapping.QuotedTableReference); sb.Append(" WHERE 1=1"); - AppendWhereClause(sb, query, parameters); + AppendWhereClause(sb, query, parameters, resolveColumnName); AppendObjectIdsFilter(sb, mapping, query, parameters); AppendSpatialFilter(sb, mapping, query, parameters); - AppendOrderByClause(sb, query); + AppendOrderByClause(sb, query, resolveColumnName); AppendPagination(sb, mapping, query, parameters); return new ParameterizedQuery(sb.ToString(), parameters); @@ -106,7 +115,10 @@ public static ParameterizedQuery BuildObjectIdsQuery(OracleLayerMapping mapping, /// SDO_GEOMETRY whose SDO_ORDINATES varray contains (minX, minY, maxX, maxY) /// in the source CRS. /// - public static ParameterizedQuery BuildExtentQuery(OracleLayerMapping mapping, FeatureQuery? query) + public static ParameterizedQuery BuildExtentQuery( + OracleLayerMapping mapping, + FeatureQuery? query, + IReadOnlyList? attributeColumns = null) { ArgumentNullException.ThrowIfNull(mapping); @@ -118,6 +130,7 @@ public static ParameterizedQuery BuildExtentQuery(OracleLayerMapping mapping, Fe var sb = new StringBuilder(); var parameters = new List(); + var resolveColumnName = CreateColumnNameResolver(mapping, attributeColumns); var geometryColumn = mapping.QuotedGeometryColumn!; sb.Append("SELECT "); @@ -131,7 +144,7 @@ public static ParameterizedQuery BuildExtentQuery(OracleLayerMapping mapping, Fe var effective = query ?? new FeatureQuery(); GuardUnsupportedTemporalFilter(effective); - AppendWhereClause(sb, effective, parameters); + AppendWhereClause(sb, effective, parameters, resolveColumnName); AppendObjectIdsFilter(sb, mapping, effective, parameters); AppendSpatialFilter(sb, mapping, effective, parameters); @@ -192,7 +205,45 @@ private static void AppendAttributeColumns(StringBuilder sb, FeatureQuery query, } } - private static void AppendWhereClause(StringBuilder sb, FeatureQuery query, List parameters) + private static Func CreateColumnNameResolver( + OracleLayerMapping mapping, + IReadOnlyList? attributeColumns) + { + // Oracle folds unquoted DDL identifiers to upper-case, whereas protocol requests + // commonly use lower-case field names. We always quote identifiers to keep the + // configured physical name exact (including legitimately quoted mixed-case names), + // so resolve request fields against the catalog's physical names before quoting. + // Without this step `where=name` becomes `"name"`, which cannot address an + // unquoted Oracle NAME column and fails with ORA-00904. + var physicalNames = new List + { + mapping.PrimaryKeyColumn + }; + + if (!string.IsNullOrWhiteSpace(mapping.GeometryColumn)) + { + physicalNames.Add(mapping.GeometryColumn); + } + + if (attributeColumns is not null) + { + physicalNames.AddRange(attributeColumns); + } + + return requested => + { + ArgumentException.ThrowIfNullOrWhiteSpace(requested); + var match = physicalNames.FirstOrDefault(name => + string.Equals(name, requested, StringComparison.OrdinalIgnoreCase)); + return match ?? requested; + }; + } + + private static void AppendWhereClause( + StringBuilder sb, + FeatureQuery query, + List parameters, + Func resolveColumnName) { // Only the canonical Where text is consumed here. The shared ISqlFilterTranslator // pipeline registers a PostgreSQL translator (FeatureQuery.SqlFilter is therefore @@ -201,7 +252,7 @@ private static void AppendWhereClause(StringBuilder sb, FeatureQuery query, List // Where is rejected up front rather than masked as an opaque ORA-* failure. if (!string.IsNullOrWhiteSpace(query.Where)) { - var parameterized = ParseAndParameterizeWhereClause(query.Where!.Trim(), parameters); + var parameterized = ParseAndParameterizeWhereClause(query.Where!.Trim(), parameters, resolveColumnName); sb.Append(" AND (").Append(parameterized).Append(')'); return; } @@ -294,7 +345,7 @@ private static void AppendSpatialFilter(StringBuilder sb, OracleLayerMapping map sb.Append(" AND ").Append(clause); } - private static void AppendOrderByClause(StringBuilder sb, FeatureQuery query) + private static void AppendOrderByClause(StringBuilder sb, FeatureQuery query, Func resolveColumnName) { if (!query.OrderBy.HasValue || query.OrderBy.Value.IsDefaultOrEmpty) { @@ -304,9 +355,10 @@ private static void AppendOrderByClause(StringBuilder sb, FeatureQuery query) var clauses = new List(query.OrderBy.Value.Length); foreach (var orderBy in query.OrderBy.Value) { - OracleIdentifier.EnsureValid(orderBy.Field, "order-by column"); + var column = resolveColumnName(orderBy.Field); + OracleIdentifier.EnsureValid(column, "order-by column"); var direction = orderBy.Ascending ? "ASC" : "DESC"; - clauses.Add($"{OracleIdentifier.Quote(orderBy.Field)} {direction}"); + clauses.Add($"{OracleIdentifier.Quote(column)} {direction}"); } sb.Append(" ORDER BY ").Append(string.Join(", ", clauses)); @@ -337,7 +389,10 @@ private static void AppendPagination(StringBuilder sb, OracleLayerMapping mappin } } - private static string ParseAndParameterizeWhereClause(string whereClause, List parameters) + private static string ParseAndParameterizeWhereClause( + string whereClause, + List parameters, + Func resolveColumnName) { var expressions = SplitOnAnd(whereClause); if (expressions.Count == 0) @@ -366,7 +421,7 @@ private static string ParseAndParameterizeWhereClause(string whereClause, List(); foreach (Match valueMatch in InValueRegex().Matches(inMatch.Groups["values"].Value)) @@ -392,7 +447,7 @@ private static string ParseAndParameterizeWhereClause(string whereClause, List { - private static readonly string[] Script002Only = ["002_drop_legacy_annotated.sql"]; - private static readonly string[] Script004Only = ["004_drop_note_annotated.sql"]; - private const string ExpandScript = """ CREATE TABLE honua_ci_demo ( @@ -222,10 +219,11 @@ public async Task RunMigrations_UpgradeUnderGate_AppliesAnnotatedContractWhenApp ("001_expand.sql", ExpandScript), ("002_drop_legacy_annotated.sql", AnnotatedContractScript)); - // The approval nonce is bound to the exact pending contract script name (DbUp journals by name, - // which SyntheticMigrationsCompiler emits verbatim), so the operator supplies the digest printed - // in the block message. - var nonce = MigrationSafetyClassifier.ComputeContractApprovalNonce(Script002Only); + // DbUp keys embedded migrations by their manifest-resource name. The synthetic compiler prefixes + // that name with the generated assembly name, so mint the approval nonce from the same name the + // runner discovers and journals. + var nonce = MigrationSafetyClassifier.ComputeContractApprovalNonce( + [GetEmbeddedScriptName(assemblyName, "002_drop_legacy_annotated.sql")]); var runner = CreateRunner( new MigrationSafetyOptions { @@ -257,7 +255,8 @@ public async Task RunMigrations_UpgradeUnderGate_StaleNonceDoesNotApproveLaterCo // First upgrade: drop legacy_name (contract 002) and add a note column (expand 003). The pending // annotated-contract set is just {002}, so the approval nonce is bound to that single script. - var staleNonce = MigrationSafetyClassifier.ComputeContractApprovalNonce(Script002Only); + var staleNonce = MigrationSafetyClassifier.ComputeContractApprovalNonce( + [GetEmbeddedScriptName(assemblyName, "002_drop_legacy_annotated.sql")]); var firstUpgrade = SyntheticMigrationsCompiler.Compile( assemblyName, ("001_expand.sql", ExpandScript), @@ -290,7 +289,8 @@ public async Task RunMigrations_UpgradeUnderGate_StaleNonceDoesNotApproveLaterCo .Should().BeTrue("the stale nonce must not have applied the later contract migration"); // The freshly-minted nonce for 004 approves it — the gate is not permanently stuck. - var freshNonce = MigrationSafetyClassifier.ComputeContractApprovalNonce(Script004Only); + var freshNonce = MigrationSafetyClassifier.ComputeContractApprovalNonce( + [GetEmbeddedScriptName(assemblyName, "004_drop_note_annotated.sql")]); var approved = await CreateRunner( new MigrationSafetyOptions { Enforce = true, ContractApplyPolicy = ContractApplyPolicy.Gate }, approvalToken: freshNonce) @@ -426,6 +426,9 @@ private async Task ApplyExpandBaselineAsync(string connectionString, string asse private static PostgresDatabaseMigrationRunner CreateEnforcingRunner() => new(Options.Create(new MigrationSafetyOptions { Enforce = true })); + private static string GetEmbeddedScriptName(string assemblyName, string scriptName) + => $"{assemblyName}.{scriptName}"; + private static PostgresDatabaseMigrationRunner CreateRunner( MigrationSafetyOptions options, string? approvalToken = null, diff --git a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs index 5588c4b6a5..5602c51f76 100644 --- a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs +++ b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs @@ -107,6 +107,16 @@ public void BuildSelectQuery_WhereWithLiteralAndNullCheck_ProducesParameterizedS Assert.Equal("Alpha", result.WhereParameters[0]); } + [Fact] + public void BuildSelectQuery_WhereFieldCasingDiffersFromCatalog_UsesPhysicalOracleIdentifier() + { + var query = new FeatureQuery { Where = "name = 'Alpha'" }; + + var result = OracleFeatureQueryBuilder.BuildSelectQuery(BuildMapping(), query, ["NAME"]); + + Assert.Contains("\"NAME\" = :p0", result.Sql, StringComparison.Ordinal); + } + [Fact] public void BuildSelectQuery_StacCandidateInWhere_UsesOracleParameters() { From 639d37449fb8da5e9df4b12b7641ba4c6c5ac581 Mon Sep 17 00:00:00 2001 From: eval Date: Sat, 8 Aug 2026 05:34:34 -1000 Subject: [PATCH 02/11] chore(ci): remove unused ArcGIS Pro evidence workflow --- .../workflows/arcgis-pro-desktop-evidence.yml | 248 ------------------ 1 file changed, 248 deletions(-) delete mode 100644 .github/workflows/arcgis-pro-desktop-evidence.yml diff --git a/.github/workflows/arcgis-pro-desktop-evidence.yml b/.github/workflows/arcgis-pro-desktop-evidence.yml deleted file mode 100644 index e06f8ca4fa..0000000000 --- a/.github/workflows/arcgis-pro-desktop-evidence.yml +++ /dev/null @@ -1,248 +0,0 @@ -name: Licensed ArcGIS Pro Desktop Evidence - -# Tier: nightly/manual evidence. This workflow never runs on pull_request. -# It only schedules the self-hosted Windows job when explicitly enabled, so -# ordinary PR gates do not require ArcGIS Pro or Esri licensing. - -on: - schedule: - - cron: '35 8 * * 1' - workflow_dispatch: - inputs: - run_licensed_lane: - description: 'Run the self-hosted ArcGIS Pro lane now' - required: true - type: choice - default: 'false' - options: - - 'false' - - 'true' - honua_base_url: - description: 'Seeded Honua base URL reachable from the Windows runner' - required: false - default: '' - service_id: - description: 'Seeded GeoServices service id' - required: false - default: 'browser_compat' - layer_id: - description: 'Point layer id' - required: false - default: '2000' - line_layer_id: - description: 'Line layer id' - required: false - default: '2001' - polygon_layer_id: - description: 'Polygon layer id' - required: false - default: '2002' - project_template_path: - description: 'Runner-local blank .aprx template path for standalone ProPy' - required: false - default: '' - layout_name: - description: 'Optional layout name to export for headless screenshot evidence' - required: false - default: '' - map_frame_name: - description: 'Optional map frame name to export for headless screenshot evidence' - required: false - default: '' - -permissions: - contents: read - -concurrency: - group: arcgis-pro-desktop-evidence-${{ github.ref }} - cancel-in-progress: true - -jobs: - preflight: - name: Resolve licensed-lane inputs - runs-on: ubuntu-24.04 - timeout-minutes: 2 - outputs: - enabled: ${{ steps.resolve.outputs.enabled }} - base_url: ${{ steps.resolve.outputs.base_url }} - service_id: ${{ steps.resolve.outputs.service_id }} - layer_id: ${{ steps.resolve.outputs.layer_id }} - line_layer_id: ${{ steps.resolve.outputs.line_layer_id }} - polygon_layer_id: ${{ steps.resolve.outputs.polygon_layer_id }} - project_template_path: ${{ steps.resolve.outputs.project_template_path }} - layout_name: ${{ steps.resolve.outputs.layout_name }} - map_frame_name: ${{ steps.resolve.outputs.map_frame_name }} - steps: - - name: Resolve inputs - id: resolve - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - DISPATCH_ENABLED: ${{ github.event.inputs.run_licensed_lane || 'false' }} - VAR_ENABLED: ${{ vars.ARCGIS_PRO_EVIDENCE_ENABLED || 'false' }} - INPUT_BASE_URL: ${{ github.event.inputs.honua_base_url || '' }} - VAR_BASE_URL: ${{ vars.ARCGIS_PRO_EVIDENCE_BASE_URL || '' }} - INPUT_SERVICE_ID: ${{ github.event.inputs.service_id || '' }} - VAR_SERVICE_ID: ${{ vars.ARCGIS_PRO_EVIDENCE_SERVICE_ID || '' }} - INPUT_LAYER_ID: ${{ github.event.inputs.layer_id || '' }} - VAR_LAYER_ID: ${{ vars.ARCGIS_PRO_EVIDENCE_LAYER_ID || '' }} - INPUT_LINE_LAYER_ID: ${{ github.event.inputs.line_layer_id || '' }} - VAR_LINE_LAYER_ID: ${{ vars.ARCGIS_PRO_EVIDENCE_LINE_LAYER_ID || '' }} - INPUT_POLYGON_LAYER_ID: ${{ github.event.inputs.polygon_layer_id || '' }} - VAR_POLYGON_LAYER_ID: ${{ vars.ARCGIS_PRO_EVIDENCE_POLYGON_LAYER_ID || '' }} - INPUT_PROJECT_TEMPLATE: ${{ github.event.inputs.project_template_path || '' }} - VAR_PROJECT_TEMPLATE: ${{ vars.ARCGIS_PRO_PROJECT_TEMPLATE || '' }} - INPUT_LAYOUT_NAME: ${{ github.event.inputs.layout_name || '' }} - VAR_LAYOUT_NAME: ${{ vars.ARCGIS_PRO_LAYOUT_NAME || '' }} - INPUT_MAP_FRAME_NAME: ${{ github.event.inputs.map_frame_name || '' }} - VAR_MAP_FRAME_NAME: ${{ vars.ARCGIS_PRO_MAP_FRAME_NAME || '' }} - run: | - set -euo pipefail - - enabled=false - if [[ "$EVENT_NAME" == "workflow_dispatch" && "$DISPATCH_ENABLED" == "true" ]]; then - enabled=true - elif [[ "$EVENT_NAME" == "schedule" && "$VAR_ENABLED" == "true" ]]; then - enabled=true - fi - - base_url="${INPUT_BASE_URL:-$VAR_BASE_URL}" - service_id="${INPUT_SERVICE_ID:-${VAR_SERVICE_ID:-browser_compat}}" - layer_id="${INPUT_LAYER_ID:-${VAR_LAYER_ID:-2000}}" - line_layer_id="${INPUT_LINE_LAYER_ID:-${VAR_LINE_LAYER_ID:-2001}}" - polygon_layer_id="${INPUT_POLYGON_LAYER_ID:-${VAR_POLYGON_LAYER_ID:-2002}}" - project_template_path="${INPUT_PROJECT_TEMPLATE:-$VAR_PROJECT_TEMPLATE}" - layout_name="${INPUT_LAYOUT_NAME:-$VAR_LAYOUT_NAME}" - map_frame_name="${INPUT_MAP_FRAME_NAME:-$VAR_MAP_FRAME_NAME}" - - if [[ "$enabled" == "true" ]]; then - if [[ -z "$base_url" ]]; then - echo "::error::ArcGIS Pro evidence is enabled but no Honua base URL was provided." - exit 1 - fi - if [[ -z "$project_template_path" ]]; then - echo "::error::ArcGIS Pro evidence is enabled but no runner-local .aprx project template path was provided." - exit 1 - fi - else - echo "Licensed ArcGIS Pro lane is disabled. Set workflow_dispatch run_licensed_lane=true or ARCGIS_PRO_EVIDENCE_ENABLED=true." - fi - - { - echo "enabled=$enabled" - echo "base_url=$base_url" - echo "service_id=$service_id" - echo "layer_id=$layer_id" - echo "line_layer_id=$line_layer_id" - echo "polygon_layer_id=$polygon_layer_id" - echo "project_template_path=$project_template_path" - echo "layout_name=$layout_name" - echo "map_frame_name=$map_frame_name" - } >> "$GITHUB_OUTPUT" - - arcgis-pro-evidence: - name: Licensed ArcGIS Pro desktop run - needs: preflight - if: needs.preflight.outputs.enabled == 'true' - runs-on: [self-hosted, Windows, arcgis-pro] - timeout-minutes: 75 - env: - HONUA_BASE_URL: ${{ needs.preflight.outputs.base_url }} - HONUA_SERVICE_ID: ${{ needs.preflight.outputs.service_id }} - HONUA_LAYER_ID: ${{ needs.preflight.outputs.layer_id }} - HONUA_LINE_LAYER_ID: ${{ needs.preflight.outputs.line_layer_id }} - HONUA_POLYGON_LAYER_ID: ${{ needs.preflight.outputs.polygon_layer_id }} - ARCGIS_PRO_PROJECT_TEMPLATE: ${{ needs.preflight.outputs.project_template_path }} - ARCGIS_PRO_LAYOUT_NAME: ${{ needs.preflight.outputs.layout_name }} - ARCGIS_PRO_MAP_FRAME_NAME: ${{ needs.preflight.outputs.map_frame_name }} - HONUA_API_KEY: ${{ secrets.HONUA_ARCGIS_PRO_API_KEY }} - HONUA_AUTHORIZATION: ${{ secrets.HONUA_ARCGIS_PRO_AUTHORIZATION }} - CERT_RUN_ID: ${{ github.run_id }} - HONUA_ARCGIS_PRO_OUTPUT_DIR: artifacts/arcgis-pro-desktop/${{ github.run_id }} - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Locate ArcGIS Pro Python - id: propy - shell: pwsh - run: | - $candidates = @( - "$env:ProgramFiles\ArcGIS\Pro\bin\Python\Scripts\propy.bat", - "$env:ProgramFiles\ArcGIS\Pro\bin\Python\envs\arcgispro-py3\python.exe" - ) - $propy = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $propy) { - throw "ArcGIS Pro Python was not found. Install ArcGIS Pro on this self-hosted runner." - } - "propy=$propy" >> $env:GITHUB_OUTPUT - - - name: Validate runner-local project template - shell: pwsh - run: | - if (-not (Test-Path $env:ARCGIS_PRO_PROJECT_TEMPLATE)) { - throw "Project template path does not exist on this runner: $env:ARCGIS_PRO_PROJECT_TEMPLATE" - } - - - name: Run licensed ArcGIS Pro evidence - id: evidence - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path $env:HONUA_ARCGIS_PRO_OUTPUT_DIR | Out-Null - & "${{ steps.propy.outputs.propy }}" ` - scripts/client-compat/arcgis-pro/run-arcgis-pro-evidence.py ` - --base-url "$env:HONUA_BASE_URL" ` - --service-id "$env:HONUA_SERVICE_ID" ` - --layer-id "$env:HONUA_LAYER_ID" ` - --line-layer-id "$env:HONUA_LINE_LAYER_ID" ` - --polygon-layer-id "$env:HONUA_POLYGON_LAYER_ID" ` - --project-template "$env:ARCGIS_PRO_PROJECT_TEMPLATE" ` - --layout-name "$env:ARCGIS_PRO_LAYOUT_NAME" ` - --map-frame-name "$env:ARCGIS_PRO_MAP_FRAME_NAME" ` - --output-dir "$env:HONUA_ARCGIS_PRO_OUTPUT_DIR" ` - --run-id "$env:CERT_RUN_ID" ` - --environment ci - - - name: Guard against credential leakage - if: always() - shell: pwsh - run: | - $root = $env:HONUA_ARCGIS_PRO_OUTPUT_DIR - if (-not (Test-Path $root)) { - Write-Host "No evidence directory to scan." - exit 0 - } - - $secretValues = @( - $env:HONUA_API_KEY, - $env:HONUA_AUTHORIZATION - ) | Where-Object { $_ -and $_.Length -ge 4 } - - foreach ($secret in $secretValues) { - $matches = Get-ChildItem -Path $root -Recurse -File | - Where-Object { $_.Extension -in @('.json', '.log', '.md', '.txt') } | - Select-String -SimpleMatch -Pattern $secret -List - if ($matches) { - $paths = ($matches | ForEach-Object { $_.Path }) -join ', ' - throw "Evidence artifact contains an unredacted secret in: $paths" - } - } - - - name: Validate cert envelopes - if: always() - shell: pwsh - run: | - & "${{ steps.propy.outputs.propy }}" ` - scripts/client-compat/arcgis-pro/run-arcgis-pro-evidence.py ` - --validate-output "$env:HONUA_ARCGIS_PRO_OUTPUT_DIR" ` - --require-live-artifacts - - - name: Upload licensed ArcGIS Pro evidence - if: always() - uses: ./.github/actions/upload-ci-evidence - with: - kind: evidence - suffix: arcgis-pro-desktop - path: ${{ env.HONUA_ARCGIS_PRO_OUTPUT_DIR }} - tier: nightly - summary-file: ${{ env.HONUA_ARCGIS_PRO_OUTPUT_DIR }}/summary.md From 2e012d0e79ee763317b03df5e795880568c16565 Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Sat, 8 Aug 2026 21:20:09 -1000 Subject: [PATCH 03/11] docs(ci): remove stale ArcGIS Pro workflow references (#1372) --- .../CROSS_CLIENT_CERTIFICATION_EVIDENCE.md | 2 +- docs/internal/ci/workflow-inventory.md | 1 - .../evidence/ARCGIS_PRO_LICENSED_EVIDENCE.md | 26 +++++++++---------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/gis/CROSS_CLIENT_CERTIFICATION_EVIDENCE.md b/docs/gis/CROSS_CLIENT_CERTIFICATION_EVIDENCE.md index 78d33efbce..1b152fd204 100644 --- a/docs/gis/CROSS_CLIENT_CERTIFICATION_EVIDENCE.md +++ b/docs/gis/CROSS_CLIENT_CERTIFICATION_EVIDENCE.md @@ -232,7 +232,7 @@ This section describes how each evidence source will map to the evidence envelop | Playwright MapLibre suite | JS (MVT) | Automated: `tests/js-browser/maplibre/` renders MapLibre GL JS against live TileJSON/MVT endpoints and emits a `-js-mvt.cert.json` envelope via the custom reporter. See [MapLibre MVT automated workflow](#maplibre-mvt-automated-workflow) below. A manual fallback remains documented for ad-hoc visual verification. | | Playwright CesiumJS suite | JS — Cesium (`js-cesium`) | Automated: `tests/js-browser/cesium/` exercises CesiumJS imagery providers (WMS, WMTS, OGC API Tiles, OGC API Maps) under `docker/client-compat/cesium/` in `client-interop-nightly.yml`; emits one envelope per protocol (`-js-cesium-{wms,wmts,ogc-tiles,ogc-maps}.cert.json`). Vector-feature CERT-\* IDs and the visual / style slice IDs are recorded as `not-applicable` because Cesium imagery providers consume server-rendered raster output. | | ArcGIS Pro REST stub | `arcgis-stub` | Automated stub: `docker/client-compat/arcgis-stub/stub_runner.py` issues both the FeatureServer and MapServer REST sequences Pro itself emits and writes one envelope per protocol (`*-arcgis-stub-featureserver.cert.json` and `*-arcgis-stub-mapserver.cert.json`). The FeatureServer-applicable render IDs `CERT-RNDR-01`, `CERT-RNDR-02`, and the visual / style slice IDs `CERT-RNDR-{SYM,LIN,FIL,LBL,URL}-01` are recorded as `skip` with note `pending: licensed-arcgis-runner` until a licensed Windows runner is provisioned. `CERT-RNDR-SPR-01` is MVT-only per the matrix and is emitted as `not-applicable` for this lane. | -| Licensed ArcGIS Pro desktop runner | `desktop-arcgis` | Manual/scheduled: `.github/workflows/arcgis-pro-desktop-evidence.yml` runs only on an explicitly enabled self-hosted Windows ArcGIS Pro runner and invokes `scripts/client-compat/arcgis-pro/run-arcgis-pro-evidence.py`. The runner emits `*-desktop-arcgis-featureserver.cert.json` and `*-desktop-arcgis-mapserver.cert.json`, captures logs/screenshots/project artifacts, exports render evidence through an active view or headless layout/map-frame fallback, writes an `artifact-manifest.json`, and keeps this evidence distinct from the REST-only `arcgis-stub` lane. Ordinary PR gates validate only the fixture/envelope contract; they do not require ArcGIS Pro. See [Licensed ArcGIS Pro Desktop Evidence](../internal/evidence/ARCGIS_PRO_LICENSED_EVIDENCE.md). | +| Licensed ArcGIS Pro desktop runner | `desktop-arcgis` | Maintained externally in [`honua-esri-compat`](https://github.com/honua-io/honua-esri-compat), where the licensed Windows runner can execute without coupling this repository's nightly schedule to unavailable self-hosted capacity. The retained local runner contract, `scripts/client-compat/arcgis-pro/run-arcgis-pro-evidence.py`, emits `*-desktop-arcgis-featureserver.cert.json` and `*-desktop-arcgis-mapserver.cert.json`, captures logs/screenshots/project artifacts, exports render evidence through an active view or headless layout/map-frame fallback, writes an `artifact-manifest.json`, and keeps this evidence distinct from the REST-only `arcgis-stub` lane. Ordinary PR gates validate only the fixture/envelope contract; they do not require ArcGIS Pro. See [Licensed ArcGIS Pro Desktop Evidence](../internal/evidence/ARCGIS_PRO_LICENSED_EVIDENCE.md). | | GDAL/OGR pytest suite | CLI (`cli` via converter) | Automated: `tests/python/gdal_ogr/conftest.py:EvidenceCollector` writes `gdal-ogr-results.json`; the `gdal` lane runner invokes `scripts/client-compat/convert-gdal-results.py` to emit one cert envelope per protocol (`-cli-gdal-ogc-features.cert.json`, `-cli-gdal-wfs.cert.json`). The converter maps the GDAL category labels many-to-one onto CERT-* IDs with worst-status aggregation (`fail > pass > skip > not-applicable`): `discovery`/`feature_count` → CERT-DISC-01, `schema_introspection` → CERT-SCHM-01, `feature_read`/`read` → CERT-CONN-01, `attribute_query`/`spatial_query`/`query` → CERT-QFLT-01. The `export_*` categories are intentionally unmapped per the matrix CLI/SDK lane row (which excludes CERT-RNDR), so CERT-RNDR-01 and CERT-RNDR-02 are recorded as `not-applicable` rather than certified by an ogr2ogr export. Unknown labels surface as `::warning::` so test-side additions stay visible. | ### Manual Lane Workflow diff --git a/docs/internal/ci/workflow-inventory.md b/docs/internal/ci/workflow-inventory.md index 4a1346cb0a..9722f8ee67 100644 --- a/docs/internal/ci/workflow-inventory.md +++ b/docs/internal/ci/workflow-inventory.md @@ -30,7 +30,6 @@ | `warehouse-nightly.yml` | Warehouse Providers Nightly (Creds-Gated) | weekly | `schedule`, `workflow_dispatch` | No | Weekly Sunday 6:00 UTC (#2943); matrix over Honua.Snowflake/Redshift/Databricks/SqlServer.Tests, consuming optional repository secrets; surfaces passed/failed/skipped counts in the run summary so a missing secret reads as "not configured" rather than "silently absent from CI" | | `cross-server-consume-nightly.yml` | Cross-Server Consume Nightly | nightly | `schedule`, `workflow_dispatch` | No | Daily 7:00am UTC; runs Honua-as-client WMS/WFS/WMTS reads against reference GeoServer and MapServer containers via the Test-environment `/__test/cross-server-consume/proxy` endpoint, uploads TRX/report artifacts, and best-effort commits the refreshed gap report (warns instead of failing if push is blocked) | | `windows-client-compat-nightly.yml` | Windows Client Compatibility Certification | nightly | `schedule`, `workflow_dispatch` | No | Daily 7:15am UTC; full CERT-\* matrix (18 test cases × 4 protocol lanes: FeatureServer, OGC Features, MapServer, OData) with per-protocol `.cert.json` envelopes under `certification/`, plus `overall-summary.json`, per-lane transcripts, and `pack/`; supports `--profile smoke` (11-check MVP) and `--profile full` (default) | -| `arcgis-pro-desktop-evidence.yml` | Licensed ArcGIS Pro Desktop Evidence | nightly/manual | `schedule`, `workflow_dispatch` | No | Weekly scaffold for `desktop-arcgis` licensed evidence. The self-hosted Windows ArcGIS Pro job runs only when manually dispatched with `run_licensed_lane=true` or when `ARCGIS_PRO_EVIDENCE_ENABLED=true`; no PR trigger. Invokes the ArcPy runner against a seeded Honua FeatureServer/MapServer target, emits `desktop-arcgis` `.cert.json` envelopes, captures active-view or layout/map-frame screenshots, validates live evidence refs and redaction, writes an artifact manifest, and uploads nightly-retention evidence. | | `pyqgis-client-compat-nightly.yml` | PyQGIS Client Compatibility Certification | nightly | `schedule`, `workflow_dispatch` | No | Daily 7:30am UTC; PyQGIS desktop client compatibility using real QGIS providers against `client-compat-v1.sql`; produces `desktop-qgis-ogc-features.cert.json` and `desktop-qgis-wfs.cert.json` envelopes | | `sdk-server-compatibility.yml` | SDK Server Compatibility | nightly | `schedule`, `workflow_dispatch` | No | Manifest-driven last-3 server refs x last-3 SDK sets matrix from `docs/developer/sdk-compatibility-versions.json`; manual dispatch can pin `server_current_ref` for release-candidate evidence; checks out `honua-sdk-js`, `honua-sdk-python`, and `honua-sdk-dotnet`, copies them to `$RUNNER_TEMP/sdk-compat`, and runs live compatibility smoke checks from the isolated copies so server repo build policy does not affect SDK source builds; records package versions/server commit/seed profile/surfaces/migration automation status/diagnostics in per-cell JSON evidence, and publishes `sdk-compatibility-matrix-` with supported-cell regression failure | | `client-interop-nightly.yml` | Real-Client Interop Matrix (Nightly) | nightly | `schedule`, `workflow_dispatch` | No | Daily 7:00am UTC; runs the docker/client-compat matrix (`gdal`, `pyqgis`, `openlayers`, `cesium`, `arcgis-stub`) via Docker harnesses, diffs the per-lane `.cert.json` envelopes against `tests/baselines/client-compat/` (gated by `expected-pairs.json`), refreshes `docs/gis/gap-report.md`, and fails strict mode on any baseline `pass`→non-`pass` regression, missing current envelope, missing expected-pair, missing committed baseline, or new `fail` in an unbaselined case. Lane artifacts include `lane-exit-code.txt` and `compose.log` when a lane exits non-zero; workflow-dispatch subsets are scoped by `--client-lanes`. Promote to PR-blocking once 30 consecutive nightly passes are observed (#806) | diff --git a/docs/internal/evidence/ARCGIS_PRO_LICENSED_EVIDENCE.md b/docs/internal/evidence/ARCGIS_PRO_LICENSED_EVIDENCE.md index 2ac2a3a158..74a7dabca0 100644 --- a/docs/internal/evidence/ARCGIS_PRO_LICENSED_EVIDENCE.md +++ b/docs/internal/evidence/ARCGIS_PRO_LICENSED_EVIDENCE.md @@ -6,23 +6,23 @@ It is separate from the `arcgis-stub` REST lane: the stub proves Honua serves the ArcGIS REST request pattern, while this lane is reserved for licensed ArcGIS Pro / ArcPy automation. -This slice does not make ordinary PR gates depend on ArcGIS Pro. It adds the -runner contract, the manual/scheduled workflow entry point, artifact guardrails, -an ArcPy script that emits standard `.cert.json` envelopes, a headless -layout/map-frame screenshot fallback for ProPy runs, and a strict artifact -validator for licensed runs. +Ordinary PR gates do not depend on ArcGIS Pro. This repository retains the +runner contract, artifact guardrails, an ArcPy script that emits standard +`.cert.json` envelopes, a headless layout/map-frame screenshot fallback for +ProPy runs, and a strict artifact validator for licensed runs. Scheduled +licensed execution is maintained in +[`honua-esri-compat`](https://github.com/honua-io/honua-esri-compat), avoiding a +nightly workflow here that cannot run without dedicated self-hosted capacity. A successful licensed run still needs to be executed and linked before #1019 can be closed. -## Workflow +## Execution -Workflow file: -[`arcgis-pro-desktop-evidence.yml`](../../../.github/workflows/arcgis-pro-desktop-evidence.yml) - -Triggers: -- `workflow_dispatch` with `run_licensed_lane=true` -- weekly `schedule`, but only when repository variable - `ARCGIS_PRO_EVIDENCE_ENABLED=true` +The maintained licensed workflow lives in +[`honua-esri-compat`](https://github.com/honua-io/honua-esri-compat). This +repository intentionally has no scheduled ArcGIS Pro workflow; the local +scripts remain the evidence-production contract used by that harness and by +manual operator runs. The licensed job runs only on a self-hosted Windows runner with labels: From cba0f53ffc2b30fc69a884010d824d7b68ce9e48 Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Sat, 8 Aug 2026 21:45:57 -1000 Subject: [PATCH 04/11] fix(oracle): preserve exact quoted column matches (#1372) --- .../Services/OracleFeatureQueryBuilder.cs | 31 +++++++++++++++++-- .../OracleFeatureQueryBuilderTests.cs | 21 +++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs b/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs index 127cc90981..c1ca215c57 100644 --- a/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs +++ b/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs @@ -233,9 +233,34 @@ private static Func CreateColumnNameResolver( return requested => { ArgumentException.ThrowIfNullOrWhiteSpace(requested); - var match = physicalNames.FirstOrDefault(name => - string.Equals(name, requested, StringComparison.OrdinalIgnoreCase)); - return match ?? requested; + var exactMatch = physicalNames.FirstOrDefault(name => + string.Equals(name, requested, StringComparison.Ordinal)); + if (exactMatch is not null) + { + return exactMatch; + } + + string? foldedMatch = null; + foreach (var physicalName in physicalNames) + { + if (!string.Equals(physicalName, requested, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (foldedMatch is not null && + !string.Equals(foldedMatch, physicalName, StringComparison.Ordinal)) + { + // Quoted Oracle identifiers may legitimately differ only by case. An + // inexact request cannot choose between them without targeting the wrong + // physical column, so leave it unchanged and let Oracle fail closed. + return requested; + } + + foldedMatch = physicalName; + } + + return foldedMatch ?? requested; }; } diff --git a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs index 5602c51f76..6eaacb3e15 100644 --- a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs +++ b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs @@ -117,6 +117,27 @@ public void BuildSelectQuery_WhereFieldCasingDiffersFromCatalog_UsesPhysicalOrac Assert.Contains("\"NAME\" = :p0", result.Sql, StringComparison.Ordinal); } + [Fact] + public void BuildSelectQuery_WhereFieldExactlyMatchesCaseDistinctColumn_PreservesExactIdentifier() + { + var query = new FeatureQuery { Where = "name = 'Alpha'" }; + + var result = OracleFeatureQueryBuilder.BuildSelectQuery(BuildMapping(), query, ["NAME", "name"]); + + Assert.Contains("\"name\" = :p0", result.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("\"NAME\" = :p0", result.Sql, StringComparison.Ordinal); + } + + [Fact] + public void BuildSelectQuery_WhereFieldAmbiguouslyMatchesCaseDistinctColumns_FailsClosed() + { + var query = new FeatureQuery { Where = "Name = 'Alpha'" }; + + var result = OracleFeatureQueryBuilder.BuildSelectQuery(BuildMapping(), query, ["NAME", "name"]); + + Assert.Contains("\"Name\" = :p0", result.Sql, StringComparison.Ordinal); + } + [Fact] public void BuildSelectQuery_StacCandidateInWhere_UsesOracleParameters() { From 017d79592cb586bd882993380b7fb11da8769faa Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Sat, 8 Aug 2026 22:03:11 -1000 Subject: [PATCH 05/11] fix(ci): reject incomplete CITE WMS runs (#1372) --- scripts/conformance/cite/run-cite-wms11-tests.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/cite/run-cite-wms11-tests.sh b/scripts/conformance/cite/run-cite-wms11-tests.sh index bf9d4cb11d..ae0bdeb932 100755 --- a/scripts/conformance/cite/run-cite-wms11-tests.sh +++ b/scripts/conformance/cite/run-cite-wms11-tests.sh @@ -332,6 +332,7 @@ echo -e "\n${BLUE}CITE Test Results Analysis${NC}" echo "===============================" RESULTS_FOUND=false +COMPLETE_RESULTS=false if [[ -d "$CITE_RESULTS_DIR" && $(ls -A "$CITE_RESULTS_DIR" 2>/dev/null) ]]; then RESULTS_FOUND=true echo "Results saved to: $CITE_RESULTS_DIR/" @@ -344,6 +345,11 @@ if [[ -d "$CITE_RESULTS_DIR" && $(ls -A "$CITE_RESULTS_DIR" 2>/dev/null) ]]; the FAILED_TESTS=$(sed -n 's/.*failed="\([0-9]\+\)".*/\1/p' "$RESULTS_XML" | head -n 1) SKIPPED_TESTS=$(sed -n 's/.*skipped="\([0-9]\+\)".*/\1/p' "$RESULTS_XML" | head -n 1) CANTTELL_TESTS=0 + if [[ "$TOTAL_TESTS" =~ ^[0-9]+$ && "$PASSED_TESTS" =~ ^[0-9]+$ && + "$FAILED_TESTS" =~ ^[0-9]+$ && "$SKIPPED_TESTS" =~ ^[0-9]+$ ]] && + grep -q '' "$RESULTS_XML"; then + COMPLETE_RESULTS=true + fi else SESSION_DIR=$(find "$CITE_RESULTS_DIR" -maxdepth 1 -type d -name "cite-wms11-session-*" | sort | tail -n 1) RESULT_CODE_LINES="" @@ -417,7 +423,10 @@ EOF_SUMMARY echo -e "${GREEN}Summary report saved to: $CITE_RESULTS_DIR/cite-wms11-summary.md${NC}" -if [[ "$RESULTS_FOUND" != "true" ]]; then +if [[ $runner_exit -ne 0 && "$COMPLETE_RESULTS" != "true" ]]; then + echo -e "${RED}CITE runner failed before a complete TestNG result artifact was produced.${NC}" + exit "$runner_exit" +elif [[ "$RESULTS_FOUND" != "true" ]]; then echo -e "${RED}CITE testing failed to execute properly.${NC}" exit 2 elif [[ $FAILED_TESTS -gt 0 || $SKIPPED_TESTS -gt 0 || $CANTTELL_TESTS -gt 0 ]]; then From 3a05528a4b83268416263b3612a407872de853cd Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Sun, 9 Aug 2026 01:19:49 -1000 Subject: [PATCH 06/11] test(streaming): align conformance fixture bounds (#1372) --- .../FeatureStreamConformanceEndpointsTests.cs | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/tests/dotnet/Honua.Server.Tests/Features/Streaming/FeatureStreamConformanceEndpointsTests.cs b/tests/dotnet/Honua.Server.Tests/Features/Streaming/FeatureStreamConformanceEndpointsTests.cs index 97414f46f0..54cc1dc6e0 100644 --- a/tests/dotnet/Honua.Server.Tests/Features/Streaming/FeatureStreamConformanceEndpointsTests.cs +++ b/tests/dotnet/Honua.Server.Tests/Features/Streaming/FeatureStreamConformanceEndpointsTests.cs @@ -131,13 +131,18 @@ public async Task Mutate_WhenTheMutationBudgetIsSpent_Returns409() { var run = await LeaseRunAsync(ttlSeconds: 300); - // The fixture caps a run at three mutations. + // The fixture allows the four-operation conformance path above. Spend that + // budget while retaining one record so this assertion exercises the mutation + // bound rather than the independent record-count bound. + var inserted = await ReadJsonAsync(await MutateAsync(run, """{"operation":"insert"}""")); + var objectId = inserted.GetProperty("data").GetProperty("objectId").GetInt64(); for (var i = 0; i < 3; i++) { - (await MutateAsync(run, """{"operation":"insert"}""")).StatusCode.Should().Be(HttpStatusCode.OK); + (await MutateAsync(run, $$"""{"operation":"touch","objectId":{{objectId}}}""")).StatusCode + .Should().Be(HttpStatusCode.OK); } - var overBudget = await MutateAsync(run, """{"operation":"insert"}"""); + var overBudget = await MutateAsync(run, $$"""{"operation":"touch","objectId":{{objectId}}}"""); overBudget.StatusCode.Should().Be(HttpStatusCode.Conflict); await CleanupAsync(run); @@ -262,12 +267,21 @@ public async Task LeaseRun_WithAnotherSourceIdentity_Returns409() [Endpoint("POST /api/v1/streaming/conformance/runs")] public async Task LeaseRun_WithoutAConformanceCredential_Returns401() { - using var anonymous = _fixture.CreateClient(); - using var content = new StringContent("{}", Encoding.UTF8, "application/json"); + var fixture = CreateFixture(maxConcurrentRuns: 2, requireAuthentication: true); + await fixture.InitializeAsync(); + try + { + using var anonymous = fixture.CreateClient(); + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); - using var response = await anonymous.PostAsync(RunsPath, content, CancellationToken.None); + using var response = await anonymous.PostAsync(RunsPath, content, CancellationToken.None); - response.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden); + response.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden); + } + finally + { + await fixture.DisposeAsync(); + } } // ── NFR-001/NFR-002: reset, and the anonymous advertisement ───────────────── @@ -445,25 +459,36 @@ public async Task LeaseRun_WhenTheDeploymentReportsNoImmutableRevision_Returns50 // ── helpers ──────────────────────────────────────────────────────────────── - private static WebAppFixture CreateFixture(int maxConcurrentRuns) + private static WebAppFixture CreateFixture(int maxConcurrentRuns, bool requireAuthentication = false) => new WebAppFixture() .ReplaceService(new TestLicenseEntitlementService(HonuaEdition.Pro)) - .ConfigureWebHost(builder => builder.ConfigureAppConfiguration((_, configBuilder) => - configBuilder.AddInMemoryCollection(new Dictionary + .ConfigureWebHost(builder => + { + if (requireAuthentication) { - ["FeatureStreaming:Conformance:Enabled"] = "true", - ["FeatureStreaming:Conformance:ServiceId"] = "test", - ["FeatureStreaming:Conformance:LayerId"] = "0", - ["FeatureStreaming:Conformance:RunIdField"] = "name", - ["FeatureStreaming:Conformance:LabelField"] = "category", - ["FeatureStreaming:Conformance:MaxConcurrentRuns"] = maxConcurrentRuns.ToString(System.Globalization.CultureInfo.InvariantCulture), - ["FeatureStreaming:Conformance:MaxMutationsPerRun"] = "3", - ["FeatureStreaming:Conformance:MaxRecordsPerRun"] = "3", - // A long sweep interval keeps the background sweeper from racing these - // tests; TTL reclamation itself is covered by the registry unit tests. - ["FeatureStreaming:Conformance:SweepInterval"] = "00:30:00", - ["Deployment:Revision"] = TestRevision - }))); + // The standard test host explicitly enables development-auth bypass, + // which would make the anonymous-credential assertion meaningless. + builder.UseSetting("HONUA_DEV_AUTH", "false"); + builder.UseSetting("HONUA_ADMIN_PASSWORD", WebAppFixture.SharedAdminPassword); + } + + builder.ConfigureAppConfiguration((_, configBuilder) => + configBuilder.AddInMemoryCollection(new Dictionary + { + ["FeatureStreaming:Conformance:Enabled"] = "true", + ["FeatureStreaming:Conformance:ServiceId"] = "test", + ["FeatureStreaming:Conformance:LayerId"] = "0", + ["FeatureStreaming:Conformance:RunIdField"] = "name", + ["FeatureStreaming:Conformance:LabelField"] = "category", + ["FeatureStreaming:Conformance:MaxConcurrentRuns"] = maxConcurrentRuns.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["FeatureStreaming:Conformance:MaxMutationsPerRun"] = "4", + ["FeatureStreaming:Conformance:MaxRecordsPerRun"] = "3", + // A long sweep interval keeps the background sweeper from racing these + // tests; TTL reclamation itself is covered by the registry unit tests. + ["FeatureStreaming:Conformance:SweepInterval"] = "00:30:00", + ["Deployment:Revision"] = TestRevision + })); + }); private async Task LeaseRunAsync(string? label = null, int? ttlSeconds = null) { From ef55d3f9b3b266f5fd46c2da85bab2fe89d216fa Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Sun, 9 Aug 2026 01:42:21 -1000 Subject: [PATCH 07/11] fix(ci): require complete WMS CITE results --- scripts/conformance/cite/run-cite-wms11-tests.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/conformance/cite/run-cite-wms11-tests.sh b/scripts/conformance/cite/run-cite-wms11-tests.sh index ae0bdeb932..1d038e5971 100755 --- a/scripts/conformance/cite/run-cite-wms11-tests.sh +++ b/scripts/conformance/cite/run-cite-wms11-tests.sh @@ -16,6 +16,7 @@ CITE_RESULTS_CONTAINER_DIR="/root/te_base/users/cite/logs" CITE_TIMEOUT=1800 HONUA_HEALTHCHECK_TIMEOUT=300 POSTGRES_HEALTHCHECK_TIMEOUT=120 +EXPECTED_TOTAL_TESTS=126 HONUA_CITE_WMS11_SERVER_PORT="${HONUA_CITE_WMS11_SERVER_PORT:-8098}" export HONUA_CITE_WMS11_SERVER_PORT PASSED_TESTS=0 @@ -347,6 +348,8 @@ if [[ -d "$CITE_RESULTS_DIR" && $(ls -A "$CITE_RESULTS_DIR" 2>/dev/null) ]]; the CANTTELL_TESTS=0 if [[ "$TOTAL_TESTS" =~ ^[0-9]+$ && "$PASSED_TESTS" =~ ^[0-9]+$ && "$FAILED_TESTS" =~ ^[0-9]+$ && "$SKIPPED_TESTS" =~ ^[0-9]+$ ]] && + (( TOTAL_TESTS == EXPECTED_TOTAL_TESTS && + TOTAL_TESTS == PASSED_TESTS + FAILED_TESTS + SKIPPED_TESTS )) && grep -q '' "$RESULTS_XML"; then COMPLETE_RESULTS=true fi From bfa32711d2b4cf6d9203a00cbd8254eb8198067b Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Sun, 9 Aug 2026 02:03:26 -1000 Subject: [PATCH 08/11] fix(ci): validate WMS totals by CITE profile --- scripts/conformance/cite/run-cite-wms11-tests.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/cite/run-cite-wms11-tests.sh b/scripts/conformance/cite/run-cite-wms11-tests.sh index 1d038e5971..fc6134e693 100755 --- a/scripts/conformance/cite/run-cite-wms11-tests.sh +++ b/scripts/conformance/cite/run-cite-wms11-tests.sh @@ -16,7 +16,7 @@ CITE_RESULTS_CONTAINER_DIR="/root/te_base/users/cite/logs" CITE_TIMEOUT=1800 HONUA_HEALTHCHECK_TIMEOUT=300 POSTGRES_HEALTHCHECK_TIMEOUT=120 -EXPECTED_TOTAL_TESTS=126 +EXPECTED_TOTAL_TESTS=0 HONUA_CITE_WMS11_SERVER_PORT="${HONUA_CITE_WMS11_SERVER_PORT:-8098}" export HONUA_CITE_WMS11_SERVER_PORT PASSED_TESTS=0 @@ -38,6 +38,10 @@ WMS_BBOXCONSTRAINTS="either" set_profile_options() { local profile="$1" + # The pinned ets-wms11 1.23 image counts the two wrapper tests plus every selected + # conformance-class/test node: basic runs 99; queryable + recommended + GML runs 126. + # Keep the completion proof profile-specific so a closed partial artifact cannot mask a + # runner failure and a complete minimal artifact is not rejected as truncated. WMS_PROFILE="no" WMS_RECOMMENDED="false" WMS_GETFEATUREINFO="false" @@ -49,17 +53,20 @@ set_profile_options() { WMS_PROFILE="basic" WMS_RECOMMENDED="false" WMS_GETFEATUREINFO="false" + EXPECTED_TOTAL_TESTS=99 ;; default) WMS_PROFILE="queryable" WMS_RECOMMENDED="true" WMS_GETFEATUREINFO="true" + EXPECTED_TOTAL_TESTS=126 ;; full) WMS_PROFILE="queryable" WMS_RECOMMENDED="true" WMS_GETFEATUREINFO="true" WMS_BBOXCONSTRAINTS="either" + EXPECTED_TOTAL_TESTS=126 ;; *) echo -e "${RED}Unknown profile: $profile${NC}" From 7fad45105e8df2e29143d7bd4b28090b7119ea98 Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Sun, 9 Aug 2026 02:14:58 -1000 Subject: [PATCH 09/11] fix(oracle): preserve case-distinct key attributes --- .../FeatureStore/OracleFeatureStore.cs | 2 +- .../OracleFeatureQueryBuilderTests.cs | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Honua.Oracle/Features/FeatureStore/OracleFeatureStore.cs b/src/Honua.Oracle/Features/FeatureStore/OracleFeatureStore.cs index f80fec19ba..52ee1cc400 100644 --- a/src/Honua.Oracle/Features/FeatureStore/OracleFeatureStore.cs +++ b/src/Honua.Oracle/Features/FeatureStore/OracleFeatureStore.cs @@ -275,7 +275,7 @@ private async Task EnsureNoPermanentFilterAsync(int layerId, CancellationToken c var attributeColumns = binding.Resource.SchemaFields .Where(f => f.Type is not (MetadataV2FieldType.Geometry or MetadataV2FieldType.Geography) - && !f.Name.Equals(mapping.PrimaryKeyColumn, StringComparison.OrdinalIgnoreCase)) + && !f.Name.Equals(mapping.PrimaryKeyColumn, StringComparison.Ordinal)) .Select(f => f.Name) .ToArray(); diff --git a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs index 6eaacb3e15..4ef22c20a9 100644 --- a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs +++ b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs @@ -20,14 +20,17 @@ public class OracleFeatureQueryBuilderTests private static readonly IReadOnlyList _attributeColumns = ["name", "area", "category"]; - private static OracleLayerMapping BuildMapping(string? schema = "GIS", int? srid = 4326) + private static OracleLayerMapping BuildMapping( + string? schema = "GIS", + int? srid = 4326, + string primaryKeyColumn = "OBJECTID") { var storage = new LayerStorageMapping( TableName: "PARCELS", SchemaName: schema, CatalogName: null, DatabaseName: null, - PrimaryKeyColumn: "OBJECTID", + PrimaryKeyColumn: primaryKeyColumn, GeometryColumn: "SHAPE", StorageSrid: srid); @@ -128,6 +131,19 @@ public void BuildSelectQuery_WhereFieldExactlyMatchesCaseDistinctColumn_Preserve Assert.DoesNotContain("\"NAME\" = :p0", result.Sql, StringComparison.Ordinal); } + [Fact] + public void BuildSelectQuery_AttributeDifferingFromPrimaryKeyOnlyByCase_PreservesExactIdentifier() + { + var query = new FeatureQuery { Where = "id = 'attribute'" }; + + var result = OracleFeatureQueryBuilder.BuildSelectQuery( + BuildMapping(primaryKeyColumn: "ID"), query, ["id"]); + + Assert.Contains("\"ID\" AS \"__objectid\"", result.Sql, StringComparison.Ordinal); + Assert.Contains("\"id\" = :p0", result.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("\"ID\" = :p0", result.Sql, StringComparison.Ordinal); + } + [Fact] public void BuildSelectQuery_WhereFieldAmbiguouslyMatchesCaseDistinctColumns_FailsClosed() { From 2056d2f415fc29945c2079062d19e6776e1d7902 Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Sun, 9 Aug 2026 02:23:15 -1000 Subject: [PATCH 10/11] fix(oracle): resolve case-distinct output fields --- .../Services/OracleFeatureQueryBuilder.cs | 13 ++++++++++--- .../OracleFeatureQueryBuilderTests.cs | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs b/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs index c1ca215c57..aee348942d 100644 --- a/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs +++ b/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs @@ -42,7 +42,7 @@ public static ParameterizedQuery BuildSelectQuery(OracleLayerMapping mapping, Fe sb.Append(", ").Append(BuildGeometryWkbExpression(mapping)).Append(" AS \"__geometry\""); - AppendAttributeColumns(sb, query, attributeColumns); + AppendAttributeColumns(sb, mapping, query, attributeColumns); sb.Append(" FROM ").Append(mapping.QuotedTableReference); sb.Append(" WHERE 1=1"); @@ -184,7 +184,11 @@ private static string BuildGeometryWkbExpression(OracleLayerMapping mapping) return $"SDO_UTIL.TO_WKBGEOMETRY({mapping.QuotedGeometryColumn})"; } - private static void AppendAttributeColumns(StringBuilder sb, FeatureQuery query, IReadOnlyList attributeColumns) + private static void AppendAttributeColumns( + StringBuilder sb, + OracleLayerMapping mapping, + FeatureQuery query, + IReadOnlyList attributeColumns) { if (query.ExcludeAttributes || attributeColumns.Count == 0) { @@ -194,7 +198,10 @@ private static void AppendAttributeColumns(StringBuilder sb, FeatureQuery query, IEnumerable columns = attributeColumns; if (query.OutFields.HasValue && !query.OutFields.Value.IsDefaultOrEmpty) { - var requested = new HashSet(query.OutFields.Value, StringComparer.OrdinalIgnoreCase); + var resolveColumnName = CreateColumnNameResolver(mapping, attributeColumns); + var requested = new HashSet( + query.OutFields.Value.Select(resolveColumnName), + StringComparer.Ordinal); columns = attributeColumns.Where(c => requested.Contains(c)); } diff --git a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs index 4ef22c20a9..73030204b3 100644 --- a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs +++ b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs @@ -144,6 +144,20 @@ public void BuildSelectQuery_AttributeDifferingFromPrimaryKeyOnlyByCase_Preserve Assert.DoesNotContain("\"ID\" = :p0", result.Sql, StringComparison.Ordinal); } + [Fact] + public void BuildSelectQuery_OutFieldsWithCaseDistinctNames_ProjectsOnlyExactAttributes() + { + var query = new FeatureQuery { OutFields = ["ID", "NAME"] }; + + var result = OracleFeatureQueryBuilder.BuildSelectQuery( + BuildMapping(primaryKeyColumn: "ID"), query, ["id", "NAME", "name"]); + + Assert.Contains("\"ID\" AS \"__objectid\"", result.Sql, StringComparison.Ordinal); + Assert.Contains(", \"NAME\"", result.Sql, StringComparison.Ordinal); + Assert.DoesNotContain(", \"id\"", result.Sql, StringComparison.Ordinal); + Assert.DoesNotContain(", \"name\"", result.Sql, StringComparison.Ordinal); + } + [Fact] public void BuildSelectQuery_WhereFieldAmbiguouslyMatchesCaseDistinctColumns_FailsClosed() { From 1b2c639ef7895fb9a35c6adaee19ebb2e88dc9d4 Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Sun, 9 Aug 2026 02:30:39 -1000 Subject: [PATCH 11/11] fix(oracle): reject ambiguous output fields --- .../Services/OracleFeatureQueryBuilder.cs | 19 ++++++++++++++++--- .../OracleFeatureQueryBuilderTests.cs | 12 ++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs b/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs index aee348942d..3dc570d1b2 100644 --- a/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs +++ b/src/Honua.Oracle/Features/FeatureStore/Services/OracleFeatureQueryBuilder.cs @@ -198,7 +198,10 @@ private static void AppendAttributeColumns( IEnumerable columns = attributeColumns; if (query.OutFields.HasValue && !query.OutFields.Value.IsDefaultOrEmpty) { - var resolveColumnName = CreateColumnNameResolver(mapping, attributeColumns); + var resolveColumnName = CreateColumnNameResolver( + mapping, + attributeColumns, + rejectAmbiguousMatch: true); var requested = new HashSet( query.OutFields.Value.Select(resolveColumnName), StringComparer.Ordinal); @@ -214,7 +217,8 @@ private static void AppendAttributeColumns( private static Func CreateColumnNameResolver( OracleLayerMapping mapping, - IReadOnlyList? attributeColumns) + IReadOnlyList? attributeColumns, + bool rejectAmbiguousMatch = false) { // Oracle folds unquoted DDL identifiers to upper-case, whereas protocol requests // commonly use lower-case field names. We always quote identifiers to keep the @@ -260,7 +264,16 @@ private static Func CreateColumnNameResolver( { // Quoted Oracle identifiers may legitimately differ only by case. An // inexact request cannot choose between them without targeting the wrong - // physical column, so leave it unchanged and let Oracle fail closed. + // physical column. Projection must reject the ambiguity before it can + // silently omit both attributes; WHERE leaves the name unresolved so Oracle + // still fails closed instead of targeting either physical column. + if (rejectAmbiguousMatch) + { + throw new ArgumentException( + $"Oracle field name '{requested}' ambiguously matches case-distinct columns.", + nameof(requested)); + } + return requested; } diff --git a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs index 73030204b3..3b9e0aa408 100644 --- a/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs +++ b/tests/dotnet/Honua.Oracle.Tests/OracleFeatureQueryBuilderTests.cs @@ -158,6 +158,18 @@ public void BuildSelectQuery_OutFieldsWithCaseDistinctNames_ProjectsOnlyExactAtt Assert.DoesNotContain(", \"name\"", result.Sql, StringComparison.Ordinal); } + [Fact] + public void BuildSelectQuery_AmbiguousCaseDistinctOutField_Throws() + { + var query = new FeatureQuery { OutFields = ["Name"] }; + + var exception = Assert.Throws(() => + OracleFeatureQueryBuilder.BuildSelectQuery( + BuildMapping(), query, ["NAME", "name"])); + + Assert.Contains("ambiguously matches", exception.Message, StringComparison.Ordinal); + } + [Fact] public void BuildSelectQuery_WhereFieldAmbiguouslyMatchesCaseDistinctColumns_FailsClosed() {