diff --git a/Directory.Packages.props b/Directory.Packages.props index cb9e062442..e8d56c270c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ 5.11.0 10.0.10 6.2.0 - 0.0.163 + 0.6.73 diff --git a/build/build-variables.yml b/build/build-variables.yml index 61e5a80201..27ee614caa 100644 --- a/build/build-variables.yml +++ b/build/build-variables.yml @@ -17,8 +17,11 @@ variables: azureContainerRegistryName: 'healthplatformregistry' azureContainerRegistry: '$(azureContainerRegistryName).azurecr.io' DeploymentEnvironmentNameSql: '$(DeploymentEnvironmentName)-sql' + DeploymentEnvironmentNameSqlVNext: '$(DeploymentEnvironmentName)-svn' + SqlVNextElasticPoolName: '$(DeploymentEnvironmentName)-vnext-pool' DeploymentEnvironmentNameR4: '$(DeploymentEnvironmentName)-r4' DeploymentEnvironmentNameR4Sql: '$(DeploymentEnvironmentNameR4)-sql' + DeploymentEnvironmentNameR4SqlVNext: '$(DeploymentEnvironmentName)-r4vn' DeploymentEnvironmentNameR4B: '$(DeploymentEnvironmentName)-r4b' DeploymentEnvironmentNameR4BSql: '$(DeploymentEnvironmentNameR4B)-sql' DeploymentEnvironmentNameR5: '$(DeploymentEnvironmentName)-r5' @@ -26,8 +29,10 @@ variables: AcaEnvironmentName: '$(DeploymentEnvironmentName)-acae' # Key Vault names (shorter due to 24 character limit) KeyVaultNameSql: '$(KeyVaultBaseName)-sql' + KeyVaultNameSqlVNext: '$(KeyVaultBaseName)-sql-vn' KeyVaultNameR4: '$(KeyVaultBaseName)-r4' KeyVaultNameR4Sql: '$(KeyVaultNameR4)-sql' + KeyVaultNameR4SqlVNext: '$(KeyVaultBaseName)-r4-vn' KeyVaultNameR4B: '$(KeyVaultBaseName)-r4b' KeyVaultNameR4BSql: '$(KeyVaultNameR4B)-sql' KeyVaultNameR5: '$(KeyVaultBaseName)-r5' diff --git a/build/ci-deploy.yml b/build/ci-deploy.yml index a79fdaa075..21df56cdcd 100644 --- a/build/ci-deploy.yml +++ b/build/ci-deploy.yml @@ -6,38 +6,74 @@ name: $(SourceBranchName)-$(Date:yyyyMMdd)$(Rev:-r) trigger: none +parameters: +- name: buildImages + displayName: Build images + type: boolean + default: true +- name: imageTagOverride + displayName: Existing image tag (required when images are not built) + type: string + default: '' + variables: - template: ci-variables.yml - template: build-variables.yml stages: -- stage: UpdateVersion - displayName: 'Determine Semver' - dependsOn: [] - jobs: - - job: Semver - pool: - name: '$(InternalPool)' - demands: - - ImageOverride -equals $(InternalLinuxImage) - steps: - - template: ./jobs/update-semver.yml +- ${{ if eq(parameters.buildImages, true) }}: + - stage: UpdateVersion + displayName: 'Determine Semver' + dependsOn: [] + jobs: + - job: Semver + pool: + name: '$(InternalPool)' + demands: + - ImageOverride -equals $(InternalLinuxImage) + steps: + - template: ./jobs/update-semver.yml -- stage: DockerBuild - displayName: 'Build images' - dependsOn: - - UpdateVersion - variables: - assemblySemFileVer: $[stageDependencies.UpdateVersion.Semver.outputs['SetVariablesFromGitVersion.assemblySemFileVer']] - jobs: - - template: ./jobs/docker-build-all.yml - parameters: - tag: $(ImageTag) - buildPlatform: $(publicDockerImagePlatforms) + - stage: DockerBuild + displayName: 'Build images' + dependsOn: + - UpdateVersion + variables: + assemblySemFileVer: $[stageDependencies.UpdateVersion.Semver.outputs['SetVariablesFromGitVersion.assemblySemFileVer']] + jobs: + - template: ./jobs/docker-build-all.yml + parameters: + tag: $(ImageTag) + buildPlatform: $(publicDockerImagePlatforms) + +- ${{ if eq(parameters.buildImages, false) }}: + - stage: ValidateRecoveryParameters + displayName: 'Validate recovery parameters' + dependsOn: [] + jobs: + - job: ValidateRecoveryParameters + displayName: 'Validate recovery parameters' + pool: + name: '$(AzurePipelinesPool)' + vmImage: '$(LinuxVmImage)' + steps: + - task: PowerShell@2 + displayName: 'Require an existing image tag' + env: + IMAGE_TAG_OVERRIDE: ${{ parameters.imageTagOverride }} + inputs: + targetType: inline + pwsh: true + script: | + if ([string]::IsNullOrWhiteSpace($env:IMAGE_TAG_OVERRIDE)) { + throw "Parameter 'imageTagOverride' must be non-empty when 'buildImages' is false." + } - stage: provisionEnvironment displayName: Provision Environment - dependsOn: [] + dependsOn: + - ${{ if eq(parameters.buildImages, false) }}: + - ValidateRecoveryParameters jobs: - template: ./jobs/cleanup-resourcegroup-aad.yml - job: provision @@ -168,11 +204,27 @@ stages: dbMinCapacity: 0 dbMaxCapacity: 8 +- stage: deploySqlVNextElasticPool + displayName: 'Deploy SQL vNext Elastic Pool (CI)' + dependsOn: + - deploySqlServer + jobs: + - template: ./jobs/provision-sqlElasticPool.yml + parameters: + resourceGroup: $(ResourceGroupName) + sqlServerName: $(DeploymentEnvironmentName) + elasticPoolName: $(SqlVNextElasticPoolName) + capacity: 4 + maxSizeBytes: '68719476736' + dbMinCapacity: 0 + dbMaxCapacity: 2 + - stage: deployStu3 displayName: 'Deploy STU3 CosmosDB Site' dependsOn: - aadTestEnvironment - - DockerBuild + - ${{ if eq(parameters.buildImages, true) }}: + - DockerBuild - createNsp - createAcaEnvironment jobs: @@ -186,14 +238,18 @@ stages: subscription: $(ConnectedServiceName) resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) - imageTag: $(ImageTag) + ${{ if eq(parameters.buildImages, true) }}: + imageTag: $(ImageTag) + ${{ else }}: + imageTag: ${{ parameters.imageTagOverride }} reindexEnabled: true - stage: deployStu3Sql displayName: 'Deploy STU3 SQL Site' dependsOn: - aadTestEnvironment - - DockerBuild + - ${{ if eq(parameters.buildImages, true) }}: + - DockerBuild - deploySqlServer - deploySqlElasticPool - createAcaEnvironment @@ -208,18 +264,53 @@ stages: subscription: $(ConnectedServiceName) resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) - imageTag: $(ImageTag) + ${{ if eq(parameters.buildImages, true) }}: + imageTag: $(ImageTag) + ${{ else }}: + imageTag: ${{ parameters.imageTagOverride }} schemaAutomaticUpdatesEnabled: 'auto' sqlServerName: $(DeploymentEnvironmentName) sqlComputeTier: 'Hyperscale' sqlElasticPoolName: $(DeploymentEnvironmentName)-pool reindexEnabled: true +- stage: deployStu3SqlVNext + displayName: 'Deploy STU3 SQL vNext SDK Site' + dependsOn: + - aadTestEnvironment + - ${{ if eq(parameters.buildImages, true) }}: + - DockerBuild + - deploySqlServer + - deploySqlVNextElasticPool + - createAcaEnvironment + jobs: + - template: ./jobs/provision-deploy.yml + parameters: + dataStore: sql + version: Stu3 + webAppName: $(DeploymentEnvironmentNameSqlVNext) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameSqlVNext) + subscription: $(ConnectedServiceName) + resourceGroup: $(ResourceGroupName) + testEnvironmentUrl: $(TestApplicationResource) + ${{ if eq(parameters.buildImages, true) }}: + imageTag: $(ImageTag) + ${{ else }}: + imageTag: ${{ parameters.imageTagOverride }} + schemaAutomaticUpdatesEnabled: 'auto' + sqlServerName: $(DeploymentEnvironmentName) + sqlElasticPoolName: $(SqlVNextElasticPoolName) + sqlDatabaseName: FHIRStu3VNext + fhirSdkProviderDefault: Ignixa + reindexEnabled: true + - stage: deployR4 displayName: 'Deploy R4 CosmosDB Site' dependsOn: - aadTestEnvironment - - DockerBuild + - ${{ if eq(parameters.buildImages, true) }}: + - DockerBuild - createNsp - createAcaEnvironment jobs: @@ -233,14 +324,18 @@ stages: subscription: $(ConnectedServiceName) resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) - imageTag: $(ImageTag) + ${{ if eq(parameters.buildImages, true) }}: + imageTag: $(ImageTag) + ${{ else }}: + imageTag: ${{ parameters.imageTagOverride }} reindexEnabled: true - stage: deployR4Sql displayName: 'Deploy R4 SQL Site' dependsOn: - aadTestEnvironment - - DockerBuild + - ${{ if eq(parameters.buildImages, true) }}: + - DockerBuild - deploySqlServer - deploySqlElasticPool - createAcaEnvironment @@ -255,18 +350,53 @@ stages: subscription: $(ConnectedServiceName) resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) - imageTag: $(ImageTag) + ${{ if eq(parameters.buildImages, true) }}: + imageTag: $(ImageTag) + ${{ else }}: + imageTag: ${{ parameters.imageTagOverride }} schemaAutomaticUpdatesEnabled: 'auto' sqlServerName: $(DeploymentEnvironmentName) sqlComputeTier: 'Hyperscale' sqlElasticPoolName: $(DeploymentEnvironmentName)-pool reindexEnabled: true +- stage: deployR4SqlVNext + displayName: 'Deploy R4 SQL vNext SDK Site' + dependsOn: + - aadTestEnvironment + - ${{ if eq(parameters.buildImages, true) }}: + - DockerBuild + - deploySqlServer + - deploySqlVNextElasticPool + - createAcaEnvironment + jobs: + - template: ./jobs/provision-deploy.yml + parameters: + dataStore: sql + version: R4 + webAppName: $(DeploymentEnvironmentNameR4SqlVNext) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameR4SqlVNext) + subscription: $(ConnectedServiceName) + resourceGroup: $(ResourceGroupName) + testEnvironmentUrl: $(TestApplicationResource) + ${{ if eq(parameters.buildImages, true) }}: + imageTag: $(ImageTag) + ${{ else }}: + imageTag: ${{ parameters.imageTagOverride }} + schemaAutomaticUpdatesEnabled: 'auto' + sqlServerName: $(DeploymentEnvironmentName) + sqlElasticPoolName: $(SqlVNextElasticPoolName) + sqlDatabaseName: FHIRR4VNext + fhirSdkProviderDefault: Ignixa + reindexEnabled: true + - stage: deployR5Sql displayName: 'Deploy R5 SQL Site' dependsOn: - aadTestEnvironment - - DockerBuild + - ${{ if eq(parameters.buildImages, true) }}: + - DockerBuild - deploySqlServer - deploySqlElasticPool - createAcaEnvironment @@ -281,7 +411,10 @@ stages: subscription: $(ConnectedServiceName) resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) - imageTag: $(ImageTag) + ${{ if eq(parameters.buildImages, true) }}: + imageTag: $(ImageTag) + ${{ else }}: + imageTag: ${{ parameters.imageTagOverride }} schemaAutomaticUpdatesEnabled: 'auto' sqlServerName: $(DeploymentEnvironmentName) sqlComputeTier: 'Hyperscale' diff --git a/build/ci-pipeline.yml b/build/ci-pipeline.yml index 886ffa3691..e1109d8725 100644 --- a/build/ci-pipeline.yml +++ b/build/ci-pipeline.yml @@ -130,6 +130,18 @@ stages: - ImageOverride -equals $(InternalWindowsImage) steps: - template: ./jobs/analyze.yml + - job: ValidateAcaSqlDeploymentPlan + displayName: 'Validate ACA SQL deployment plan' + pool: + name: '$(InternalPool)' + demands: + - ImageOverride -equals $(InternalLinuxImage) + steps: + - task: PowerShell@2 + displayName: 'Validate SQL vNext deployment plan' + inputs: + filePath: '$(System.DefaultWorkingDirectory)/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1' + pwsh: true - stage: DockerBuild displayName: 'Build images' @@ -170,6 +182,19 @@ stages: imageTag: $(ImageTag) resourceGroup: $(ResourceGroupName) +- stage: redeployStu3SqlVNext + displayName: 'Redeploy STU3 SQL vNext SDK Site' + dependsOn: + - DockerBuild + jobs: + - template: ./jobs/redeploy-webapp.yml + parameters: + version: Stu3 + webAppName: $(DeploymentEnvironmentNameSqlVNext) + subscription: $(ConnectedServiceName) + resourceGroup: $(ResourceGroupName) + imageTag: $(ImageTag) + - stage: testStu3Cosmos displayName: 'Run Stu3 Cosmos Tests' dependsOn: @@ -200,6 +225,27 @@ stages: runReindexJob: false runBulkUpdateJob: false +- stage: testStu3SqlVNext + displayName: 'Run Stu3 SQL vNext SDK Tests' + dependsOn: + - BuildArtifacts + - redeployStu3SqlVNext + jobs: + - template: ./jobs/run-sql-tests.yml + parameters: + version: Stu3 + keyVaultName: $(KeyVaultNameSqlVNext) + containerAppName: $(DeploymentEnvironmentNameSqlVNext) + integrationSqlServerName: $(DeploymentEnvironmentName)inttest + runIntegrationTests: false + runBulkUpdateJob: false + testRunTitleSuffix: ' vNext SDK' + expectedFhirSdkProviderDefault: Ignixa + expectedKeyVaultName: $(KeyVaultNameSqlVNext) + expectedSqlServerName: $(DeploymentEnvironmentName) + expectedSqlDatabaseName: FHIRStu3VNext + expectedSqlElasticPoolName: $(SqlVNextElasticPoolName) + # *********************** R4 *********************** - stage: redeployR4 displayName: 'Redeploy R4 CosmosDB Site' @@ -227,6 +273,19 @@ stages: imageTag: $(ImageTag) resourceGroup: $(ResourceGroupName) +- stage: redeployR4SqlVNext + displayName: 'Redeploy R4 SQL vNext SDK Site' + dependsOn: + - DockerBuild + jobs: + - template: ./jobs/redeploy-webapp.yml + parameters: + version: R4 + webAppName: $(DeploymentEnvironmentNameR4SqlVNext) + subscription: $(ConnectedServiceName) + resourceGroup: $(ResourceGroupName) + imageTag: $(ImageTag) + - stage: testR4Cosmos displayName: 'Run R4 Cosmos Tests' dependsOn: @@ -257,6 +316,27 @@ stages: runReindexJob: false runBulkUpdateJob: false +- stage: testR4SqlVNext + displayName: 'Run R4 SQL vNext SDK Tests' + dependsOn: + - BuildArtifacts + - redeployR4SqlVNext + jobs: + - template: ./jobs/run-sql-tests.yml + parameters: + version: R4 + keyVaultName: $(KeyVaultNameR4SqlVNext) + containerAppName: $(DeploymentEnvironmentNameR4SqlVNext) + integrationSqlServerName: $(DeploymentEnvironmentName)inttest + runIntegrationTests: false + runBulkUpdateJob: false + testRunTitleSuffix: ' vNext SDK' + expectedFhirSdkProviderDefault: Ignixa + expectedKeyVaultName: $(KeyVaultNameR4SqlVNext) + expectedSqlServerName: $(DeploymentEnvironmentName) + expectedSqlDatabaseName: FHIRR4VNext + expectedSqlElasticPoolName: $(SqlVNextElasticPoolName) + # *********************** R5 *********************** - stage: redeployR5Sql displayName: 'Redeploy R5 SQL Site' @@ -313,8 +393,10 @@ stages: dependsOn: - testStu3Cosmos - testStu3Sql + - testStu3SqlVNext - testR4Cosmos - testR4Sql + - testR4SqlVNext - testR5Sql # Only scale down on success. On failure we leave the apps at min=3 so developers can investigate # via the live endpoints and ADO "Rerun failed stages" (which does NOT re-execute scaleUpContainerApps) diff --git a/build/jobs/e2e-tests.yml b/build/jobs/e2e-tests.yml index 0874229659..7e0f2f75ed 100644 --- a/build/jobs/e2e-tests.yml +++ b/build/jobs/e2e-tests.yml @@ -11,6 +11,21 @@ parameters: - name: testRunTitleSuffix type: string default: '' +- name: expectedFhirSdkProviderDefault + type: string + default: '' +- name: expectedKeyVaultName + type: string + default: '' +- name: expectedSqlServerName + type: string + default: '' +- name: expectedSqlDatabaseName + type: string + default: '' +- name: expectedSqlElasticPoolName + type: string + default: '' steps: - template: e2e-tests-extract.yml @@ -22,6 +37,11 @@ steps: containerAppName: ${{ parameters.containerAppName }} version: ${{ parameters.version }} appServiceType: ${{ parameters.appServiceType }} + expectedFhirSdkProviderDefault: '${{ parameters.expectedFhirSdkProviderDefault }}' + expectedKeyVaultName: '${{ parameters.expectedKeyVaultName }}' + expectedSqlServerName: '${{ parameters.expectedSqlServerName }}' + expectedSqlDatabaseName: '${{ parameters.expectedSqlDatabaseName }}' + expectedSqlElasticPoolName: '${{ parameters.expectedSqlElasticPoolName }}' - task: PowerShell@2 displayName: 'E2E ${{ parameters.version }} ${{parameters.appServiceType}}${{ parameters.testRunTitleSuffix }}' diff --git a/build/jobs/provision-deploy.yml b/build/jobs/provision-deploy.yml index fa832a9f73..9ece500fda 100644 --- a/build/jobs/provision-deploy.yml +++ b/build/jobs/provision-deploy.yml @@ -31,6 +31,12 @@ parameters: - name: sqlElasticPoolName type: string default: '' +- name: sqlDatabaseName + type: string + default: '' +- name: fhirSdkProviderDefault + type: string + default: '' - name: keyVaultName type: string @@ -71,6 +77,8 @@ jobs: -TenantIdGuid "$(tenant-id-guid)" -SqlServerName "${{ parameters.sqlServerName }}" -SqlElasticPoolName "${{ parameters.sqlElasticPoolName }}" + -SqlDatabaseName "${{ parameters.sqlDatabaseName }}" + -FhirSdkProviderDefault "${{ parameters.fhirSdkProviderDefault }}" -SchemaAutomaticUpdatesEnabled "${{ parameters.schemaAutomaticUpdatesEnabled }}" -ReindexEnabled "${{ parameters.reindexEnabled }}" -MinReplicas $(minReplicas) diff --git a/build/jobs/run-sql-tests.yml b/build/jobs/run-sql-tests.yml index 45d9a82d85..6a463d5115 100644 --- a/build/jobs/run-sql-tests.yml +++ b/build/jobs/run-sql-tests.yml @@ -16,95 +16,117 @@ parameters: - name: runBulkUpdateJob type: boolean default: true +- name: runIntegrationTests + type: boolean + default: true +- name: testRunTitleSuffix + type: string + default: '' +- name: expectedFhirSdkProviderDefault + type: string + default: '' +- name: expectedKeyVaultName + type: string + default: '' +- name: expectedSqlServerName + type: string + default: '' +- name: expectedSqlDatabaseName + type: string + default: '' +- name: expectedSqlElasticPoolName + type: string + default: '' jobs: -- job: "SqlIntegrationTests" - timeoutInMinutes: 75 - pool: - name: '$(InternalPool)' - demands: - - ImageOverride -equals $(InternalLinuxImage) - variables: - AllowPtrToDetectTestRunRetryFiles: true - steps: - - checkout: self - fetchDepth: 1 - fetchTags: false - path: source +- ${{ if eq(parameters.runIntegrationTests, true) }}: + - job: "SqlIntegrationTests" + timeoutInMinutes: 75 + pool: + name: '$(InternalPool)' + demands: + - ImageOverride -equals $(InternalLinuxImage) + variables: + AllowPtrToDetectTestRunRetryFiles: true + steps: + - checkout: self + fetchDepth: 1 + fetchTags: false + path: source - - template: integration-setup.yml + - template: integration-setup.yml - - template: integration-tests-extract.yml - parameters: - version: ${{ parameters.version }} + - template: integration-tests-extract.yml + parameters: + version: ${{ parameters.version }} - - task: AzureKeyVault@1 - displayName: 'Azure Key Vault: ${{ parameters.keyVaultName }}' - inputs: - azureSubscription: $(ConnectedServiceName) - KeyVaultName: '${{ parameters.keyVaultName }}' + - task: AzureKeyVault@1 + displayName: 'Azure Key Vault: ${{ parameters.keyVaultName }}' + inputs: + azureSubscription: $(ConnectedServiceName) + KeyVaultName: '${{ parameters.keyVaultName }}' - - task: AzurePowerShell@5 - displayName: 'Set Workload Identity Variables' - inputs: - azureSubscription: $(ConnectedServiceName) - azurePowerShellVersion: latestVersion - pwsh: true - ScriptType: inlineScript - Inline: | - Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_CLIENT_ID]$env:AZURESUBSCRIPTION_CLIENT_ID" - Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_TENANT_ID]$env:AZURESUBSCRIPTION_TENANT_ID" - Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_SERVICE_CONNECTION_ID]$env:AZURESUBSCRIPTION_SERVICE_CONNECTION_ID" + - task: AzurePowerShell@5 + displayName: 'Set Workload Identity Variables' + inputs: + azureSubscription: $(ConnectedServiceName) + azurePowerShellVersion: latestVersion + pwsh: true + ScriptType: inlineScript + Inline: | + Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_CLIENT_ID]$env:AZURESUBSCRIPTION_CLIENT_ID" + Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_TENANT_ID]$env:AZURESUBSCRIPTION_TENANT_ID" + Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_SERVICE_CONNECTION_ID]$env:AZURESUBSCRIPTION_SERVICE_CONNECTION_ID" - - task: DotNetCoreCLI@2 - displayName: 'Build Integration Test Projects' - inputs: - command: build - projects: '$(Pipeline.Workspace)/source/test/**/*${{ parameters.version }}.Tests.Integration.csproj' - arguments: '--configuration $(buildConfiguration) -f $(defaultBuildFramework)' + - task: DotNetCoreCLI@2 + displayName: 'Build Integration Test Projects' + inputs: + command: build + projects: '$(Pipeline.Workspace)/source/test/**/*${{ parameters.version }}.Tests.Integration.csproj' + arguments: '--configuration $(buildConfiguration) -f $(defaultBuildFramework)' - - task: DotNetCoreCLI@2 - displayName: 'Run SQL Integration Tests with coverage' - inputs: - command: test - projects: '$(Pipeline.Workspace)/source/test/**/*${{ parameters.version }}.Tests.Integration.csproj' - arguments: '--configuration $(buildConfiguration) --no-build -f $(defaultBuildFramework) -- --filter "FullyQualifiedName!~CosmosDb" --retry-failed-tests 3 --coverage --coverage-output-format cobertura --coverage-settings "$(System.DefaultWorkingDirectory)/CodeCoverage.Mtp.settings.xml" --report-trx' - testRunTitle: '${{ parameters.version }} SQL Integration Tests' - # Disable the task's built-in (non-retry-aware) result publishing so the - # explicit retry-aware PublishTestResults@2 task below is the sole publisher. - publishTestResults: false - env: - 'SqlServer:ConnectionString': 'Server=tcp:${{ parameters.integrationSqlServerName }}.database.windows.net,1433;Initial Catalog=master;Persist Security Info=False;Authentication=ActiveDirectoryWorkloadIdentity;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;User Id=$(AZURESUBSCRIPTION_CLIENT_ID);' - platformOptions__resultDirectory: '$(Agent.TempDirectory)/coverage' - 'AZURESUBSCRIPTION_CLIENT_ID': '$(AZURESUBSCRIPTION_CLIENT_ID)' - 'AZURESUBSCRIPTION_TENANT_ID': '$(AZURESUBSCRIPTION_TENANT_ID)' - 'AZURESUBSCRIPTION_SERVICE_CONNECTION_ID': '$(AZURESUBSCRIPTION_SERVICE_CONNECTION_ID)' - 'SYSTEM_ACCESSTOKEN': $(System.AccessToken) + - task: DotNetCoreCLI@2 + displayName: 'Run SQL Integration Tests with coverage' + inputs: + command: test + projects: '$(Pipeline.Workspace)/source/test/**/*${{ parameters.version }}.Tests.Integration.csproj' + arguments: '--configuration $(buildConfiguration) --no-build -f $(defaultBuildFramework) -- --filter "FullyQualifiedName!~CosmosDb" --retry-failed-tests 3 --coverage --coverage-output-format cobertura --coverage-settings "$(System.DefaultWorkingDirectory)/CodeCoverage.Mtp.settings.xml" --report-trx' + testRunTitle: '${{ parameters.version }} SQL Integration Tests' + # Disable the task's built-in (non-retry-aware) result publishing so the + # explicit retry-aware PublishTestResults@2 task below is the sole publisher. + publishTestResults: false + env: + 'SqlServer:ConnectionString': 'Server=tcp:${{ parameters.integrationSqlServerName }}.database.windows.net,1433;Initial Catalog=master;Persist Security Info=False;Authentication=ActiveDirectoryWorkloadIdentity;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;User Id=$(AZURESUBSCRIPTION_CLIENT_ID);' + platformOptions__resultDirectory: '$(Agent.TempDirectory)/coverage' + 'AZURESUBSCRIPTION_CLIENT_ID': '$(AZURESUBSCRIPTION_CLIENT_ID)' + 'AZURESUBSCRIPTION_TENANT_ID': '$(AZURESUBSCRIPTION_TENANT_ID)' + 'AZURESUBSCRIPTION_SERVICE_CONNECTION_ID': '$(AZURESUBSCRIPTION_SERVICE_CONNECTION_ID)' + 'SYSTEM_ACCESSTOKEN': $(System.AccessToken) - - task: PublishTestResults@2 - displayName: 'Publish integration test results' - inputs: - testResultsFormat: 'VSTest' - testResultsFiles: '$(Agent.TempDirectory)/coverage/**/*.trx' - mergeTestResults: true - testRunTitle: '${{ parameters.version }} SQL Integration Tests' - failTaskOnFailedTests: true - condition: succeededOrFailed() + - task: PublishTestResults@2 + displayName: 'Publish integration test results' + inputs: + testResultsFormat: 'VSTest' + testResultsFiles: '$(Agent.TempDirectory)/coverage/**/*.trx' + mergeTestResults: true + testRunTitle: '${{ parameters.version }} SQL Integration Tests' + failTaskOnFailedTests: true + condition: succeededOrFailed() - - task: reportgenerator@5 - displayName: 'Aggregate SQL integration test coverage' - condition: succeededOrFailed() - inputs: - reports: '$(Agent.TempDirectory)/coverage/**/*.cobertura.xml' - reporttypes: 'Cobertura' - targetdir: '$(Agent.TempDirectory)/coverage-aggregated' + - task: reportgenerator@5 + displayName: 'Aggregate SQL integration test coverage' + condition: succeededOrFailed() + inputs: + reports: '$(Agent.TempDirectory)/coverage/**/*.cobertura.xml' + reporttypes: 'Cobertura' + targetdir: '$(Agent.TempDirectory)/coverage-aggregated' - - task: PublishBuildArtifacts@1 - displayName: 'Publish SQL integration test coverage' - inputs: - pathToPublish: '$(Agent.TempDirectory)/coverage-aggregated' - artifactName: 'Coverage_IntegrationTests_Sql_${{ parameters.version }}' - artifactType: 'container' + - task: PublishBuildArtifacts@1 + displayName: 'Publish SQL integration test coverage' + inputs: + pathToPublish: '$(Agent.TempDirectory)/coverage-aggregated' + artifactName: 'Coverage_IntegrationTests_Sql_${{ parameters.version }}' + artifactType: 'container' - job: 'sqlE2eTests' timeoutInMinutes: 120 @@ -123,6 +145,12 @@ jobs: containerAppName: '${{ parameters.containerAppName }}' appServiceType: 'SqlServer' categoryFilter: '${{ parameters.mainCategoryFilter }}' + testRunTitleSuffix: '${{ parameters.testRunTitleSuffix }}' + expectedFhirSdkProviderDefault: '${{ parameters.expectedFhirSdkProviderDefault }}' + expectedKeyVaultName: '${{ parameters.expectedKeyVaultName }}' + expectedSqlServerName: '${{ parameters.expectedSqlServerName }}' + expectedSqlDatabaseName: '${{ parameters.expectedSqlDatabaseName }}' + expectedSqlElasticPoolName: '${{ parameters.expectedSqlElasticPoolName }}' - ${{ if eq(parameters.runReindexJob, true) }}: - job: 'sqlE2eTests_Reindex' @@ -142,7 +170,12 @@ jobs: containerAppName: '${{ parameters.containerAppName }}' appServiceType: 'SqlServer' categoryFilter: 'Category=IndexAndReindex' - testRunTitleSuffix: ' Reindex' + testRunTitleSuffix: '${{ parameters.testRunTitleSuffix }} Reindex' + expectedFhirSdkProviderDefault: '${{ parameters.expectedFhirSdkProviderDefault }}' + expectedKeyVaultName: '${{ parameters.expectedKeyVaultName }}' + expectedSqlServerName: '${{ parameters.expectedSqlServerName }}' + expectedSqlDatabaseName: '${{ parameters.expectedSqlDatabaseName }}' + expectedSqlElasticPoolName: '${{ parameters.expectedSqlElasticPoolName }}' - ${{ if eq(parameters.runBulkUpdateJob, true) }}: - job: 'sqlE2eTests_BulkUpdate' @@ -162,4 +195,9 @@ jobs: containerAppName: '${{ parameters.containerAppName }}' appServiceType: 'SqlServer' categoryFilter: 'Category=BulkUpdate' - testRunTitleSuffix: ' BulkUpdate' + testRunTitleSuffix: '${{ parameters.testRunTitleSuffix }} BulkUpdate' + expectedFhirSdkProviderDefault: '${{ parameters.expectedFhirSdkProviderDefault }}' + expectedKeyVaultName: '${{ parameters.expectedKeyVaultName }}' + expectedSqlServerName: '${{ parameters.expectedSqlServerName }}' + expectedSqlDatabaseName: '${{ parameters.expectedSqlDatabaseName }}' + expectedSqlElasticPoolName: '${{ parameters.expectedSqlElasticPoolName }}' diff --git a/build/jobs/scripts/Assert-AcaSqlTopology.ps1 b/build/jobs/scripts/Assert-AcaSqlTopology.ps1 new file mode 100644 index 0000000000..89e2e04610 --- /dev/null +++ b/build/jobs/scripts/Assert-AcaSqlTopology.ps1 @@ -0,0 +1,179 @@ +function Assert-AcaSqlTopology { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [array] $EnvironmentSettings, + + [Parameter(Mandatory = $true)] + [string] $ContainerAppName, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string] $ResourceGroupName = '', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string] $ExpectedKeyVaultName = '', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string] $ExpectedSqlServerName = '', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string] $ExpectedSqlDatabaseName = '', + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string] $ExpectedSqlElasticPoolName = '', + + [Parameter(Mandatory = $false)] + [scriptblock] $SecretResolver = { + param($VaultName, $SecretName) + Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName -AsPlainText -ErrorAction Stop + }, + + [Parameter(Mandatory = $false)] + [scriptblock] $DatabaseResolver = { + param($ResourceGroup, $ServerName, $DatabaseName) + Get-AzSqlDatabase -ResourceGroupName $ResourceGroup -ServerName $ServerName -DatabaseName $DatabaseName -ErrorAction Stop + }, + + [Parameter(Mandatory = $false)] + [scriptblock] $AzureEnvironmentResolver = { + $context = Get-AzContext -ErrorAction Stop + if ($null -eq $context -or $null -eq $context.Environment) { + throw 'No active Azure context is available.' + } + + $environmentName = if ($context.Environment -is [string]) { + $context.Environment + } + else { + [string]$context.Environment.Name + } + + if ([string]::IsNullOrWhiteSpace($environmentName)) { + throw 'The active Azure context does not identify an Azure environment.' + } + + $environment = Get-AzEnvironment -Name $environmentName -ErrorAction Stop + [pscustomobject]@{ + KeyVaultDnsSuffix = $environment.AzureKeyVaultDnsSuffix + SqlDatabaseDnsSuffix = $environment.SqlDatabaseDnsSuffix + } + } + ) + + $expectations = @( + $ExpectedKeyVaultName, + $ExpectedSqlServerName, + $ExpectedSqlDatabaseName, + $ExpectedSqlElasticPoolName + ) + $suppliedExpectationCount = @($expectations | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count + if ($suppliedExpectationCount -eq 0) { + return + } + + if ($suppliedExpectationCount -ne $expectations.Count -or [string]::IsNullOrWhiteSpace($ResourceGroupName)) { + throw 'ACA SQL topology validation requires a resource group and all four expected topology values.' + } + + try { + $azureEnvironment = & $AzureEnvironmentResolver + } + catch { + $message = 'SQL vNext topology validation could not resolve the active Azure environment. Verify that the Azure service connection is authenticated and configured for the target cloud.' + throw [System.InvalidOperationException]::new($message, $_.Exception) + } + + if ($null -eq $azureEnvironment) { + throw 'SQL vNext topology validation could not resolve the active Azure environment.' + } + + $keyVaultDnsSuffix = ([string]$azureEnvironment.KeyVaultDnsSuffix).Trim().Trim('.') + $sqlDatabaseDnsSuffix = ([string]$azureEnvironment.SqlDatabaseDnsSuffix).Trim().Trim('.') + if ([string]::IsNullOrWhiteSpace($keyVaultDnsSuffix)) { + throw 'SQL vNext topology validation requires the active Azure environment to define a Key Vault DNS suffix.' + } + + if ([string]::IsNullOrWhiteSpace($sqlDatabaseDnsSuffix)) { + throw 'SQL vNext topology validation requires the active Azure environment to define a SQL Database DNS suffix.' + } + + $keyVaultSettingName = 'KeyVault__Endpoint' + $keyVaultSettings = @($EnvironmentSettings | Where-Object { $_.name -eq $keyVaultSettingName }) + if ($keyVaultSettings.Count -ne 1) { + throw "Container App '$ContainerAppName' must define '$keyVaultSettingName' exactly once." + } + + $keyVaultEndpoint = [string]$keyVaultSettings[0].value + $endpointUri = $null + if (-not [uri]::TryCreate($keyVaultEndpoint, [System.UriKind]::Absolute, [ref]$endpointUri) -or + $endpointUri.Scheme -ne [System.Uri]::UriSchemeHttps) { + throw "Container App '$ContainerAppName' has invalid '$keyVaultSettingName' value '$keyVaultEndpoint'." + } + + $actualKeyVaultHost = $endpointUri.DnsSafeHost.TrimEnd('.') + $expectedKeyVaultHost = "$ExpectedKeyVaultName.$keyVaultDnsSuffix" + if ($actualKeyVaultHost -ine $expectedKeyVaultHost) { + throw "Container App '$ContainerAppName' targets Key Vault host '$actualKeyVaultHost'; expected '$expectedKeyVaultHost'." + } + + $connectionString = [string](& $SecretResolver $ExpectedKeyVaultName 'SqlServer--ConnectionString') + if ([string]::IsNullOrWhiteSpace($connectionString)) { + throw "Key Vault '$ExpectedKeyVaultName' secret 'SqlServer--ConnectionString' is empty." + } + + try { + $connectionStringBuilder = [System.Data.Common.DbConnectionStringBuilder]::new() + $connectionStringBuilder.set_ConnectionString($connectionString) + } + catch { + throw "Key Vault '$ExpectedKeyVaultName' secret 'SqlServer--ConnectionString' is not a valid connection string." + } + + $serverValue = $null + foreach ($serverKey in @('Server', 'Data Source')) { + if ($connectionStringBuilder.ContainsKey($serverKey)) { + $serverValue = [string]$connectionStringBuilder[$serverKey] + break + } + } + + $databaseValue = $null + foreach ($databaseKey in @('Initial Catalog', 'Database')) { + if ($connectionStringBuilder.ContainsKey($databaseKey)) { + $databaseValue = [string]$connectionStringBuilder[$databaseKey] + break + } + } + + if ([string]::IsNullOrWhiteSpace($serverValue)) { + throw "Key Vault '$ExpectedKeyVaultName' SQL connection string does not define a server; expected '$ExpectedSqlServerName'." + } + + $actualServerHost = ($serverValue -replace '^(?i:tcp):', '').Split(',')[0].Trim() + $expectedServerHost = "$ExpectedSqlServerName.$sqlDatabaseDnsSuffix" + if ([string]::IsNullOrWhiteSpace($actualServerHost) -or $actualServerHost.TrimEnd('.') -ine $expectedServerHost) { + throw "Key Vault '$ExpectedKeyVaultName' SQL connection string targets server '$actualServerHost'; expected '$expectedServerHost'." + } + + if ([string]::IsNullOrWhiteSpace($databaseValue) -or $databaseValue -ine $ExpectedSqlDatabaseName) { + throw "Key Vault '$ExpectedKeyVaultName' SQL connection string targets database '$databaseValue'; expected '$ExpectedSqlDatabaseName'." + } + + $database = & $DatabaseResolver $ResourceGroupName $ExpectedSqlServerName $ExpectedSqlDatabaseName + if ($null -eq $database) { + throw "SQL database '$ExpectedSqlDatabaseName' was not found on server '$ExpectedSqlServerName'." + } + + $actualElasticPoolName = [string]$database.ElasticPoolName + if ($actualElasticPoolName -ine $ExpectedSqlElasticPoolName) { + throw "SQL database '$ExpectedSqlDatabaseName' belongs to elastic pool '$actualElasticPoolName'; expected '$ExpectedSqlElasticPoolName'." + } + + Write-Host "Verified Container App '$ContainerAppName' targets Key Vault '$ExpectedKeyVaultName', SQL database '$ExpectedSqlDatabaseName' on server '$ExpectedSqlServerName', and elastic pool '$ExpectedSqlElasticPoolName'." +} diff --git a/build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1 b/build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1 new file mode 100644 index 0000000000..0a426c4178 --- /dev/null +++ b/build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1 @@ -0,0 +1,36 @@ +function Assert-EffectiveFhirSdkProvider { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [array] $EnvironmentSettings, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string] $ExpectedProvider = '', + + [Parameter(Mandatory = $true)] + [string] $ContainerAppName + ) + + if ([string]::IsNullOrWhiteSpace($ExpectedProvider)) { + return + } + + $settingName = 'FhirServer__CoreFeatures__FhirSdkProvider__Default' + $matchingSettings = @($EnvironmentSettings | Where-Object { $_.name -eq $settingName }) + if ($matchingSettings.Count -eq 0) { + throw "Container App '$ContainerAppName' does not define '$settingName'; expected '$ExpectedProvider'." + } + + if ($matchingSettings.Count -gt 1) { + throw "Container App '$ContainerAppName' defines '$settingName' more than once." + } + + $actualProvider = [string]$matchingSettings[0].value + if ($actualProvider -ne $ExpectedProvider) { + throw "Container App '$ContainerAppName' has '$settingName' set to '$actualProvider'; expected '$ExpectedProvider'." + } + + Write-Host "Verified Container App '$ContainerAppName' uses FHIR SDK provider '$actualProvider'." +} diff --git a/build/jobs/scripts/Provision-AcaDeploy.ps1 b/build/jobs/scripts/Provision-AcaDeploy.ps1 index 77d9c9239a..2a3f66fe3f 100644 --- a/build/jobs/scripts/Provision-AcaDeploy.ps1 +++ b/build/jobs/scripts/Provision-AcaDeploy.ps1 @@ -27,6 +27,8 @@ param( [Parameter(Mandatory = $false)] [string] $SqlServerName = '', [Parameter(Mandatory = $false)] [string] $SqlElasticPoolName = '', + [Parameter(Mandatory = $false)] [string] $SqlDatabaseName = '', + [Parameter(Mandatory = $false)] [ValidateSet('', 'Firely', 'Ignixa')] [string] $FhirSdkProviderDefault = '', [Parameter(Mandatory = $false)] [string] $SchemaAutomaticUpdatesEnabled = 'auto', [Parameter(Mandatory = $false)] [string] $ReindexEnabled = 'true', @@ -68,6 +70,22 @@ $additionalProperties["FhirServer__CoreFeatures__SystemConformanceProviderRefres $additionalProperties["FhirServer__Operations__Reindex__CacheRefreshWaitMultiplier"] = $ReindexCacheRefreshWaitMultiplier $additionalProperties["ASPNETCORE_FORWARDEDHEADERS_ENABLED"] = "true" +$fhirSdkProviderSettingName = "FhirServer__CoreFeatures__FhirSdkProvider__Default" +$sqlDeploymentPlan = $null +if ($DataStore -eq 'sql') { + $configuredFhirSdkProviderDefault = if ($additionalProperties.ContainsKey($fhirSdkProviderSettingName)) { + [string]$additionalProperties[$fhirSdkProviderSettingName] + } else { + '' + } + + $sqlDeploymentPlan = & "$PSScriptRoot/Resolve-AcaSqlDeploymentPlan.ps1" ` + -Version $Version ` + -SqlDatabaseName $SqlDatabaseName ` + -FhirSdkProviderDefault $FhirSdkProviderDefault ` + -ConfiguredFhirSdkProviderDefault $configuredFhirSdkProviderDefault +} + $staticEnvNames = @( "ASPNETCORE_FORWARDEDHEADERS_ENABLED", "KeyVault__Endpoint", @@ -93,7 +111,8 @@ if ($DataStore -eq 'sql') { "SqlServer__Initialize", "SqlServer__SchemaOptions__AutomaticUpdatesEnabled", "SqlServer__DeleteAllDataOnStartup", - "SqlServer__AllowDatabaseCreation" + "SqlServer__AllowDatabaseCreation", + $fhirSdkProviderSettingName ) } else { $staticEnvNames += @( @@ -140,7 +159,7 @@ $resourceGroupName = $ResourceGroup # --- Data-store-specific pre-deploy setup --- if ($DataStore -eq 'sql') { $sqlServerName = $SqlServerName.ToLowerInvariant() - $sqlDatabaseName = "FHIR$Version" + $sqlDatabaseName = $sqlDeploymentPlan.SqlDatabaseName $sqlElasticPoolName = $SqlElasticPoolName $existingDb = Get-AzSqlDatabase -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -DatabaseName $sqlDatabaseName -ErrorAction SilentlyContinue if ($null -eq $existingDb) { @@ -272,6 +291,10 @@ $templateParameters = @{ if ($DataStore -eq 'sql') { $templateParameters["sqlServerName"] = $sqlServerName + $templateParameters["sqlDatabaseName"] = $sqlDatabaseName + if ($sqlDeploymentPlan.EmitFhirSdkProviderEnvironmentVariable) { + $templateParameters["fhirSdkProviderDefault"] = $sqlDeploymentPlan.FhirSdkProviderDefault + } $templateParameters["sqlSchemaAutomaticUpdatesEnabled"] = $SchemaAutomaticUpdatesEnabled } else { $templateParameters["cosmosDbAccountName"] = $cosmosDbAccountName diff --git a/build/jobs/scripts/Resolve-AcaSqlDeploymentPlan.ps1 b/build/jobs/scripts/Resolve-AcaSqlDeploymentPlan.ps1 new file mode 100644 index 0000000000..81d500f310 --- /dev/null +++ b/build/jobs/scripts/Resolve-AcaSqlDeploymentPlan.ps1 @@ -0,0 +1,47 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('Stu3', 'R4', 'R4B', 'R5')] + [string] $Version, + + [Parameter(Mandatory = $false)] + [string] $SqlDatabaseName = '', + + [Parameter(Mandatory = $false)] + [ValidateSet('', 'Firely', 'Ignixa')] + [string] $FhirSdkProviderDefault = '', + + [Parameter(Mandatory = $false)] + [string] $ConfiguredFhirSdkProviderDefault = '' +) + +$resolvedSqlDatabaseName = if ([string]::IsNullOrWhiteSpace($SqlDatabaseName)) { + "FHIR$Version" +} else { + $SqlDatabaseName +} + +$hasRequestedProvider = -not [string]::IsNullOrWhiteSpace($FhirSdkProviderDefault) +$hasConfiguredProvider = -not [string]::IsNullOrWhiteSpace($ConfiguredFhirSdkProviderDefault) + +if ($hasConfiguredProvider -and $ConfiguredFhirSdkProviderDefault -notin @('Firely', 'Ignixa')) { + throw "Configured FHIR SDK provider '$ConfiguredFhirSdkProviderDefault' is unsupported. Expected 'Firely' or 'Ignixa'." +} + +if ($hasRequestedProvider -and $hasConfiguredProvider -and $FhirSdkProviderDefault -ne $ConfiguredFhirSdkProviderDefault) { + throw "Deployment FHIR SDK provider '$FhirSdkProviderDefault' conflicts with configured provider '$ConfiguredFhirSdkProviderDefault'." +} + +$effectiveProvider = if ($hasRequestedProvider) { + $FhirSdkProviderDefault +} elseif ($hasConfiguredProvider) { + $ConfiguredFhirSdkProviderDefault +} else { + 'Firely' +} + +[pscustomobject]@{ + SqlDatabaseName = $resolvedSqlDatabaseName + FhirSdkProviderDefault = $effectiveProvider + EmitFhirSdkProviderEnvironmentVariable = $hasConfiguredProvider -or $effectiveProvider -ne 'Firely' +} diff --git a/build/jobs/scripts/tests/Test-AcaSqlTopology.ps1 b/build/jobs/scripts/tests/Test-AcaSqlTopology.ps1 new file mode 100644 index 0000000000..6c07f2498f --- /dev/null +++ b/build/jobs/scripts/tests/Test-AcaSqlTopology.ps1 @@ -0,0 +1,198 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$assertionPath = Join-Path $PSScriptRoot '../Assert-AcaSqlTopology.ps1' +. $assertionPath + +function Assert-Throws { + param( + [Parameter(Mandatory = $true)] [scriptblock] $Action, + [Parameter(Mandatory = $true)] [string] $ExpectedMessage, + [Parameter(Mandatory = $true)] [string] $Description, + [Parameter(Mandatory = $false)] [string] $ExpectedInnerMessage + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notlike "*$ExpectedMessage*") { + throw "$Description. Expected error containing '$ExpectedMessage', actual '$($_.Exception.Message)'." + } + + if (-not [string]::IsNullOrEmpty($ExpectedInnerMessage) -and + ($null -eq $_.Exception.InnerException -or $_.Exception.InnerException.Message -notlike "*$ExpectedInnerMessage*")) { + throw "$Description. Expected inner error containing '$ExpectedInnerMessage'." + } + + return + } + + throw "$Description. Expected an exception." +} + +$matchingEnvironment = @( + [pscustomobject]@{ + name = 'KeyVault__Endpoint' + value = 'https://expected-vault.vault.azure.net/' + } +) +$matchingSecretResolver = { + param($VaultName, $SecretName) + if ($VaultName -ne 'expected-vault' -or $SecretName -ne 'SqlServer--ConnectionString') { + throw "Unexpected secret request for '$VaultName/$SecretName'." + } + + 'Server=tcp:shared-sql.database.windows.net,1433;Initial Catalog=FHIRR4VNext;Encrypt=True' +} +$matchingDatabaseResolver = { + param($ResourceGroup, $ServerName, $DatabaseName) + if ($ResourceGroup -ne 'expected-rg' -or $ServerName -ne 'shared-sql' -or $DatabaseName -ne 'FHIRR4VNext') { + throw "Unexpected database request for '$ResourceGroup/$ServerName/$DatabaseName'." + } + + [pscustomobject]@{ ElasticPoolName = 'vnext-pool' } +} +$matchingAzureEnvironmentResolver = { + [pscustomobject]@{ + KeyVaultDnsSuffix = 'vault.azure.net' + SqlDatabaseDnsSuffix = '.database.windows.net' + } +} +$matchingArguments = @{ + EnvironmentSettings = $matchingEnvironment + ContainerAppName = 'expected-app' + ResourceGroupName = 'expected-rg' + ExpectedKeyVaultName = 'expected-vault' + ExpectedSqlServerName = 'shared-sql' + ExpectedSqlDatabaseName = 'FHIRR4VNext' + ExpectedSqlElasticPoolName = 'vnext-pool' + SecretResolver = $matchingSecretResolver + DatabaseResolver = $matchingDatabaseResolver + AzureEnvironmentResolver = $matchingAzureEnvironmentResolver +} + +Assert-AcaSqlTopology @matchingArguments + +$nullAzureEnvironmentResolver = { + $null +} +Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -AzureEnvironmentResolver $nullAzureEnvironmentResolver +} -ExpectedMessage 'SQL vNext topology validation could not resolve the active Azure environment' -Description 'Null Azure environment metadata was accepted' + +foreach ($azureEnvironmentMetadata in @( + [pscustomobject]@{ SqlDatabaseDnsSuffix = '.database.windows.net' }, + [pscustomobject]@{ KeyVaultDnsSuffix = ' '; SqlDatabaseDnsSuffix = '.database.windows.net' } +)) { + $missingKeyVaultDnsSuffixResolver = { + $azureEnvironmentMetadata + }.GetNewClosure() + Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -AzureEnvironmentResolver $missingKeyVaultDnsSuffixResolver + } -ExpectedMessage 'define a Key Vault DNS suffix' -Description 'Missing or blank Key Vault DNS suffix was accepted' +} + +foreach ($azureEnvironmentMetadata in @( + [pscustomobject]@{ KeyVaultDnsSuffix = 'vault.azure.net' }, + [pscustomobject]@{ KeyVaultDnsSuffix = 'vault.azure.net'; SqlDatabaseDnsSuffix = ' ' } +)) { + $missingSqlDnsSuffixResolver = { + $azureEnvironmentMetadata + }.GetNewClosure() + Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -AzureEnvironmentResolver $missingSqlDnsSuffixResolver + } -ExpectedMessage 'define a SQL Database DNS suffix' -Description 'Missing or blank SQL DNS suffix was accepted' +} + +$throwingAzureEnvironmentResolver = { + throw 'Simulated Azure context failure.' +} +Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -AzureEnvironmentResolver $throwingAzureEnvironmentResolver +} ` + -ExpectedMessage 'SQL vNext topology validation could not resolve the active Azure environment. Verify that the Azure service connection is authenticated and configured for the target cloud.' ` + -ExpectedInnerMessage 'Simulated Azure context failure.' ` + -Description 'Azure environment resolver failure was not wrapped' + +$wrongKeyVaultEnvironment = @( + [pscustomobject]@{ + name = 'KeyVault__Endpoint' + value = 'https://wrong-vault.vault.azure.net/' + } +) +Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -EnvironmentSettings $wrongKeyVaultEnvironment +} -ExpectedMessage "targets Key Vault host 'wrong-vault.vault.azure.net'" -Description 'Wrong Key Vault endpoint was accepted' + +$lookalikeKeyVaultEnvironment = @( + [pscustomobject]@{ + name = 'KeyVault__Endpoint' + value = 'https://expected-vault.example.invalid/' + } +) +Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -EnvironmentSettings $lookalikeKeyVaultEnvironment +} -ExpectedMessage "expected-vault.vault.azure.net" -Description 'Lookalike Key Vault DNS suffix was accepted' + +$wrongServerResolver = { + 'Server=tcp:wrong-sql.database.windows.net,1433;Initial Catalog=FHIRR4VNext;Encrypt=True' +} +Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -SecretResolver $wrongServerResolver +} -ExpectedMessage "targets server 'wrong-sql.database.windows.net'" -Description 'Wrong SQL server was accepted' + +$lookalikeServerResolver = { + 'Server=tcp:shared-sql.example.invalid,1433;Initial Catalog=FHIRR4VNext;Encrypt=True' +} +Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -SecretResolver $lookalikeServerResolver +} -ExpectedMessage "expected 'shared-sql.database.windows.net'" -Description 'Lookalike SQL DNS suffix was accepted' + +$wrongDatabaseResolver = { + 'Server=tcp:shared-sql.database.windows.net,1433;Initial Catalog=WrongDatabase;Encrypt=True' +} +Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -SecretResolver $wrongDatabaseResolver +} -ExpectedMessage "targets database 'WrongDatabase'" -Description 'Wrong SQL database was accepted' + +$wrongPoolResolver = { + [pscustomobject]@{ ElasticPoolName = 'wrong-pool' } +} +Assert-Throws -Action { + Assert-AcaSqlTopology @matchingArguments -DatabaseResolver $wrongPoolResolver +} -ExpectedMessage "belongs to elastic pool 'wrong-pool'" -Description 'Wrong SQL elastic pool was accepted' + +$unexpectedResolver = { + throw 'A credential-backed resolver was called for a legacy lane.' +} +Assert-AcaSqlTopology ` + -EnvironmentSettings @() ` + -ContainerAppName 'legacy-app' ` + -SecretResolver $unexpectedResolver ` + -DatabaseResolver $unexpectedResolver ` + -AzureEnvironmentResolver $unexpectedResolver + +$sovereignEnvironment = @( + [pscustomobject]@{ + name = 'KeyVault__Endpoint' + value = 'https://expected-vault.vault.azure.cn/' + } +) +$sovereignSecretResolver = { + 'Server=tcp:shared-sql.database.chinacloudapi.cn,1433;Initial Catalog=FHIRR4VNext;Encrypt=True' +} +$sovereignAzureEnvironmentResolver = { + [pscustomobject]@{ + KeyVaultDnsSuffix = '.vault.azure.cn.' + SqlDatabaseDnsSuffix = 'database.chinacloudapi.cn' + } +} +Assert-AcaSqlTopology ` + @matchingArguments ` + -EnvironmentSettings $sovereignEnvironment ` + -SecretResolver $sovereignSecretResolver ` + -AzureEnvironmentResolver $sovereignAzureEnvironmentResolver + +Write-Host 'ACA SQL topology behavioral tests passed.' diff --git a/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1 b/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1 new file mode 100644 index 0000000000..4e0892fa39 --- /dev/null +++ b/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1 @@ -0,0 +1,540 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$repositoryRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path +$resolver = Join-Path $repositoryRoot 'build/jobs/scripts/Resolve-AcaSqlDeploymentPlan.ps1' +$providerAssertion = Join-Path $repositoryRoot 'build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1' +. $providerAssertion + +function Assert-Equal { + param( + [Parameter(Mandatory = $true)] $Expected, + [Parameter(Mandatory = $true)] $Actual, + [Parameter(Mandatory = $true)] [string] $Description + ) + + if ($Expected -ne $Actual) { + throw "$Description. Expected '$Expected', actual '$Actual'." + } +} + +function Assert-True { + param( + [Parameter(Mandatory = $true)] [bool] $Condition, + [Parameter(Mandatory = $true)] [string] $Description + ) + + if (-not $Condition) { + throw $Description + } +} + +function Assert-SequenceEqual { + param( + [Parameter(Mandatory = $true)] [object[]] $Expected, + [Parameter(Mandatory = $true)] [object[]] $Actual, + [Parameter(Mandatory = $true)] [string] $Description + ) + + Assert-Equal -Expected ($Expected -join ',') -Actual ($Actual -join ',') -Description $Description +} + +function Assert-Throws { + param( + [Parameter(Mandatory = $true)] [scriptblock] $Action, + [Parameter(Mandatory = $true)] [string] $ExpectedMessage, + [Parameter(Mandatory = $true)] [string] $Description + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notlike "*$ExpectedMessage*") { + throw "$Description. Expected error containing '$ExpectedMessage', actual '$($_.Exception.Message)'." + } + + return + } + + throw "$Description. Expected an exception." +} + +function Get-PythonWithPyYaml { + $pythonCommand = $null + foreach ($commandName in @('python', 'python3')) { + $pythonCommand = Get-Command $commandName -ErrorAction SilentlyContinue + if ($null -ne $pythonCommand) { + break + } + } + + if ($null -eq $pythonCommand) { + throw 'Python is required to validate the SQL vNext deployment plan.' + } + + & $pythonCommand.Source -c 'import sys' 2>$null + if ($LASTEXITCODE -ne 0) { + throw 'Python is required to validate the SQL vNext deployment plan.' + } + + & $pythonCommand.Source -c 'import yaml' 2>$null + if ($LASTEXITCODE -ne 0) { + throw "The Python package 'PyYAML' is required to validate the SQL vNext deployment plan." + } + + return $pythonCommand.Source +} + +function Assert-AzureCliBicepAvailable { + $azureCli = Get-Command az -ErrorAction SilentlyContinue + if ($null -eq $azureCli) { + throw 'Azure CLI is required to compile the SQL vNext Bicep template.' + } + + & $azureCli.Source bicep version | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'Azure CLI Bicep is required to compile the SQL vNext Bicep template.' + } +} + +function ConvertFrom-YamlFile { + param([Parameter(Mandatory = $true)] [string] $Path) + + $yaml = Get-Content -Raw $Path + $json = $yaml | & $script:pythonPath -c 'import json, sys, yaml; json.dump(yaml.safe_load(sys.stdin), sys.stdout)' + if ($LASTEXITCODE -ne 0) { + throw "Failed to parse YAML file '$Path'." + } + + return $json | ConvertFrom-Json -Depth 100 +} + +function Get-Stage { + param( + [Parameter(Mandatory = $true)] $Pipeline, + [Parameter(Mandatory = $true)] [string] $Name + ) + + $stage = @($Pipeline.stages | Where-Object { $_.stage -eq $Name }) + Assert-Equal -Expected 1 -Actual $stage.Count -Description "Stage '$Name' should occur once" + return $stage[0] +} + +function Get-ScriptArgument { + param( + [Parameter(Mandatory = $true)] [string] $Arguments, + [Parameter(Mandatory = $true)] [string] $Name + ) + + $match = [regex]::Match($Arguments, "(?m)-$([regex]::Escape($Name))\s+`"([^`"]*)`"") + if (-not $match.Success) { + throw "Script argument '-$Name' was not found." + } + + return $match.Groups[1].Value +} + +function Find-TemplateInvocation { + param( + [Parameter(Mandatory = $true)] $Node, + [Parameter(Mandatory = $true)] [string] $Template + ) + + if ($Node -is [pscustomobject]) { + if ($Node.PSObject.Properties['template'] -and $Node.template -eq $Template) { + Write-Output -NoEnumerate $Node + } + + foreach ($property in $Node.PSObject.Properties) { + Find-TemplateInvocation -Node $property.Value -Template $Template + } + } elseif ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string]) { + foreach ($item in $Node) { + Find-TemplateInvocation -Node $item -Template $Template + } + } +} + +function Get-CiDeployStages { + param( + [Parameter(Mandatory = $true)] $Pipeline, + [Parameter(Mandatory = $true)] [bool] $BuildImages + ) + + $buildCondition = '${{ if eq(parameters.buildImages, true) }}' + $recoveryCondition = '${{ if eq(parameters.buildImages, false) }}' + + foreach ($entry in $Pipeline.stages) { + if ($entry.PSObject.Properties['stage']) { + Write-Output $entry + } elseif ($BuildImages -and $entry.PSObject.Properties[$buildCondition]) { + Write-Output $entry.PSObject.Properties[$buildCondition].Value + } elseif (-not $BuildImages -and $entry.PSObject.Properties[$recoveryCondition]) { + Write-Output $entry.PSObject.Properties[$recoveryCondition].Value + } + } +} + +function Get-CiDeployDependencies { + param( + [Parameter(Mandatory = $true)] $Stage, + [Parameter(Mandatory = $true)] [bool] $BuildImages + ) + + $buildCondition = '${{ if eq(parameters.buildImages, true) }}' + $recoveryCondition = '${{ if eq(parameters.buildImages, false) }}' + + foreach ($dependency in @($Stage.dependsOn)) { + if ($dependency -is [string]) { + Write-Output $dependency + } elseif ($BuildImages -and $dependency.PSObject.Properties[$buildCondition]) { + Write-Output $dependency.PSObject.Properties[$buildCondition].Value + } elseif (-not $BuildImages -and $dependency.PSObject.Properties[$recoveryCondition]) { + Write-Output $dependency.PSObject.Properties[$recoveryCondition].Value + } + } +} + +function Get-CiDeployImageTag { + param( + [Parameter(Mandatory = $true)] $Stage, + [Parameter(Mandatory = $true)] [bool] $BuildImages + ) + + $condition = if ($BuildImages) { + '${{ if eq(parameters.buildImages, true) }}' + } else { + '${{ else }}' + } + + $parameters = $Stage.jobs[0].parameters + $conditionalParameters = $parameters.PSObject.Properties[$condition].Value + return $conditionalParameters.imageTag +} + +$script:pythonPath = Get-PythonWithPyYaml +Assert-AzureCliBicepAvailable + +$topologyTests = Join-Path $repositoryRoot 'build/jobs/scripts/tests/Test-AcaSqlTopology.ps1' +& $topologyTests + +$legacyPlan = & $resolver -Version Stu3 +Assert-Equal -Expected 'FHIRStu3' -Actual $legacyPlan.SqlDatabaseName -Description 'Legacy database default changed' +Assert-Equal -Expected 'Firely' -Actual $legacyPlan.FhirSdkProviderDefault -Description 'Legacy SDK provider default changed' +Assert-Equal -Expected $false -Actual $legacyPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'Legacy deployment would emit a new provider setting' + +$configuredPlan = & $resolver -Version R4 -ConfiguredFhirSdkProviderDefault Ignixa +Assert-Equal -Expected 'Ignixa' -Actual $configuredPlan.FhirSdkProviderDefault -Description 'Configured provider was not preserved' +Assert-Equal -Expected $true -Actual $configuredPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'Configured provider would not be emitted' + +$configuredFirelyPlan = & $resolver -Version R4 -ConfiguredFhirSdkProviderDefault Firely +Assert-Equal -Expected $true -Actual $configuredFirelyPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'Flattened Firely setting would be discarded' + +Assert-Throws -Action { + & $resolver -Version R4 -FhirSdkProviderDefault Ignixa -ConfiguredFhirSdkProviderDefault Firely +} -ExpectedMessage 'conflicts with configured provider' -Description 'Conflicting providers were accepted' + +$stu3VNextPlan = & $resolver -Version Stu3 -SqlDatabaseName FHIRStu3VNext -FhirSdkProviderDefault Ignixa +$r4VNextPlan = & $resolver -Version R4 -SqlDatabaseName FHIRR4VNext -FhirSdkProviderDefault Ignixa +Assert-Equal -Expected 'FHIRStu3VNext' -Actual $stu3VNextPlan.SqlDatabaseName -Description 'STU3 vNext database is not isolated' +Assert-Equal -Expected 'FHIRR4VNext' -Actual $r4VNextPlan.SqlDatabaseName -Description 'R4 vNext database is not isolated' +Assert-Equal -Expected $true -Actual $stu3VNextPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'STU3 vNext provider would not be emitted' +Assert-Equal -Expected $true -Actual $r4VNextPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'R4 vNext provider would not be emitted' + +$matchingEnvironment = @([pscustomobject]@{ name = 'FhirServer__CoreFeatures__FhirSdkProvider__Default'; value = 'Ignixa' }) +Assert-EffectiveFhirSdkProvider -EnvironmentSettings $matchingEnvironment -ExpectedProvider Ignixa -ContainerAppName matching-app +Assert-EffectiveFhirSdkProvider -EnvironmentSettings @() -ExpectedProvider '' -ContainerAppName legacy-app +Assert-Throws -Action { + Assert-EffectiveFhirSdkProvider -EnvironmentSettings @() -ExpectedProvider Ignixa -ContainerAppName missing-app +} -ExpectedMessage 'does not define' -Description 'Missing expected provider was accepted' +Assert-Throws -Action { + Assert-EffectiveFhirSdkProvider -EnvironmentSettings $matchingEnvironment -ExpectedProvider Firely -ContainerAppName mismatch-app +} -ExpectedMessage "expected 'Firely'" -Description 'Mismatched provider was accepted' + +$yamlPaths = @( + 'build/build-variables.yml', + 'build/ci-deploy.yml', + 'build/ci-pipeline.yml', + 'build/jobs/e2e-tests.yml', + 'build/jobs/provision-deploy.yml', + 'build/jobs/run-sql-tests.yml', + 'build/pr-pipeline.yml', + 'build/tasks/e2e-set-variables.yml' +) +$yamlDocuments = @{} +foreach ($relativePath in $yamlPaths) { + $yamlDocuments[$relativePath] = ConvertFrom-YamlFile (Join-Path $repositoryRoot $relativePath) +} + +$variables = $yamlDocuments['build/build-variables.yml'].variables +Assert-Equal -Expected '$(DeploymentEnvironmentName)-svn' -Actual $variables.DeploymentEnvironmentNameSqlVNext -Description 'STU3 vNext app name is not distinct' +Assert-Equal -Expected '$(DeploymentEnvironmentName)-r4vn' -Actual $variables.DeploymentEnvironmentNameR4SqlVNext -Description 'R4 vNext app name is not distinct' +Assert-Equal -Expected '$(KeyVaultBaseName)-sql-vn' -Actual $variables.KeyVaultNameSqlVNext -Description 'STU3 vNext Key Vault is not distinct' +Assert-Equal -Expected '$(KeyVaultBaseName)-r4-vn' -Actual $variables.KeyVaultNameR4SqlVNext -Description 'R4 vNext Key Vault is not distinct' + +$prPipeline = $yamlDocuments['build/pr-pipeline.yml'] +$ciDeployPipeline = $yamlDocuments['build/ci-deploy.yml'] +$mainPipeline = $yamlDocuments['build/ci-pipeline.yml'] + +$buildImagesParameter = @($ciDeployPipeline.parameters | Where-Object { $_.name -eq 'buildImages' }) +Assert-Equal -Expected 1 -Actual $buildImagesParameter.Count -Description 'CI deploy pipeline does not declare buildImages once' +Assert-Equal -Expected 'boolean' -Actual $buildImagesParameter[0].type -Description 'buildImages parameter is not boolean' +Assert-Equal -Expected $true -Actual $buildImagesParameter[0].default -Description 'Default CI deploy mode no longer builds images' +$imageTagOverrideParameter = @($ciDeployPipeline.parameters | Where-Object { $_.name -eq 'imageTagOverride' }) +Assert-Equal -Expected 1 -Actual $imageTagOverrideParameter.Count -Description 'CI deploy pipeline does not declare imageTagOverride once' +Assert-Equal -Expected 'string' -Actual $imageTagOverrideParameter[0].type -Description 'imageTagOverride parameter is not a string' +Assert-Equal -Expected '' -Actual $imageTagOverrideParameter[0].default -Description 'imageTagOverride parameter should default to empty' + +$defaultCiDeployStages = @(Get-CiDeployStages -Pipeline $ciDeployPipeline -BuildImages $true) +$recoveryCiDeployStages = @(Get-CiDeployStages -Pipeline $ciDeployPipeline -BuildImages $false) +$defaultCiDeployStageNames = @($defaultCiDeployStages.stage) +$recoveryCiDeployStageNames = @($recoveryCiDeployStages.stage) +$environmentStageNames = @( + 'provisionEnvironment', + 'createAcaEnvironment', + 'createNsp', + 'aadTestEnvironment', + 'deploySqlServer', + 'deploySqlElasticPool', + 'deploySqlVNextElasticPool', + 'deployStu3', + 'deployStu3Sql', + 'deployStu3SqlVNext', + 'deployR4', + 'deployR4Sql', + 'deployR4SqlVNext', + 'deployR5Sql' +) +Assert-SequenceEqual -Expected (@('UpdateVersion', 'DockerBuild') + $environmentStageNames) -Actual $defaultCiDeployStageNames -Description 'Default CI deploy stage plan changed' +Assert-SequenceEqual -Expected (@('ValidateRecoveryParameters') + $environmentStageNames) -Actual $recoveryCiDeployStageNames -Description 'Recovery CI deploy stage plan is incomplete' +Assert-True -Condition ('UpdateVersion' -in $defaultCiDeployStageNames) -Description 'Default CI deploy plan omits UpdateVersion' +Assert-True -Condition ('DockerBuild' -in $defaultCiDeployStageNames) -Description 'Default CI deploy plan omits DockerBuild' +Assert-True -Condition ('ValidateRecoveryParameters' -notin $defaultCiDeployStageNames) -Description 'Default CI deploy plan includes recovery validation' +Assert-True -Condition ('UpdateVersion' -notin $recoveryCiDeployStageNames) -Description 'Recovery CI deploy plan includes UpdateVersion' +Assert-True -Condition ('DockerBuild' -notin $recoveryCiDeployStageNames) -Description 'Recovery CI deploy plan includes DockerBuild' +Assert-True -Condition ('ValidateRecoveryParameters' -in $recoveryCiDeployStageNames) -Description 'Recovery CI deploy plan omits parameter validation' + +$deploymentStageNames = @( + 'deployStu3', + 'deployStu3Sql', + 'deployStu3SqlVNext', + 'deployR4', + 'deployR4Sql', + 'deployR4SqlVNext', + 'deployR5Sql' +) +foreach ($stageName in $deploymentStageNames) { + $defaultStage = @($defaultCiDeployStages | Where-Object { $_.stage -eq $stageName })[0] + $recoveryStage = @($recoveryCiDeployStages | Where-Object { $_.stage -eq $stageName })[0] + Assert-True -Condition ('DockerBuild' -in @(Get-CiDeployDependencies -Stage $defaultStage -BuildImages $true)) -Description "$stageName default plan does not depend on DockerBuild" + Assert-True -Condition ('DockerBuild' -notin @(Get-CiDeployDependencies -Stage $recoveryStage -BuildImages $false)) -Description "$stageName recovery plan depends on DockerBuild" + Assert-Equal -Expected '$(ImageTag)' -Actual (Get-CiDeployImageTag -Stage $defaultStage -BuildImages $true) -Description "$stageName default image tag changed" + Assert-Equal -Expected '${{ parameters.imageTagOverride }}' -Actual (Get-CiDeployImageTag -Stage $recoveryStage -BuildImages $false) -Description "$stageName recovery image tag does not use the override" +} + +$defaultProvisionStage = @($defaultCiDeployStages | Where-Object { $_.stage -eq 'provisionEnvironment' })[0] +$recoveryProvisionStage = @($recoveryCiDeployStages | Where-Object { $_.stage -eq 'provisionEnvironment' })[0] +Assert-Equal -Expected 0 -Actual @(Get-CiDeployDependencies -Stage $defaultProvisionStage -BuildImages $true).Count -Description 'Default environment provisioning dependencies changed' +Assert-SequenceEqual -Expected @('ValidateRecoveryParameters') -Actual @(Get-CiDeployDependencies -Stage $recoveryProvisionStage -BuildImages $false) -Description 'Recovery provisioning can start before parameter validation' + +$recoveryValidationStage = @($recoveryCiDeployStages | Where-Object { $_.stage -eq 'ValidateRecoveryParameters' })[0] +$recoveryValidationTask = $recoveryValidationStage.jobs[0].steps[0] +Assert-Equal -Expected '${{ parameters.imageTagOverride }}' -Actual $recoveryValidationTask.env.IMAGE_TAG_OVERRIDE -Description 'Recovery validation does not receive imageTagOverride' +$recoveryValidationScript = [scriptblock]::Create($recoveryValidationTask.inputs.script) +$originalImageTagOverride = $env:IMAGE_TAG_OVERRIDE +try { + $env:IMAGE_TAG_OVERRIDE = 'master' + & $recoveryValidationScript + + $env:IMAGE_TAG_OVERRIDE = ' ' + Assert-Throws -Action { + & $recoveryValidationScript + } -ExpectedMessage "Parameter 'imageTagOverride' must be non-empty when 'buildImages' is false." -Description 'Blank recovery image tag was accepted' +} +finally { + $env:IMAGE_TAG_OVERRIDE = $originalImageTagOverride +} + +foreach ($pipelinePlan in @( + @{ Pipeline = $prPipeline; Stu3Stage = 'deployStu3SqlVNext'; R4Stage = 'deployR4SqlVNext'; ResourceGroup = '$(UniqueResourceGroupName)' }, + @{ Pipeline = $ciDeployPipeline; Stu3Stage = 'deployStu3SqlVNext'; R4Stage = 'deployR4SqlVNext'; ResourceGroup = '$(ResourceGroupName)' } +)) { + $stu3Parameters = (Get-Stage -Pipeline $pipelinePlan.Pipeline -Name $pipelinePlan.Stu3Stage).jobs[0].parameters + $r4Parameters = (Get-Stage -Pipeline $pipelinePlan.Pipeline -Name $pipelinePlan.R4Stage).jobs[0].parameters + + Assert-Equal -Expected 'FHIRStu3VNext' -Actual $stu3Parameters.sqlDatabaseName -Description "$($pipelinePlan.Stu3Stage) database flow is incorrect" + Assert-Equal -Expected 'FHIRR4VNext' -Actual $r4Parameters.sqlDatabaseName -Description "$($pipelinePlan.R4Stage) database flow is incorrect" + Assert-Equal -Expected '$(SqlVNextElasticPoolName)' -Actual $stu3Parameters.sqlElasticPoolName -Description "$($pipelinePlan.Stu3Stage) pool flow is incorrect" + Assert-Equal -Expected '$(SqlVNextElasticPoolName)' -Actual $r4Parameters.sqlElasticPoolName -Description "$($pipelinePlan.R4Stage) pool flow is incorrect" + Assert-Equal -Expected $pipelinePlan.ResourceGroup -Actual $stu3Parameters.resourceGroup -Description "$($pipelinePlan.Stu3Stage) resource group is incorrect" + Assert-Equal -Expected $pipelinePlan.ResourceGroup -Actual $r4Parameters.resourceGroup -Description "$($pipelinePlan.R4Stage) resource group is incorrect" +} + +$prStu3SqlParameters = (Get-Stage -Pipeline $prPipeline -Name deployStu3Sql).jobs[0].parameters +$prR4SqlParameters = (Get-Stage -Pipeline $prPipeline -Name deployR4Sql).jobs[0].parameters +Assert-True -Condition (-not $prStu3SqlParameters.PSObject.Properties['sqlElasticPoolName']) -Description 'Existing PR STU3 database was moved to the vNext pool' +Assert-True -Condition (-not $prR4SqlParameters.PSObject.Properties['sqlElasticPoolName']) -Description 'Existing PR R4 database was moved to the vNext pool' + +$ciStu3SqlParameters = (Get-Stage -Pipeline $ciDeployPipeline -Name deployStu3Sql).jobs[0].parameters +$ciR4SqlParameters = (Get-Stage -Pipeline $ciDeployPipeline -Name deployR4Sql).jobs[0].parameters +Assert-Equal -Expected '$(DeploymentEnvironmentName)-pool' -Actual $ciStu3SqlParameters.sqlElasticPoolName -Description 'Existing CI STU3 database pool changed' +Assert-Equal -Expected '$(DeploymentEnvironmentName)-pool' -Actual $ciR4SqlParameters.sqlElasticPoolName -Description 'Existing CI R4 database pool changed' + +foreach ($pipeline in @($prPipeline, $ciDeployPipeline)) { + $poolStage = Get-Stage -Pipeline $pipeline -Name deploySqlVNextElasticPool + Assert-Equal -Expected 1 -Actual @($poolStage.jobs).Count -Description 'vNext pool stage contains unrelated jobs' + $poolParameters = $poolStage.jobs[0].parameters + Assert-Equal -Expected '$(SqlVNextElasticPoolName)' -Actual $poolParameters.elasticPoolName -Description 'vNext pool name is incorrect' + Assert-Equal -Expected 4 -Actual $poolParameters.capacity -Description 'vNext pool capacity does not support both canaries' + Assert-Equal -Expected 2 -Actual $poolParameters.dbMaxCapacity -Description 'vNext per-database cap is incorrect' +} +Assert-Equal -Expected 2 -Actual @((Get-Stage -Pipeline $prPipeline -Name deploySqlServer).jobs).Count -Description 'PR SQL server stage topology changed' + +foreach ($stageName in @('redeployStu3SqlVNext', 'redeployR4SqlVNext')) { + $stage = Get-Stage -Pipeline $mainPipeline -Name $stageName + Assert-Equal -Expected './jobs/redeploy-webapp.yml' -Actual $stage.jobs[0].template -Description "$stageName does not use persistent redeployment" +} + +foreach ($pipeline in @($prPipeline, $mainPipeline)) { + $validationStage = Get-Stage -Pipeline $pipeline -Name AnalyzeSecurity + $validationJob = @($validationStage.jobs | Where-Object { $_.job -eq 'ValidateAcaSqlDeploymentPlan' }) + Assert-Equal -Expected 1 -Actual $validationJob.Count -Description 'Deployment-plan validation job is not wired into CI' + Assert-Equal -Expected '$(System.DefaultWorkingDirectory)/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1' -Actual $validationJob[0].steps[0].inputs.filePath -Description 'CI validation runs the wrong deployment-plan test' +} + +foreach ($pipeline in @($prPipeline, $mainPipeline)) { + foreach ($testPlan in @( + @{ + Stage = 'testStu3SqlVNext' + KeyVault = '$(KeyVaultNameSqlVNext)' + Database = 'FHIRStu3VNext' + }, + @{ + Stage = 'testR4SqlVNext' + KeyVault = '$(KeyVaultNameR4SqlVNext)' + Database = 'FHIRR4VNext' + } + )) { + $parameters = (Get-Stage -Pipeline $pipeline -Name $testPlan.Stage).jobs[0].parameters + Assert-Equal -Expected $testPlan.KeyVault -Actual $parameters.expectedKeyVaultName -Description "$($testPlan.Stage) Key Vault expectation is incorrect" + Assert-Equal -Expected '$(DeploymentEnvironmentName)' -Actual $parameters.expectedSqlServerName -Description "$($testPlan.Stage) SQL server expectation is incorrect" + Assert-Equal -Expected $testPlan.Database -Actual $parameters.expectedSqlDatabaseName -Description "$($testPlan.Stage) SQL database expectation is incorrect" + Assert-Equal -Expected '$(SqlVNextElasticPoolName)' -Actual $parameters.expectedSqlElasticPoolName -Description "$($testPlan.Stage) SQL pool expectation is incorrect" + } +} + +$aggregateDependencies = @(Get-Stage -Pipeline $mainPipeline -Name aggregateCoverage).dependsOn +$tagDependencies = @(Get-Stage -Pipeline $mainPipeline -Name DockerAddTag).dependsOn +$scaleDependencies = @(Get-Stage -Pipeline $mainPipeline -Name scaleDownContainerApps).dependsOn +foreach ($canary in @('testStu3SqlVNext', 'testR4SqlVNext')) { + Assert-True -Condition ($canary -notin $aggregateDependencies) -Description "$canary blocks aggregate coverage" + Assert-True -Condition ($canary -notin $tagDependencies) -Description "$canary blocks Docker tag promotion" + Assert-True -Condition ($canary -in $scaleDependencies) -Description "$canary is missing from scale-down dependencies" +} + +$provisionTemplate = $yamlDocuments['build/jobs/provision-deploy.yml'] +$provisionTask = @($provisionTemplate.jobs[0].steps | Where-Object { $_.name -eq 'SetAcaOutputs' })[0] +Assert-Equal -Expected '${{ parameters.sqlDatabaseName }}' -Actual (Get-ScriptArgument -Arguments $provisionTask.inputs.ScriptArguments -Name SqlDatabaseName) -Description 'SQL database parameter is not forwarded' +Assert-Equal -Expected '${{ parameters.fhirSdkProviderDefault }}' -Actual (Get-ScriptArgument -Arguments $provisionTask.inputs.ScriptArguments -Name FhirSdkProviderDefault) -Description 'Provider parameter is not forwarded' + +$runSqlTemplate = $yamlDocuments['build/jobs/run-sql-tests.yml'] +$integrationGateName = '${{ if eq(parameters.runIntegrationTests, true) }}' +$integrationGates = @($runSqlTemplate.jobs | Where-Object { $_.PSObject.Properties[$integrationGateName] }) +Assert-Equal -Expected 1 -Actual $integrationGates.Count -Description 'SQL integration tests are not compile-time gated' +$integrationJob = $integrationGates[0].PSObject.Properties[$integrationGateName].Value[0] +Assert-Equal -Expected 'SqlIntegrationTests' -Actual $integrationJob.job -Description 'Compile-time integration gate contains the wrong job' +Assert-True -Condition (-not $integrationJob.PSObject.Properties['condition']) -Description 'SQL integration job still uses runtime gating' +$e2eInvocations = @(Find-TemplateInvocation -Node $runSqlTemplate.jobs -Template 'e2e-tests.yml') +Assert-Equal -Expected 3 -Actual $e2eInvocations.Count -Description 'SQL E2E expectation propagation count changed' +Assert-True -Condition ('${{ parameters.mainCategoryFilter }}' -in @($e2eInvocations.parameters.categoryFilter)) -Description 'Main SQL E2E invocation is missing' +Assert-True -Condition ('Category=IndexAndReindex' -in @($e2eInvocations.parameters.categoryFilter)) -Description 'Reindex SQL E2E invocation is missing' +foreach ($invocation in $e2eInvocations) { + Assert-Equal -Expected '${{ parameters.expectedFhirSdkProviderDefault }}' -Actual $invocation.parameters.expectedFhirSdkProviderDefault -Description 'run-sql-tests provider expectation is not forwarded' + Assert-Equal -Expected '${{ parameters.expectedKeyVaultName }}' -Actual $invocation.parameters.expectedKeyVaultName -Description 'run-sql-tests Key Vault expectation is not forwarded' + Assert-Equal -Expected '${{ parameters.expectedSqlServerName }}' -Actual $invocation.parameters.expectedSqlServerName -Description 'run-sql-tests SQL server expectation is not forwarded' + Assert-Equal -Expected '${{ parameters.expectedSqlDatabaseName }}' -Actual $invocation.parameters.expectedSqlDatabaseName -Description 'run-sql-tests SQL database expectation is not forwarded' + Assert-Equal -Expected '${{ parameters.expectedSqlElasticPoolName }}' -Actual $invocation.parameters.expectedSqlElasticPoolName -Description 'run-sql-tests SQL pool expectation is not forwarded' +} + +$e2eTemplate = $yamlDocuments['build/jobs/e2e-tests.yml'] +$variableInvocation = @($e2eTemplate.steps | Where-Object { $_.template -eq '../tasks/e2e-set-variables.yml' })[0] +Assert-Equal -Expected '../tasks/e2e-set-variables.yml' -Actual $e2eTemplate.steps[1].template -Description 'E2E variable and topology validation no longer runs before the test command' +Assert-Equal -Expected '${{ parameters.expectedFhirSdkProviderDefault }}' -Actual $variableInvocation.parameters.expectedFhirSdkProviderDefault -Description 'e2e-tests provider expectation is not forwarded' +Assert-Equal -Expected '${{ parameters.expectedKeyVaultName }}' -Actual $variableInvocation.parameters.expectedKeyVaultName -Description 'e2e-tests Key Vault expectation is not forwarded' +Assert-Equal -Expected '${{ parameters.expectedSqlServerName }}' -Actual $variableInvocation.parameters.expectedSqlServerName -Description 'e2e-tests SQL server expectation is not forwarded' +Assert-Equal -Expected '${{ parameters.expectedSqlDatabaseName }}' -Actual $variableInvocation.parameters.expectedSqlDatabaseName -Description 'e2e-tests SQL database expectation is not forwarded' +Assert-Equal -Expected '${{ parameters.expectedSqlElasticPoolName }}' -Actual $variableInvocation.parameters.expectedSqlElasticPoolName -Description 'e2e-tests SQL pool expectation is not forwarded' +$providerParameter = @($yamlDocuments['build/tasks/e2e-set-variables.yml'].parameters | Where-Object { $_.name -eq 'expectedFhirSdkProviderDefault' }) +Assert-Equal -Expected 1 -Actual $providerParameter.Count -Description 'e2e-set-variables does not declare the provider expectation' +foreach ($parameterName in @('expectedKeyVaultName', 'expectedSqlServerName', 'expectedSqlDatabaseName', 'expectedSqlElasticPoolName')) { + foreach ($templatePath in @('build/jobs/run-sql-tests.yml', 'build/jobs/e2e-tests.yml', 'build/tasks/e2e-set-variables.yml')) { + $topologyParameter = @($yamlDocuments[$templatePath].parameters | Where-Object { $_.name -eq $parameterName }) + Assert-Equal -Expected 1 -Actual $topologyParameter.Count -Description "$templatePath does not declare '$parameterName'" + Assert-Equal -Expected '' -Actual $topologyParameter[0].default -Description "$templatePath '$parameterName' default changes legacy lanes" + } +} +$setVariablesTask = $yamlDocuments['build/tasks/e2e-set-variables.yml'].steps[0] +$inlineTokens = $null +$inlineParseErrors = $null +$normalizedInline = [regex]::Replace($setVariablesTask.inputs.Inline, '\$\{\{.*?\}\}', 'TemplateValue') +$inlineAst = [System.Management.Automation.Language.Parser]::ParseInput($normalizedInline, [ref]$inlineTokens, [ref]$inlineParseErrors) +Assert-Equal -Expected 0 -Actual $inlineParseErrors.Count -Description 'e2e-set-variables inline PowerShell has parse errors' +$secretVariableFunction = @($inlineAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Set-SecretsAsPipelineVariables' +}, $true)) +Assert-Equal -Expected 1 -Actual $secretVariableFunction.Count -Description 'Secret pipeline-variable helper is missing' +$secretLoggingCommands = @($secretVariableFunction[0].Body.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Write-Host' -and + $node.Extent.Text.Contains('##vso[task.setvariable') +}, $true)) +Assert-Equal -Expected 1 -Actual $secretLoggingCommands.Count -Description 'Secret pipeline-variable command shape changed' +Assert-True ` + -Condition ($secretLoggingCommands[0].Extent.Text.Contains(';issecret=true]')) ` + -Description 'Key Vault values are not emitted as secret Azure Pipelines variables' +$providerAssertionCommands = @($inlineAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Assert-EffectiveFhirSdkProvider' +}, $true)) +Assert-Equal -Expected 1 -Actual $providerAssertionCommands.Count -Description 'e2e-set-variables does not invoke effective-provider validation' +$topologyAssertionCommands = @($inlineAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Assert-AcaSqlTopology' +}, $true)) +Assert-Equal -Expected 1 -Actual $topologyAssertionCommands.Count -Description 'e2e-set-variables does not invoke SQL topology validation' + +$provisionScriptPath = Join-Path $repositoryRoot 'build/jobs/scripts/Provision-AcaDeploy.ps1' +$tokens = $null +$parseErrors = $null +$provisionAst = [System.Management.Automation.Language.Parser]::ParseFile($provisionScriptPath, [ref]$tokens, [ref]$parseErrors) +Assert-Equal -Expected 0 -Actual $parseErrors.Count -Description 'Provision-AcaDeploy.ps1 has parse errors' +$assignments = @($provisionAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true)) +$databaseAssignment = @($assignments | Where-Object { $_.Left.Extent.Text -eq '$templateParameters["sqlDatabaseName"]' }) +Assert-Equal -Expected 1 -Actual $databaseAssignment.Count -Description 'Provision script does not assign the resolved database template parameter' +Assert-Equal -Expected '$sqlDatabaseName' -Actual $databaseAssignment[0].Right.Extent.Text -Description 'Provision script database template parameter uses the wrong value' +$providerAssignment = @($assignments | Where-Object { $_.Left.Extent.Text -eq '$templateParameters["fhirSdkProviderDefault"]' }) +Assert-Equal -Expected 1 -Actual $providerAssignment.Count -Description 'Provision script does not assign the resolved provider template parameter' +Assert-Equal -Expected '$sqlDeploymentPlan.FhirSdkProviderDefault' -Actual $providerAssignment[0].Right.Extent.Text -Description 'Provision script provider template parameter bypasses the deployment plan' + +$bicepPath = Join-Path $repositoryRoot 'samples/templates/aca/fhir-sql.bicep' +$armJson = & az bicep build --file $bicepPath --stdout +if ($LASTEXITCODE -ne 0) { + throw 'Bicep compilation failed.' +} + +$arm = $armJson | ConvertFrom-Json -Depth 100 +Assert-Equal -Expected '' -Actual $arm.parameters.fhirSdkProviderDefault.defaultValue -Description 'Legacy ARM provider default changed' +Assert-True -Condition ($arm.parameters.sqlDatabaseName.defaultValue -like "*parameters('fhirVersion')*") -Description 'ARM database default is not version-specific' +$secret = @($arm.resources | Where-Object { $_.type -eq 'Microsoft.KeyVault/vaults/secrets' })[0] +Assert-True -Condition ($secret.properties.value -like "*parameters('sqlDatabaseName')*") -Description 'Compiled SQL connection string does not use sqlDatabaseName' +Assert-True -Condition ($arm.variables.sdkProviderEnvVars -like "*FhirServer__CoreFeatures__FhirSdkProvider__Default*") -Description 'Compiled ARM does not emit the provider environment variable' +Assert-True -Condition ($arm.variables.sdkProviderEnvVars -like "*empty(parameters('fhirSdkProviderDefault'))*") -Description 'Compiled ARM changes the legacy environment' + +Write-Host 'SQL vNext deployment plan validation passed.' diff --git a/build/pr-pipeline.yml b/build/pr-pipeline.yml index bfddd87d0a..b830ead5fb 100644 --- a/build/pr-pipeline.yml +++ b/build/pr-pipeline.yml @@ -90,6 +90,18 @@ stages: - ImageOverride -equals $(InternalWindowsImage) steps: - template: ./jobs/analyze.yml + - job: ValidateAcaSqlDeploymentPlan + displayName: 'Validate ACA SQL deployment plan' + pool: + name: '$(InternalPool)' + demands: + - ImageOverride -equals $(InternalLinuxImage) + steps: + - task: PowerShell@2 + displayName: 'Validate SQL vNext deployment plan' + inputs: + filePath: '$(System.DefaultWorkingDirectory)/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1' + pwsh: true - stage: DockerBuild displayName: 'Build images' @@ -266,6 +278,21 @@ stages: deploymentName: "IntegrationTests" nspName: 'nsp-$(UniqueResourceGroupName)' +- stage: deploySqlVNextElasticPool + displayName: 'Deploy SQL vNext Elastic Pool (PR)' + dependsOn: + - deploySqlServer + jobs: + - template: ./jobs/provision-sqlElasticPool.yml + parameters: + resourceGroup: $(UniqueResourceGroupName) + sqlServerName: $(DeploymentEnvironmentName) + elasticPoolName: $(SqlVNextElasticPoolName) + capacity: 4 + maxSizeBytes: '68719476736' + dbMinCapacity: 0 + dbMaxCapacity: 2 + - stage: deployStu3 displayName: 'Deploy STU3 CosmosDB Site' dependsOn: @@ -308,6 +335,32 @@ stages: sqlServerName: $(DeploymentEnvironmentName) reindexEnabled: true +- stage: deployStu3SqlVNext + displayName: 'Deploy STU3 SQL vNext SDK Site' + dependsOn: + - DockerBuild + - setupEnvironment + - deploySqlServer + - deploySqlVNextElasticPool + jobs: + - template: ./jobs/provision-deploy.yml + parameters: + dataStore: sql + version: Stu3 + webAppName: $(DeploymentEnvironmentNameSqlVNext) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameSqlVNext) + subscription: $(ConnectedServiceName) + resourceGroup: $(UniqueResourceGroupName) + testEnvironmentUrl: $(TestApplicationResource) + imageTag: $(ImageTag) + schemaAutomaticUpdatesEnabled: 'auto' + sqlServerName: $(DeploymentEnvironmentName) + sqlElasticPoolName: $(SqlVNextElasticPoolName) + sqlDatabaseName: FHIRStu3VNext + fhirSdkProviderDefault: Ignixa + reindexEnabled: true + - stage: deployR4 displayName: 'Deploy R4 CosmosDB Site' dependsOn: @@ -350,6 +403,32 @@ stages: sqlServerName: $(DeploymentEnvironmentName) reindexEnabled: true +- stage: deployR4SqlVNext + displayName: 'Deploy R4 SQL vNext SDK Site' + dependsOn: + - DockerBuild + - setupEnvironment + - deploySqlServer + - deploySqlVNextElasticPool + jobs: + - template: ./jobs/provision-deploy.yml + parameters: + dataStore: sql + version: R4 + webAppName: $(DeploymentEnvironmentNameR4SqlVNext) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameR4SqlVNext) + subscription: $(ConnectedServiceName) + resourceGroup: $(UniqueResourceGroupName) + testEnvironmentUrl: $(TestApplicationResource) + imageTag: $(ImageTag) + schemaAutomaticUpdatesEnabled: 'auto' + sqlServerName: $(DeploymentEnvironmentName) + sqlElasticPoolName: $(SqlVNextElasticPoolName) + sqlDatabaseName: FHIRR4VNext + fhirSdkProviderDefault: Ignixa + reindexEnabled: true + - stage: deployR5Sql displayName: 'Deploy R5 SQL Site' dependsOn: @@ -405,6 +484,31 @@ stages: containerAppName: $(DeploymentEnvironmentNameSql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest +- stage: testStu3SqlVNext + displayName: 'Run Stu3 SQL vNext SDK Tests' + dependsOn: + - BuildArtifacts + - setupEnvironment + - deployStu3SqlVNext + variables: + TestEnvironmentUrl_Stu3_Sql: $[stageDependencies.deployStu3SqlVNext.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Stu3_Sql']] + TestEnvironmentUrl_Sql: $[stageDependencies.deployStu3SqlVNext.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Sql']] + jobs: + - template: ./jobs/run-sql-tests.yml + parameters: + version: Stu3 + keyVaultName: $(KeyVaultNameSqlVNext) + containerAppName: $(DeploymentEnvironmentNameSqlVNext) + integrationSqlServerName: $(DeploymentEnvironmentName)inttest + runIntegrationTests: false + runBulkUpdateJob: false + testRunTitleSuffix: ' vNext SDK' + expectedFhirSdkProviderDefault: Ignixa + expectedKeyVaultName: $(KeyVaultNameSqlVNext) + expectedSqlServerName: $(DeploymentEnvironmentName) + expectedSqlDatabaseName: FHIRStu3VNext + expectedSqlElasticPoolName: $(SqlVNextElasticPoolName) + - stage: testR4Cosmos displayName: 'Run R4 Cosmos Tests' dependsOn: @@ -438,6 +542,31 @@ stages: containerAppName: $(DeploymentEnvironmentNameR4Sql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest +- stage: testR4SqlVNext + displayName: 'Run R4 SQL vNext SDK Tests' + dependsOn: + - BuildArtifacts + - setupEnvironment + - deployR4SqlVNext + variables: + TestEnvironmentUrl_R4_Sql: $[stageDependencies.deployR4SqlVNext.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_R4_Sql']] + TestEnvironmentUrl_Sql: $[stageDependencies.deployR4SqlVNext.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Sql']] + jobs: + - template: ./jobs/run-sql-tests.yml + parameters: + version: R4 + keyVaultName: $(KeyVaultNameR4SqlVNext) + containerAppName: $(DeploymentEnvironmentNameR4SqlVNext) + integrationSqlServerName: $(DeploymentEnvironmentName)inttest + runIntegrationTests: false + runBulkUpdateJob: false + testRunTitleSuffix: ' vNext SDK' + expectedFhirSdkProviderDefault: Ignixa + expectedKeyVaultName: $(KeyVaultNameR4SqlVNext) + expectedSqlServerName: $(DeploymentEnvironmentName) + expectedSqlDatabaseName: FHIRR4VNext + expectedSqlElasticPoolName: $(SqlVNextElasticPoolName) + - stage: testR5Sql displayName: 'Run R5 SQL Tests' dependsOn: diff --git a/build/tasks/e2e-set-variables.yml b/build/tasks/e2e-set-variables.yml index 22eb8c4a5b..0137f018ac 100644 --- a/build/tasks/e2e-set-variables.yml +++ b/build/tasks/e2e-set-variables.yml @@ -5,6 +5,21 @@ parameters: type: string - name: appServiceType type: string +- name: expectedFhirSdkProviderDefault + type: string + default: '' +- name: expectedKeyVaultName + type: string + default: '' +- name: expectedSqlServerName + type: string + default: '' +- name: expectedSqlDatabaseName + type: string + default: '' +- name: expectedSqlElasticPoolName + type: string + default: '' steps: - task: AzurePowerShell@5 @@ -32,7 +47,8 @@ steps: throw "$($secret.Name) is empty" } - Write-Host "##vso[task.setvariable variable=$($environmentVariableName)]$($plainValue)" + # Secret variables are masked by Azure Pipelines when later tasks reference them. + Write-Host "##vso[task.setvariable variable=$($environmentVariableName);issecret=true]$($plainValue)" } } @@ -87,6 +103,9 @@ steps: Write-Host "##vso[task.setvariable variable=$versionVariableName]$url" } + . "$(System.DefaultWorkingDirectory)/build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1" + . "$(System.DefaultWorkingDirectory)/build/jobs/scripts/Assert-AcaSqlTopology.ps1" + $keyVault = "$(KeyVaultBaseName)-ts" Set-SecretsAsPipelineVariables -VaultName $keyVault @@ -102,6 +121,17 @@ steps: throw "Container App '$containerAppName' has no environment variables configured." } + $expectedFhirSdkProviderDefault = '${{ parameters.expectedFhirSdkProviderDefault }}' + Assert-EffectiveFhirSdkProvider -EnvironmentSettings $envSettings -ExpectedProvider $expectedFhirSdkProviderDefault -ContainerAppName $containerAppName + Assert-AcaSqlTopology ` + -EnvironmentSettings $envSettings ` + -ContainerAppName $containerAppName ` + -ResourceGroupName '$(UniqueResourceGroupName)' ` + -ExpectedKeyVaultName '${{ parameters.expectedKeyVaultName }}' ` + -ExpectedSqlServerName '${{ parameters.expectedSqlServerName }}' ` + -ExpectedSqlDatabaseName '${{ parameters.expectedSqlDatabaseName }}' ` + -ExpectedSqlElasticPoolName '${{ parameters.expectedSqlElasticPoolName }}' + Set-TestEnvironmentUrlVariables -ContainerAppData $containerAppData -Version '${{ parameters.version }}' -AppServiceType '${{ parameters.appServiceType }}' $acrLoginServer = Get-ContainerEnvValue -EnvSettings $envSettings -Name "FhirServer__Operations__ConvertData__ContainerRegistryServers__0" diff --git a/docs/arch/adr-2607-ignixa-import-phase0.md b/docs/arch/adr-2607-ignixa-import-phase0.md deleted file mode 100644 index 0b84e518ce..0000000000 --- a/docs/arch/adr-2607-ignixa-import-phase0.md +++ /dev/null @@ -1,92 +0,0 @@ -# ADR 2607: Incremental Ignixa SDK Migration — Phase 0 ($import) - -## Context - -The server is migrating from the Firely FHIR SDK to Ignixa, a new FHIR SDK offering faster FHIRPath evaluation and serialization. An earlier full attempt (`feature/ignixa-sdk`, later extended in `personal/bkowitz/ignixa-sdk-next-steps-fable`) wired Ignixa natively across import, persistence, HTTP formatters, and validation in a single large change — 37 new/modified files in the Ignixa-specific surface alone, more once formatter and feature-flag wiring is included. That scope is too large for one reviewable PR and creates a single point of failure if any one seam has a problem. - -We need a migration strategy that lands real, mergeable progress in small increments without destabilizing the default (Firely) request path, and without accumulating a facade or abstraction layer that outlives its usefulness once Firely is eventually removed. - -## Decision - -Integrate Ignixa one feature seam at a time behind a global two-state provider setting: - -```csharp -public enum FhirSdkProvider -{ - Firely = 0, - Ignixa = 1, -} -``` - -- `CoreFeatureConfiguration.FhirSdkProvider` defaults to `Firely`. There is no `Hybrid` mode and no runtime fallback from Ignixa to Firely — shadow comparison is a testing technique, not a production mode. (Superseded in part by [ADR 2608](adr-2608-ignixa-fhirpath-seam.md): this setting became a nested section — `Default`, plus a nullable override per seam — once FHIRPath needed to roll out independently of import. The default, the absence of `Hybrid`, and the no-fallback rule are unchanged.) -- Selecting `Ignixa` means every feature seam already migrated uses its Ignixa implementation; every other seam keeps using Firely until that seam is migrated in its own PR. -- Startup logs the configured provider and the seams it currently controls (`FhirSdkProviderStartupLogger`: `"FHIR SDK provider configured: {FhirSdkProvider}; migrated seams: Import."`), so the global setting never creates a false impression that the whole server has moved. -- We do not introduce an `IFhirSdkProvider` facade — it would accumulate unrelated serialization, validation, FHIRPath, and persistence responsibilities. Each migrated feature keeps its existing narrow contract (Phase 0 reuses `IImportResourceParser` unchanged) or introduces one narrow contract if none exists. - -**Phase 0 migrates only `$import` parsing.** - -- Four version-specific Firely provider projects (`Microsoft.Health.Fhir.{Stu3,R4,R4B,R5}.FirelySdk`) share one `FirelyImportResourceParser` source file so behavior can't drift by version. -- One `Microsoft.Health.Fhir.Ignixa` project carries all Ignixa code and targets `net10.0`, matching the repo's single target framework (`Directory.Build.props`, `net10.0` since .NET 8 build targets were retired in #5686). The pinned package version (`IgnixaPackageVersion` = `0.0.163` in `Directory.Packages.props`) ships only `net9.0` binaries, which a `net10.0` project consumes fine through ordinary NuGet TFM compatibility; newer Ignixa releases (`0.6.4`, seen in the local package cache) already ship a `net10.0` target directly. -- `OperationsModule` registers exactly one `IImportResourceParser` from the configured provider at startup; it never resolves both parsers per resource or catches an Ignixa failure to retry with Firely. - -The Ignixa parser intentionally converts its parsed node to the existing Firely-shaped `ResourceElement` before calling the existing `IResourceWrapperFactory`, rather than preserving the native Ignixa node end-to-end. This keeps Phase 0 at the ~10-file guardrail (see below) and leaves the entire downstream pipeline (search indexing, raw-resource creation, storage) unchanged for either provider. The deliberate cost: `RawResourceFactory` still rebuilds a full Firely POCO and serializes through Firely's `FhirJsonSerializer` regardless of which parser produced the resource, so Ignixa mode is performance-neutral-to-slightly-slower and higher-allocating than Firely mode on `$import` today. Recovering that win is Phase 2a below (the write-side persistence codec), which is scheduled immediately after Phase 0 for exactly that reason — see Adverse Effects and Execution order. - -Within the parser, soft-delete detection and removal use genuinely native Ignixa APIs rather than a Firely adapter or raw-JSON code: - -- Soft-delete detection evaluates the same `Resource.meta.extension...` predicate the Firely parser runs via `ResourceElement.IsSoftDeleted()`, but directly against the native `IElement` through `Ignixa.FhirPath`'s `IElement.Predicate(path, EvaluationContext)`. This is a deliberate, narrow exception to the "no FHIRPath in Phase 0" scope guideline — one predicate, fully contained inside the import parser. Search-parameter extraction for indexing is unaffected: it still runs through Firely's engine over the Firely-shaped `ResourceElement`, and stays scoped to Phase 3 below. `Ignixa.FhirPath` is consequently a genuinely new package dependency, scoped to `Microsoft.Health.Fhir.Ignixa.csproj` only. -- The matched extension is removed through the typed `SourceNodeExtensions.RemoveExtension(MetaJsonNode, url)` helper rather than manual `JsonObject`/`JsonArray` traversal — verified empirically that it removes only one match per call (looped to mirror Firely's `Meta.RemoveExtension`, which removes every match), and that `ResourceJsonNode` caches its converted `IElement` per instance (mutating the node requires `InvalidateCaches()` before the next `ToElement()` call, or it silently returns the stale element). - -Both providers must preserve the current import policy exactly: - -- Valid resource ID required; conditional references rejected on initial load, allowed on incremental load. -- `meta` initialized when absent; `lastUpdated` normalized to milliseconds and rejected if in the future. -- Version preserved when valid, otherwise reset to `1`; soft-deleted resources detected and their extension stripped before persistence. -- Conditional-reference detection — the highest-risk behavior in the Ignixa parser — covers the resource's own schema-declared reference fields (`IFhirSchemaProvider.ReferenceMetadataProvider`) with the same semantics as Firely's `GetAllChildren()`, each read through the typed `ReferenceJsonNode` model. This is scoped to what the search indexer itself already treats as in scope (see Neutral Effects), and intentionally strict about malformed shapes: a `reference` property holding a non-string scalar throws, and a reference field that is present but isn't a JSON object at all (confirmed empirically that `resource.ToElement(schema)` does not reject this shape on its own) also throws, rather than either case being silently skipped. A missing `reference` property (identifier-only or display-only references, both valid FHIR) is correctly treated as "no conditional reference" rather than dereferenced — a real `NullReferenceException` here was caught and fixed during review. - -### Migration ladder (subsequent PRs, one seam each) - -1. **Export NDJSON serialization** — switch `IResourceToByteArraySerializer`; byte/semantic parity corpus across all versions; no formatter or persistence changes. -2. **Persistence codecs** (two PRs) — **2a, writes:** provider-selected `IRawResourceFactory` with a native-serialize branch, with `ResourceElement` carrying the native Ignixa node via its existing internal two-arg constructor. This is what recovers Phase 0's deferred performance win. **2b, reads:** provider-selected `IResourceDeserializer`, preserving the native node after a database read. Both halves must continue returning existing Core types to SQL and Cosmos, prove parity for raw JSON, search values, history, and deleted resources, and keep rollback possible without rewriting stored data. -3. **FHIRPath and search indexing** — provider-neutral FHIRPath evaluation context, a Firely-authoritative parity corpus per generated search parameter per version, then switch indexing and reindex together (they must use the same provider to avoid index drift; `resolve()` behavior is a release blocker). Specified in [ADR 2608](adr-2608-ignixa-fhirpath-seam.md), which lands this as **one** PR rather than the three estimated here: the seam turns out to be a single narrow contract with ~27 mechanical `using` swaps behind it, and splitting it would ship a half-migrated engine. FHIRPath Patch is excluded and stays Firely-backed until Phase 7 — Ignixa cannot preserve the `ElementNode` identity the patch operations mutate through. -4. **Ordinary HTTP JSON ingress** — single-resource create/update only; Bundles, Parameters, JSON/FHIRPath Patch, and XML explicitly excluded. -5. **HTTP JSON egress** (three PRs) — single-resource responses, then search/history bundles, then `_summary`/`_elements` projection. -6. **Validation** (three PRs) — primitive/structural, then conformance-resource, then profile/terminology-backed. Ignixa success must never synchronously invoke Firely validation as a check. -7. **Complex write semantics** — one operation family per PR (conditional-reference mutation, transaction/batch bundles, JSON Patch, FHIRPath Patch, bulk update/codecs). -8. **Remaining surfaces** — CapabilityStatement/conformance, terminology, resource-parser tools, XML (XML needs an explicit retain/replace/remove decision; Firely can't be deleted while supported XML behavior depends on it). -9. **Firely removal** — only after an inventory shows zero remaining Firely runtime seams: delete the four Firely provider projects, remove Firely packages/adapters, remove provider-selection code, remove now-unneeded compatibility conversions. - -### Execution order - -The ladder above is a *dependency* order, not a commitment to execute it top to bottom. - -The first delivery target is a demonstrable `$import` performance win reachable through a supported configuration change — the benefit measured on the throwaway `feature/ignixa-sdk` branch, reproduced on the production toggle path. That is a vertical slice of **Phase 0 + Phase 2a**, and it does not require Phase 1 or Phase 2b: `$import` is a write path, so `ResourceWrapperFactory` calls `IRawResourceFactory` on every imported resource, while `IResourceDeserializer` is not on the import path at all. - -Phase 2a is therefore scheduled ahead of both Phase 1 and Phase 2b, followed by a benchmark gate comparing Ignixa and Firely modes on one binary with only configuration differing. If that gate does not reproduce the `feature/ignixa-sdk` delta, the bottleneck is identified and written up before the ladder resumes. Phase 1 and Phase 2b follow the gate. - -Phase numbering stays fixed regardless of execution order — the backlog references these numbers, so they are identifiers, not a schedule. - -Every migration PR changes exactly one feature seam, preserves Firely as the default until final cutover, has one composition-root decision point, avoids hidden fallback, states its rollback action (typically: reset the provider setting; no data migration required), and states which seams remain Firely-backed. As a review heuristic, we target no more than roughly ten modified production files per seam PR (excluding new provider-project scaffolding and mechanical solution/Docker entries) — exceeding that isn't automatically wrong, but it requires explaining why the seam can't be split further. Phase 0 lands at exactly ten. - -## Status - -Accepted - -## Consequences - -### Benefits - -- Each seam lands as an independently reviewable, independently revertible PR instead of one large cutover. -- Firely stays the default and fully functional throughout the migration; rollback at any point is a configuration change, not a data migration. -- Reusing existing narrow contracts (`IImportResourceParser`, `IResourceWrapperFactory`) means downstream consumers (indexing, storage, job processing) require zero changes for Phase 0, and the parity test suite can assert byte-level equivalence between providers. -- The migration ladder gives reviewers a shared map of what's left, preventing "is this seam actually migrated?" ambiguity. -- The parser is a worked example of idiomatic native Ignixa usage (`IElement.Predicate`, `MetaJsonNode`/`ReferenceJsonNode`, `InvalidateCaches()`) for later migration-ladder PRs to build on. - -### Adverse Effects - -- Phase 0 delivers no performance improvement for Ignixa-mode `$import` — likely a slight regression versus Firely mode, from paying both an Ignixa parse and a full Firely POCO rebuild + serialize. Call this out explicitly wherever the migration's performance rationale is cited, so reviewers and operators don't assume Phase 0 alone delivers the documented Ignixa speedup; recovering it is Phase 2a (the write-side persistence codec), scheduled immediately after Phase 0 — see Execution order. -- The Ignixa→Firely `ResourceElement` conversion is a known, intentional shim. The line where the native node is dropped (the one-argument `ResourceElement` constructor, which leaves `ResourceInstance` unset) is marked in code as the Phase 2a flip point, so it's a planned one-line change plus a new native-serialize decorator, not a rediscovered defect. -- A two-state provider enum will need reconciling later if a `Hybrid`/shadow-comparison mode is ever wanted for a specific seam — deliberately excluded from Phase 0, not a limitation of the enum shape. - -### Neutral Effects - -- Conditional-reference checking in Ignixa mode does not recurse into `contained` resources or Bundle entries. This matches the search indexer's existing behavior (which also never indexes into `contained`) and import's NDJSON-of-individual-resources model — an intentional scope boundary, not a parity gap to close later. diff --git a/docs/arch/adr-2608-ignixa-fhirpath-seam.md b/docs/arch/adr-2608-ignixa-fhirpath-seam.md deleted file mode 100644 index 36f970c2a0..0000000000 --- a/docs/arch/adr-2608-ignixa-fhirpath-seam.md +++ /dev/null @@ -1,167 +0,0 @@ -# ADR 2608: Incremental Ignixa SDK Migration — Phase 3 (FHIRPath evaluation) - -## Context - -[ADR 2607](adr-2607-ignixa-import-phase0.md) established an incremental migration from the Firely SDK to Ignixa, one feature seam per PR, behind a provider setting that defaults to Firely. Phase 0 migrated `$import` parsing. This ADR covers Phase 3, FHIRPath evaluation — the seam the migration's performance rationale ultimately rests on, since FHIRPath is what extracts every search index entry on every write. - -Today FHIRPath reaches the server through two unrelated doors: - -- **The extension methods.** `Hl7.FhirPath.IValueProviderFPExtensions` adds `Select`/`Scalar`/`Predicate`/`IsTrue`/`IsBoolean` to `ITypedElement`. Roughly 27 production files call these — the twenty search-value converters, the bundle wrappers, `CompartmentDefinitionManager`, `SearchParameterDefinitionBuilder`, `NarrativeValidator`, and others. A file opts in merely by writing `using Hl7.FhirPath;`, so there is no seam to intercept and no way to tell from a call site which engine will run. -- **Direct compilation.** `TypedElementSearchIndexer` holds its own `FhirPathCompiler` and expression cache and invokes the compiled delegate directly. - -A third group — `SearchParameterToTypeResolver`, `SearchParameterSupportResolver`, `SearchParameterComparer` — consumes Firely's `Hl7.FhirPath.Expressions` AST for type inference. These never evaluate against a resource. - -There is no abstraction over any of this. Replacing the engine therefore means either touching every call site or introducing a seam that the call sites can be moved onto mechanically. Prior full-cutover prototypes (`feature/ignixa-sdk`, `personal/bkowitz/ignixa-sdk-next-steps-fable`) changed 144 files and were not reviewable. - -## Decision - -Introduce one narrow FHIRPath seam in Core, move every evaluating call site onto it, and select the implementation from configuration at a single composition point. - -### The seam - -```csharp -public interface IFhirPathProvider { ICompiledFhirPath Compile(string expression); } - -public interface ICompiledFhirPath -{ - string Expression { get; } - IEnumerable Select(ITypedElement input, EvaluationContext context = null); -} -``` - -`Select` is the only primitive. `Scalar`, `Predicate`, `IsTrue`, and `IsBoolean` are derived once, in the seam's extension class, from `Select`. This is deliberate: the existing Ignixa prototype's `Predicate` returned `false` for an empty result where Firely returns `true`, and `ConformanceProviderBase` gates every capability query on `Predicate`. Deriving once removes that class of drift entirely rather than asking two implementations to agree. - -The derivation must reproduce Firely 5.11.4 exactly, and its semantics are subtler than they look: - -- `Predicate` is `BooleanEval`, not "empty or truthy": empty yields `true`; a single element whose `Value` is a `bool` yields that bool (so `active = false` yields **false**); any other non-empty content yields `true`. `BooleanEval` is `internal` in the SDK, so the seam reimplements it. -- `Scalar` takes two results and calls `Single()`, so **two or more results throw `InvalidOperationException`**. Firely SDK 6 changed this to return null; we are on 5.11.4 and pin the throw. -- Every extension method applies `ToScopedNode()` to its input before evaluating. That wrap is what makes `%resource` and `%rootResource` resolve, and it is part of the observable contract. -- Firely's `Closure.Root` mutates the caller's `EvaluationContext`. The concrete `FhirEvaluationContext` must flow through the seam untouched, or `ElementResolver` — and with it `resolve()` — is silently dropped. - -These are pinned by characterization tests written against the Firely provider before any Ignixa code exists. - -`FirelyFhirPathProvider` and `FirelyCompiledFhirPath` live in Core beside the interfaces. Core already references `Hl7.Fhir.Base`, which contains the engine, `EvaluationContext`, and `AddFhirExtensions`, so this adds no dependency and avoids touching the four version-specific `*.FirelySdk` projects that Phase 0 created. Final cutover deletes two files. - -**The seam owns symbol-table registration.** `FhirModule` currently calls `FhirPathCompiler.DefaultSymbolTable.AddFhirExtensions()` in two places; that global mutation is what puts `resolve()` into the engine. Leaving it there means a provider constructed outside full server startup — exactly what the characterization tests do — cannot compile `resolve()` expressions. `FirelyFhirPathProvider` performs the registration itself, and `FhirModule` drops both calls. - -### Provider selection - -The provider is a process-wide ambient, following the pattern `ModelInfoProvider` already establishes in this codebase, because the call sites include static extension methods (`SoftDeletedFhirPathExtension`, the converter helpers, `SearchParameterInfo`) that have no access to DI. - -```csharp -public static class FhirPathProvider -{ - private static Func _factory = static () => new FirelyFhirPathProvider(); - private static Lazy _instance = new(() => _factory()); - - public static IFhirPathProvider Instance => _instance.Value; - - public static void SetProviderFactory(Func factory) - { - _factory = EnsureArg.IsNotNull(factory, nameof(factory)); - _instance = new Lazy(() => _factory()); - } -} -``` - -Two properties matter. The default is Firely, so **nothing has to call the setter for current behaviour to hold** — roughly a thousand unit tests that construct converters directly need no fixture change, unlike `ModelInfoProvider`, which throws when unset. And resolution is lazy behind a `Lazy`, so the provider is built after `ModelInfoProvider` is set and two threads cannot race into two expression caches. `SetProviderFactory` replaces the `Lazy`, so a pre-registration read cannot latch Firely permanently. - -`SearchModule` is the single composition point — it already takes `FhirServerConfiguration` and owns the feature area, where `FhirModule` is parameterless: - -```csharp -FhirPathProvider.SetProviderFactory( - _configuration.CoreFeatures.FhirSdkProvider.EffectiveFhirPath == FhirSdkProvider.Ignixa - ? () => new IgnixaFhirPathProvider(new IgnixaSchemaContext(ModelInfoProvider.Instance)) - : () => new FirelyFhirPathProvider()); - -services.AddSingleton(_ => FhirPathProvider.Instance); -``` - -The DI registration delegates to the ambient rather than constructing a second provider, so there is exactly one expression cache per process. `TypedElementSearchIndexer` takes `IFhirPathProvider` through its constructor rather than reaching for the ambient; it is DI-constructed, and injecting it is what allows the parity corpus to drive both providers in one test process. - -### Configuration - -Phase 0's scalar `CoreFeatureConfiguration.FhirSdkProvider` becomes a nested section so seams can be rolled out independently: - -```csharp -public class FhirSdkProviderConfiguration -{ - public FhirSdkProvider Default { get; set; } = FhirSdkProvider.Firely; - public FhirSdkProvider? Import { get; set; } - public FhirSdkProvider? FhirPath { get; set; } - - public FhirSdkProvider EffectiveImport => Import ?? Default; - public FhirSdkProvider EffectiveFhirPath => FhirPath ?? Default; -} -``` - -```json -"FhirSdkProvider": { "Default": "Firely", "FhirPath": "Ignixa" } -``` - -FHIRPath changes search index *content*, where import parsing does not, so the two must be flippable separately. `FhirSdkProviderStartupLogger` logs every effective per-seam value rather than one enum. - -### Scope - -**In:** the ~27 extension-method call sites, `TypedElementSearchIndexer`, `ResourceElement`, `ConformanceProviderBase`, and reindex (which shares the same `ISearchIndexer` singleton, so it cannot diverge from indexing within a process). - -**Out, and permanently marked so:** - -- **FHIRPath Patch.** The six `Operation*.cs` files select nodes and then mutate the returned nodes: `ElementModelExtensions.ToElementNode` is `(element is ElementNode el) ? el : ElementNode.FromElement(element)`, and `OperationDelete` then calls `Target.Parent.Remove(Target)`. Firely returns the input tree's own `ElementNode` instances, so the mutation lands. An Ignixa provider returns adapter-wrapped nodes, fails the type test, receives a **detached copy**, and patches the copy — the operation reports success and the resource is unchanged. Ignixa structurally cannot honour node identity across the adapter boundary. Patch is Phase 7 in ADR 2607 regardless; these files keep `Hl7.FhirPath` and are whitelisted in the seam test with this reason. -- **The three AST consumers.** They perform type inference at definition time, never evaluate against a resource, and porting them to Ignixa's AST plus `FhirPathAnalyzer` is a large visitor rewrite with no runtime benefit. They keep `Hl7.FhirPath` for `FhirPathCompiler` and are whitelisted. -- **`ITypedElement` and `EvaluationContext` stay in the seam signatures.** Both ship in `Hl7.Fhir.Base`, which Core keeps for the element model regardless, so abstracting `EvaluationContext` alone removes no dependency while adding churn. They get replaced together when the element model moves. - -### Locking the seam - -A test asserts that no file imports `Hl7.FhirPath` outside the Firely provider and the documented whitelist. It must also ban `Hl7.Fhir.FhirPath` — the POCO-based `Select`/`Scalar` extensions live there with their own always-FHIR-enabled cache, so a call site on a POCO would neither collide with the seam nor be caught. It must not prefix-match `Hl7.FhirPath.Sprache`, which is Firely's embedded parser-combinator library and unrelated to the engine (`SqlServerFhirDataStore` and `StringExtensions` use it legitimately). The repo has no `BannedApiAnalyzers` package; a test is cheaper than adding one. - -### Cache policy - -Stated explicitly because three different policies exist today and none is written down: the extension path uses a shared static 500-entry LRU, `TypedElementSearchIndexer` uses a private unbounded dictionary, and Ignixa keeps its own static unbounded AST and delegate caches. Expressions are influenced by user input through custom search parameters, so unbounded caching is a slow leak. - -Each provider owns a **bounded** compile cache. `TypedElementSearchIndexer` keeps holding `ICompiledFhirPath` handles in its own dictionary keyed by search parameter — the `Compile`-returns-a-handle shape means the hot path never consults a string-keyed cache at all, which is strictly better than today and independent of the LRU size. R4 alone ships roughly 1,400 search-parameter expressions, so routing them through a 500-entry LRU would thrash every extract cycle. - -### Failure handling - -`TypedElementSearchIndexer` catches all exceptions from expression evaluation, logs a warning, and yields an empty index entry set. Ignixa throws `NotSupportedException` for unimplemented functions where Firely would return empty, so that catch is the exact mechanism by which a conformance gap becomes silent index drift. In Ignixa mode, evaluation failure is surfaced as a metric and is a bake-in gate, not a swallowed warning. - -## Status - -Proposed - -## Consequences - -### Benefits - -- One seam, one composition point, one engine per process. There is no mode in which two FHIRPath implementations run within a single indexing pass. -- The change is dominated by mechanical edits: ~27 files change only their `using` line, bodies untouched. Keeping both namespace imports is a `CS0121` ambiguity, so the compiler proves no call site was missed. -- Firely stays the default and fully functional; rollback is a configuration change with no data migration. -- Deriving four helpers from one primitive closes a real defect class, evidenced by the prototype's inverted `Predicate` and its `Scalar` that used `FirstOrDefault` with no single-item enforcement. -- Final cutover deletes `FirelyFhirPathProvider`, `FirelyCompiledFhirPath`, and the flag, with no call-site changes. -- Ignixa's FHIRPath conformance is not a gating concern: the official HL7 suite passes 2906 of 2906 across R4/R4B/R5, with nine soft-passes for `conformsTo()` and `%terminologies`, neither of which appears in any search-parameter expression in any supported version. - -### Adverse Effects - -- **The performance rationale does not apply at this phase, and this must not be cited as if it does.** HTTP ingress (Phase 4) and read codecs (Phase 2b) are still Firely, so every element reaching the indexer is a Firely POCO. Evaluation therefore runs through a per-call `ToIgnixaElement()` adapter and returns through `TypedElementAdapter`. Ignixa's published 3,220x figure is measured on native Ignixa elements and does not describe this path. The benchmark gate for enabling Ignixa in production must measure *adapter-input* evaluation specifically; if it does not show a win, Phase 3 is a correctness-and-seam change only, exactly as Phase 0 was. -- **A process-wide static cannot express per-server configuration.** `TestFhirServerFactory` caches multiple in-process servers, so two in-proc servers configured with different providers cross-contaminate — which is precisely the shape an Ignixa-versus-Firely E2E comparison would take. Constructor injection into `TypedElementSearchIndexer` covers the parity corpus; a genuine per-server FHIRPath provider would require removing the ambient, which in turn requires the ~1,000 direct-`new` converter tests to gain a fixture. Deferred, and the E2E constraint is documented at the static. -- **`$patch` remains Firely-backed until Phase 7** even when the flag says Ignixa. The startup log names the seams the setting actually controls, so this does not silently mislead operators. -- **Reshaping the config node is a breaking change.** An existing scalar `"FhirSdkProvider": "Firely"` binds to the new object type as *nothing*, so an operator who had set `Ignixa` silently reverts to Firely. The direction is fail-safe, and Phase 0's flag is opt-in and unreleased, but the startup log must make the effective values unambiguous. -- **A prerequisite lands in another repository.** The Ignixa adapters passed `Value` through untranslated in both directions, so Firely's `P.DateTime` reached Ignixa's comparison helpers — which narrow operands through a `string`/`DateTime`/`DateTimeOffset` switch and fall through to `null` — turning every date comparison into an empty result instead of a boolean, silently. Fixed in [ignixa-fhir#398](https://github.com/brendankowitz/ignixa-fhir/pull/398); enabling Ignixa in production is blocked on a package release containing it. - -### Neutral Effects - -- `%context` is not bound by name in Ignixa's `GetEnvironmentVariable`; it falls through to the generic environment dictionary. The evaluation-context bridge binds it explicitly, along with `%resource` and `%rootResource` and the `ElementResolver` that backs `resolve()` — which appears 76 times across the R4, R4B, and R5 search parameters and is the single highest-risk behaviour in the bridge. -- `TypedElementSearchIndexer` moving onto the seam changes two behaviours that were never deliberate: it gains the `ToScopedNode()` wrap the extension path always applied, and it loses its unbounded private cache. Both are pinned by characterization tests before the move. -- Custom search parameters are validated through Firely's AST tooling (out of scope) but indexed through Ignixa, so an accept-versus-index mismatch is possible. The parity corpus covers generated parameters; custom-parameter parity is a bake-in observation, not a pre-merge gate. - -### Delivery - -One PR. The blast radius is roughly 45 files, of which ~27 are single-line `using` swaps, which sits inside ADR 2607's review guardrail once mechanical edits are excluded on the same basis as its solution and Docker entries. - -Verification, in order: - -1. **Characterization tests** pinning `Select`/`Scalar`/`Predicate`/`IsTrue`/`IsBoolean` and the indexer's current behaviour against Firely 5.11.4 — written and passing before the Ignixa provider exists, so the seam's fidelity is established independently of it. -2. **Parity corpus** — every generated search parameter, every FHIR version, a resource corpus, run through both providers, asserting equal `Select` results and equal `SearchIndexEntry` sets. This is the gate on enabling Ignixa. -3. **Benchmark** of adapter-input evaluation, per the first adverse effect. - -Rollback is resetting `CoreFeatures:FhirSdkProvider:FhirPath`. No data migration; index rows written under either provider stay valid, and the parity corpus is what justifies that claim. diff --git a/docs/arch/adr-2608-ignixa-sdk-migration.md b/docs/arch/adr-2608-ignixa-sdk-migration.md new file mode 100644 index 0000000000..1324b3d384 --- /dev/null +++ b/docs/arch/adr-2608-ignixa-sdk-migration.md @@ -0,0 +1,37 @@ +# ADR-2608: Incrementally adopt the Ignixa SDK + +**Status**: Accepted +**Date**: 2026-08-31 +**Feature**: Ignixa SDK migration + +## Context + +The server is deeply coupled to the Firely SDK across parsing, serialization, validation, FHIRPath evaluation, and resource processing. Production-shaped measurements show that Ignixa can materially reduce FHIRPath and element-model costs, while its modular design provides greater control over capabilities that are currently supplied by one broad dependency. Retaining Firely indefinitely would preserve the status quo but would also forgo those benefits and provide no path to reduce that coupling. + +Replacing the SDK across the entire server in one change would create an unreviewable blast radius and make behavioral regressions, index drift, and rollback difficult to manage. + +FHIR behavior and persisted data must remain compatible throughout the migration. Individual capabilities have different correctness risks, performance characteristics, and dependencies, so they cannot all be enabled safely at the same time. + +## Options Considered + +1. **Continue using Firely as the only SDK** - This minimizes near-term change but retains the current coupling and forgoes measured performance opportunities. *(rejected)* +2. **Replace Firely with Ignixa in one release** - A single cutover avoids temporary adapters but creates excessive implementation and rollback risk. *(rejected)* +3. **Run both SDKs indefinitely and compare every operation** - Continuous shadow execution provides evidence but permanently doubles complexity and can conceal which implementation is authoritative. *(rejected)* +4. **Adopt Ignixa incrementally at explicit feature seams** - Migrate independently selectable capabilities while Firely remains the default for unmigrated behavior. *(selected)* + +## Decision + +We will adopt Ignixa incrementally through narrow, capability-specific seams. Each seam selects exactly one implementation at startup, defaults to Firely until Ignixa is approved for that capability, and supports rollback through configuration without rewriting persisted data. Production requests will not silently fall back from Ignixa to Firely; failures must remain observable. + +We will not introduce a single facade for the entire FHIR SDK. Existing focused contracts will be reused, and new abstractions will be limited to capabilities that do not already have an appropriate boundary. A seam must include every path that produces or regenerates the same persisted or externally visible representation; for example, indexing and reindexing cannot select different providers. + +A seam may be enabled only after production-shaped tests demonstrate semantic parity, compatibility with supported FHIR versions, and acceptable performance. Firely will be removed only after all supported runtime behavior has migrated and the compatibility layer is no longer required. + +## Consequences + +- Migration changes remain reviewable, independently deployable, and reversible. +- Firely and Ignixa dependencies, adapters, and configuration coexist temporarily. +- Different capabilities may intentionally use different SDKs during the transition, so startup diagnostics must identify the effective provider for each migrated seam. +- Every migrated seam requires parity tests and operational failure signals; performance-motivated changes also require measurements of the actual server path rather than isolated SDK claims. +- Ignixa performance benefits may be limited while a path still crosses Firely compatibility adapters. +- Removing Firely becomes a deliberate final migration step rather than an incidental consequence of an individual feature change. diff --git a/samples/templates/aca/fhir-sql.bicep b/samples/templates/aca/fhir-sql.bicep index 08da16e369..5a7caf57df 100644 --- a/samples/templates/aca/fhir-sql.bicep +++ b/samples/templates/aca/fhir-sql.bicep @@ -27,6 +27,13 @@ param imageTag string = 'latest' @description('Existing SQL server name.') param sqlServerName string +@description('SQL database name. Defaults to the version-specific legacy name.') +param sqlDatabaseName string = 'FHIR${fhirVersion}' + +@description('Default FHIR SDK provider. Empty preserves the legacy deployment environment.') +@allowed(['', 'Firely', 'Ignixa']) +param fhirSdkProviderDefault string = '' + @description('Schema automatic updates mode.') @allowed(['auto', 'tool']) param sqlSchemaAutomaticUpdatesEnabled string = 'auto' @@ -73,8 +80,6 @@ param additionalEnvVars array = [] var normalizedSqlServerName = toLower(sqlServerName) var sqlManagedIdentityName = '${normalizedSqlServerName}-uami' -var sqlDatabaseName = 'FHIR${fhirVersion}' - var sqlManagedIdentityResourceId = resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', sqlManagedIdentityName) var userAssignedIdentities = { @@ -82,7 +87,11 @@ var userAssignedIdentities = { '${acrPullUserAssignedManagedIdentityResourceId}': {} } -var datastoreEnvVars = [ +var sdkProviderEnvVars = empty(fhirSdkProviderDefault) ? [] : [ + { name: 'FhirServer__CoreFeatures__FhirSdkProvider__Default', value: fhirSdkProviderDefault } +] + +var datastoreEnvVars = concat([ { name: 'DataStore', value: 'SqlServer' } { name: 'SqlServer__Initialize', value: 'true' } { @@ -91,7 +100,7 @@ var datastoreEnvVars = [ } { name: 'SqlServer__DeleteAllDataOnStartup', value: 'false' } { name: 'SqlServer__AllowDatabaseCreation', value: 'true' } -] +], sdkProviderEnvVars) // ────────────────────────────────────────────── // Modules diff --git a/src/Microsoft.Health.Fhir.Core.UnitTests/Config/FhirSdkProviderConfigurationTests.cs b/src/Microsoft.Health.Fhir.Core.UnitTests/Config/FhirSdkProviderConfigurationTests.cs index fafa826a88..a322134d2c 100644 --- a/src/Microsoft.Health.Fhir.Core.UnitTests/Config/FhirSdkProviderConfigurationTests.cs +++ b/src/Microsoft.Health.Fhir.Core.UnitTests/Config/FhirSdkProviderConfigurationTests.cs @@ -18,7 +18,8 @@ public class FhirSdkProviderConfigurationTests public void GivenDefaultConfiguration_WhenProviderRead_ThenFirelyIsSelected() { var configuration = new CoreFeatureConfiguration(); - Assert.Equal(FhirSdkProvider.Firely, configuration.FhirSdkProvider); + Assert.Equal(FhirSdkProvider.Firely, configuration.FhirSdkProvider.EffectiveImport); + Assert.Equal(FhirSdkProvider.Firely, configuration.FhirSdkProvider.EffectiveFhirPath); } [Fact] @@ -26,9 +27,29 @@ public void GivenIgnixaConfigured_WhenProviderRead_ThenIgnixaIsSelected() { var configuration = new CoreFeatureConfiguration { - FhirSdkProvider = FhirSdkProvider.Ignixa, + FhirSdkProvider = new FhirSdkProviderConfiguration + { + Default = FhirSdkProvider.Ignixa, + }, }; - Assert.Equal(FhirSdkProvider.Ignixa, configuration.FhirSdkProvider); + Assert.Equal(FhirSdkProvider.Ignixa, configuration.FhirSdkProvider.EffectiveImport); + Assert.Equal(FhirSdkProvider.Ignixa, configuration.FhirSdkProvider.EffectiveFhirPath); + } + + [Fact] + public void GivenSeamOverrides_WhenProvidersRead_ThenSelectionsAreIndependent() + { + var configuration = new CoreFeatureConfiguration + { + FhirSdkProvider = new FhirSdkProviderConfiguration + { + Default = FhirSdkProvider.Firely, + Import = FhirSdkProvider.Ignixa, + }, + }; + + Assert.Equal(FhirSdkProvider.Ignixa, configuration.FhirSdkProvider.EffectiveImport); + Assert.Equal(FhirSdkProvider.Firely, configuration.FhirSdkProvider.EffectiveFhirPath); } } } diff --git a/src/Microsoft.Health.Fhir.Core.UnitTests/Features/FhirPath/FhirPathSeamTests.cs b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/FhirPath/FhirPathSeamTests.cs new file mode 100644 index 0000000000..5656a01c52 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/FhirPath/FhirPathSeamTests.cs @@ -0,0 +1,102 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.Core.UnitTests.Features.FhirPath +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class FhirPathSeamTests + { + private static readonly HashSet AllowedFiles = new(StringComparer.OrdinalIgnoreCase) + { + "src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathExtensions.cs", + "src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyCompiledFhirPath.cs", + "src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyFhirPathProvider.cs", + "src/Microsoft.Health.Fhir.Core/Features/FhirPath/ICompiledFhirPath.cs", + + // The composition root unconditionally invokes Firely's guarded, idempotent registration + // because FHIRPath Patch remains Firely-backed when the evaluation provider is Ignixa. + "src/Microsoft.Health.Fhir.Shared.Api/Modules/SearchModule.cs", + + // FHIRPath Patch mutates the selected Firely ElementNode instances. Ignixa adapters cannot + // preserve that node identity, so Patch remains Firely-backed until migration phase 7. + "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationAdd.cs", + "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationDelete.cs", + "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationInsert.cs", + "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationMove.cs", + "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationReplace.cs", + "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationUpsert.cs", + + // These consumers inspect Firely's AST and never evaluate an expression. + "src/Microsoft.Health.Fhir.Shared.Core/Features/Search/Parameters/SearchParameterComparer.cs", + "src/Microsoft.Health.Fhir.Shared.Core/Features/Search/Parameters/SearchParameterSupportResolver.cs", + "src/Microsoft.Health.Fhir.Shared.Core/Features/Search/Parameters/SearchParameterToTypeResolver.cs", + }; + + [Fact] + public void GivenProductionSource_WhenEngineNamespacesAreImported_ThenOnlyDocumentedExceptionsRemain() + { + string root = FindRepositoryRoot(); + string[] sourceRoots = + [ + Path.Join(root, "src"), + Path.Join(root, "tools", "Microsoft.Health.Fhir.R4.ResourceParser"), + ]; + + string[] violations = sourceRoots.SelectMany(sourceRoot => Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories)) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .Where(path => !path.Contains("UnitTests", StringComparison.OrdinalIgnoreCase)) + .Where(ImportsFirelyEngine) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => !AllowedFiles.Contains(path)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + + Assert.True(violations.Length == 0, $"Direct Firely FHIRPath imports bypass the provider seam:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } + + private static bool ImportsFirelyEngine(string path) + { + string source = File.ReadAllText(path); + return Regex.IsMatch( + source, + @"using\s+(?:\w+\s*=\s*)?(?:global::)?Hl7\.FhirPath\s*;", + RegexOptions.CultureInvariant) || + Regex.IsMatch( + source, + @"using\s+(?:\w+\s*=\s*)?(?:global::)?Hl7\.Fhir\.FhirPath\s*;", + RegexOptions.CultureInvariant) || + Regex.IsMatch( + source, + @"(?:global::)?Hl7\.FhirPath\.(?!(?:Expressions|Sprache|EvaluationContext)\b)", + RegexOptions.CultureInvariant) || + Regex.IsMatch( + source, + @"(?:global::)?Hl7\.Fhir\.FhirPath\.(?!FhirEvaluationContext\b)", + RegexOptions.CultureInvariant); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Join(directory.FullName, "Microsoft.Health.Fhir.sln"))) + { + directory = directory.Parent; + } + + return directory?.FullName ?? throw new InvalidOperationException("Could not locate the repository root."); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Configs/CoreFeatureConfiguration.cs b/src/Microsoft.Health.Fhir.Core/Configs/CoreFeatureConfiguration.cs index d1bd7249fb..004b062a4c 100644 --- a/src/Microsoft.Health.Fhir.Core/Configs/CoreFeatureConfiguration.cs +++ b/src/Microsoft.Health.Fhir.Core/Configs/CoreFeatureConfiguration.cs @@ -38,7 +38,7 @@ public class CoreFeatureConfiguration /// Gets or sets the preferred FHIR SDK at feature seams that support provider selection. /// Firely remains the default until the final migration cutover. /// - public FhirSdkProvider FhirSdkProvider { get; set; } = FhirSdkProvider.Firely; + public FhirSdkProviderConfiguration FhirSdkProvider { get; set; } = new(); /// /// Gets or sets the maximum value for _count in search. diff --git a/src/Microsoft.Health.Fhir.Core/Configs/FhirSdkProviderConfiguration.cs b/src/Microsoft.Health.Fhir.Core/Configs/FhirSdkProviderConfiguration.cs new file mode 100644 index 0000000000..17746b0932 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Configs/FhirSdkProviderConfiguration.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +namespace Microsoft.Health.Fhir.Core.Configs +{ + /// + /// Selects the SDK provider independently for each migrated seam. + /// + public sealed class FhirSdkProviderConfiguration + { + /// + /// Gets or sets the default provider. + /// + public FhirSdkProvider Default { get; set; } = FhirSdkProvider.Firely; + + /// + /// Gets or sets the import provider override. + /// + public FhirSdkProvider? Import { get; set; } + + /// + /// Gets or sets the FHIRPath provider override. + /// + public FhirSdkProvider? FhirPath { get; set; } + + /// + /// Gets the effective import provider. + /// + public FhirSdkProvider EffectiveImport => Import ?? Default; + + /// + /// Gets the effective FHIRPath provider. + /// + public FhirSdkProvider EffectiveFhirPath => FhirPath ?? Default; + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Extensions/TypedElementExtensions.cs b/src/Microsoft.Health.Fhir.Core/Extensions/TypedElementExtensions.cs index 7ee96fd3f6..1eaff1c029 100644 --- a/src/Microsoft.Health.Fhir.Core/Extensions/TypedElementExtensions.cs +++ b/src/Microsoft.Health.Fhir.Core/Extensions/TypedElementExtensions.cs @@ -7,7 +7,7 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Extensions diff --git a/src/Microsoft.Health.Fhir.Core/Features/Conformance/ConformanceProviderBase.cs b/src/Microsoft.Health.Fhir.Core/Features/Conformance/ConformanceProviderBase.cs index c29c20c6a4..d61ab09aaf 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Conformance/ConformanceProviderBase.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Conformance/ConformanceProviderBase.cs @@ -10,7 +10,7 @@ using System.Threading; using System.Threading.Tasks; using EnsureThat; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Models; namespace Microsoft.Health.Fhir.Core.Features.Conformance diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleEntryWrapper.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleEntryWrapper.cs index 031b46b4b3..21519e264a 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleEntryWrapper.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleEntryWrapper.cs @@ -7,7 +7,7 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; namespace Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers { diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleWrapper.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleWrapper.cs index bccb1a0cb6..90e83b77fb 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleWrapper.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleWrapper.cs @@ -8,7 +8,7 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Models; namespace Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/SearchParameterWrapper.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/SearchParameterWrapper.cs index 7686816232..8aea965def 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/SearchParameterWrapper.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/SearchParameterWrapper.cs @@ -8,8 +8,8 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Models; namespace Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/CompartmentDefinitionManager.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/CompartmentDefinitionManager.cs index 7d0773ef1f..dab057d06c 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Definition/CompartmentDefinitionManager.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/CompartmentDefinitionManager.cs @@ -14,12 +14,12 @@ using Hl7.Fhir.ElementModel; using Hl7.Fhir.Serialization; using Hl7.Fhir.Utility; -using Hl7.FhirPath; using Microsoft.Extensions.Hosting; using Microsoft.Health.Fhir.Core.Data; using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Models; using Newtonsoft.Json; using CompartmentType = Microsoft.Health.Fhir.ValueSets.CompartmentType; diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionBuilder.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionBuilder.cs index 8b9054850b..e760cccf78 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionBuilder.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionBuilder.cs @@ -14,11 +14,11 @@ using EnsureThat; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Utility; -using Hl7.FhirPath; using Microsoft.Extensions.Logging; using Microsoft.Health.Fhir.Core.Data; using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Features.Search.Parameters; diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathExtensions.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathExtensions.cs new file mode 100644 index 0000000000..4f369cf455 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathExtensions.cs @@ -0,0 +1,70 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using Hl7.Fhir.ElementModel; +using Hl7.FhirPath; + +namespace Microsoft.Health.Fhir.Core.Features.FhirPath +{ + /// + /// Evaluates FHIRPath expressions through the configured provider. + /// + public static class FhirPathExtensions + { + /// + /// Evaluates an expression and returns its selected elements. + /// + public static IEnumerable Select(this ITypedElement input, string expression, EvaluationContext context = null) + => Compile(input, expression).Select(input, context); + + /// + /// Evaluates an expression and returns its single primitive value, or null when empty. + /// + public static object Scalar(this ITypedElement input, string expression, EvaluationContext context = null) + { + ITypedElement[] result = Compile(input, expression).Select(input, context).Take(2).ToArray(); + return result.Length == 0 ? null : result.Single().Value; + } + + /// + /// Returns true when the expression evaluates to true or empty. + /// + public static bool Predicate(this ITypedElement input, string expression, EvaluationContext context = null) + => BooleanEval(Compile(input, expression).Select(input, context)) is not false; + + /// + /// Returns true when the expression evaluates to true. + /// + public static bool IsTrue(this ITypedElement input, string expression, EvaluationContext context = null) + => BooleanEval(Compile(input, expression).Select(input, context)) is true; + + /// + /// Returns true when the expression evaluates to the supplied boolean. + /// + public static bool IsBoolean(this ITypedElement input, string expression, bool value, EvaluationContext context = null) + => BooleanEval(Compile(input, expression).Select(input, context)) is bool result && result == value; + + private static ICompiledFhirPath Compile(ITypedElement input, string expression) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + return FhirPathProvider.Instance.Compile(expression); + } + + private static bool? BooleanEval(IEnumerable elements) + { + ITypedElement[] result = elements.Take(2).ToArray(); + return result.Length switch + { + 0 => null, + 1 when result[0].Value is bool value => value, + _ => true, + }; + } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathProvider.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathProvider.cs new file mode 100644 index 0000000000..e902c6573b --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathProvider.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Threading; +using EnsureThat; + +namespace Microsoft.Health.Fhir.Core.Features.FhirPath +{ + /// + /// Provides process-wide access to the configured FHIRPath engine. + /// + /// + /// This ambient is required by static extension call sites. In-process servers configured with + /// different providers are not supported; DI consumers should inject . + /// + public static class FhirPathProvider + { + private static Lazy _instance = CreateLazy(static () => new FirelyFhirPathProvider()); + + /// + /// Gets the configured provider. + /// + public static IFhirPathProvider Instance => Volatile.Read(ref _instance).Value; + + /// + /// Replaces the provider factory. The provider is created lazily and exactly once for each factory. + /// + /// The provider factory. + public static void SetProviderFactory(Func factory) + { + Func providerFactory = EnsureArg.IsNotNull(factory, nameof(factory)); + Interlocked.Exchange(ref _instance, CreateLazy(providerFactory)); + } + + private static Lazy CreateLazy(Func factory) + => new( + () => factory() ?? throw new InvalidOperationException("The FHIRPath provider factory returned null."), + LazyThreadSafetyMode.ExecutionAndPublication); + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyCompiledFhirPath.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyCompiledFhirPath.cs new file mode 100644 index 0000000000..7afe67af1a --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyCompiledFhirPath.cs @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using Hl7.Fhir.ElementModel; +using Hl7.FhirPath; + +namespace Microsoft.Health.Fhir.Core.Features.FhirPath +{ + /// + /// Executes a Firely-compiled FHIRPath expression. + /// + public sealed class FirelyCompiledFhirPath : ICompiledFhirPath + { + private readonly CompiledExpression _compiledExpression; + + /// + /// Initializes a new instance of the class. + /// + /// The source expression. + /// The Firely compiled delegate. + public FirelyCompiledFhirPath(string expression, CompiledExpression compiledExpression) + { + Expression = expression ?? throw new ArgumentNullException(nameof(expression)); + _compiledExpression = compiledExpression ?? throw new ArgumentNullException(nameof(compiledExpression)); + } + + /// + public string Expression { get; } + + /// + public IEnumerable Select(ITypedElement input, EvaluationContext context = null) + { + ArgumentNullException.ThrowIfNull(input); + return _compiledExpression(input.ToScopedNode(), context ?? new EvaluationContext()); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyFhirPathProvider.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyFhirPathProvider.cs new file mode 100644 index 0000000000..cae70a57c2 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyFhirPathProvider.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using Hl7.Fhir.FhirPath; +using Hl7.FhirPath; + +namespace Microsoft.Health.Fhir.Core.Features.FhirPath +{ + /// + /// Compiles expressions with the Firely FHIRPath engine. + /// + public sealed class FirelyFhirPathProvider : IFhirPathProvider + { + private const int CacheSize = 4096; + private readonly FhirPathCompilerCache _cache; + + /// + /// Initializes a new instance of the class. + /// + public FirelyFhirPathProvider() + { + ElementNavFhirExtensions.PrepareFhirSymbolTableFunctions(); + _cache = new FhirPathCompilerCache(new FhirPathCompiler(FhirPathCompiler.DefaultSymbolTable), CacheSize); + } + + /// + public ICompiledFhirPath Compile(string expression) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + return new FirelyCompiledFhirPath(expression, _cache.GetCompiledExpression(expression)); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/ICompiledFhirPath.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/ICompiledFhirPath.cs new file mode 100644 index 0000000000..87976bbc65 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/ICompiledFhirPath.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using Hl7.Fhir.ElementModel; +using Hl7.FhirPath; + +namespace Microsoft.Health.Fhir.Core.Features.FhirPath +{ + /// + /// Represents a compiled FHIRPath expression. + /// + public interface ICompiledFhirPath + { + /// + /// Gets the source expression. + /// + string Expression { get; } + + /// + /// Evaluates the expression against an input element. + /// + /// The input element. + /// The evaluation context. + /// The selected elements. + IEnumerable Select(ITypedElement input, EvaluationContext context = null); + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/IFhirPathProvider.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/IFhirPathProvider.cs new file mode 100644 index 0000000000..9f14cf2a04 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/IFhirPathProvider.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +namespace Microsoft.Health.Fhir.Core.Features.FhirPath +{ + /// + /// Compiles FHIRPath expressions for evaluation. + /// + public interface IFhirPathProvider + { + /// + /// Compiles an expression into an executable handle. + /// + /// The FHIRPath expression. + /// The compiled expression. + ICompiledFhirPath Compile(string expression); + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/Resources/Patch/PatchPayload.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/Resources/Patch/PatchPayload.cs index e031c611a7..77dd924bbd 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Operations/Resources/Patch/PatchPayload.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/Resources/Patch/PatchPayload.cs @@ -8,9 +8,9 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Models; @@ -24,7 +24,7 @@ public abstract class PatchPayload "Resource.id", "Resource.meta.lastUpdated", "Resource.meta.versionId", - "Resource.text.div", + "Resource.text.`div`", "Resource.text.status", }; @@ -42,7 +42,7 @@ public ResourceElement Patch(ResourceWrapper resourceToPatch) ResourceElement patchedResource = GetPatchedResourceElement(resourceToPatch); // Check if any immutable properties were changed - (string path, object result)[] postState = ImmutableProperties.Select(x => (path: x, result: patchedResource.Scalar(x))).ToArray(); + (string path, object result)[] postState = ImmutableProperties.Select(x => (path: x, result: patchedResource.Instance.Scalar(x))).ToArray(); if (!preState.Zip(postState).All(x => x.First.path == x.Second.path && string.Equals(x.First.result?.ToString(), x.Second.result?.ToString(), StringComparison.Ordinal))) { throw new RequestNotValidException(Core.Resources.PatchImmutablePropertiesIsNotValid); diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/AddressToStringSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/AddressToStringSearchValueConverter.cs index 328d2f52a5..87d0caed2d 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/AddressToStringSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/AddressToStringSearchValueConverter.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Linq; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeToTokenSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeToTokenSearchValueConverter.cs index 1f24c250fa..36de04c754 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeToTokenSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeToTokenSearchValueConverter.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableConceptToTokenSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableConceptToTokenSearchValueConverter.cs index ef946e7838..848e19e7bd 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableConceptToTokenSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableConceptToTokenSearchValueConverter.cs @@ -6,7 +6,7 @@ using System; using System.Collections.Generic; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableReferenceToReferenceSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableReferenceToReferenceSearchValueConverter.cs index e3bd96bccc..efd35c1182 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableReferenceToReferenceSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableReferenceToReferenceSearchValueConverter.cs @@ -7,7 +7,7 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableReferenceToTokenSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableReferenceToTokenSearchValueConverter.cs index 06cc55e7bd..eaade10d3d 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableReferenceToTokenSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/CodeableReferenceToTokenSearchValueConverter.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Linq; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ContactPointToTokenSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ContactPointToTokenSearchValueConverter.cs index 9c6d42c209..bebc58127f 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ContactPointToTokenSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ContactPointToTokenSearchValueConverter.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/FhirTypedElementToSearchValueConverterManager.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/FhirTypedElementToSearchValueConverterManager.cs index ad995fdcd8..1947c497cb 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/FhirTypedElementToSearchValueConverterManager.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/FhirTypedElementToSearchValueConverterManager.cs @@ -8,7 +8,7 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/HumanNameToStringSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/HumanNameToStringSearchValueConverter.cs index 8f2adf480f..9d028af706 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/HumanNameToStringSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/HumanNameToStringSearchValueConverter.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Linq; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdToReferenceSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdToReferenceSearchValueConverter.cs index 0abaf8b407..0ae9f5ff44 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdToReferenceSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdToReferenceSearchValueConverter.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdentifierToStringSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdentifierToStringSearchValueConverter.cs index 0acf148615..d57bda3b0e 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdentifierToStringSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdentifierToStringSearchValueConverter.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdentifierToTokenSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdentifierToTokenSearchValueConverter.cs index 69c4c3e30e..7424e17d37 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdentifierToTokenSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/IdentifierToTokenSearchValueConverter.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/MoneyToQuantitySearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/MoneyToQuantitySearchValueConverter.cs index 67d25d690d..eca03b4559 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/MoneyToQuantitySearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/MoneyToQuantitySearchValueConverter.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.ValueSets; diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/PeriodToDateTimeSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/PeriodToDateTimeSearchValueConverter.cs index 3566c5be1e..dedf6ae732 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/PeriodToDateTimeSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/PeriodToDateTimeSearchValueConverter.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; using Microsoft.Health.Fhir.Core.Models; diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/QuantityToQuantitySearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/QuantityToQuantitySearchValueConverter.cs index c9f2eb1d76..3f2b0315ea 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/QuantityToQuantitySearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/QuantityToQuantitySearchValueConverter.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/RangeToNumberSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/RangeToNumberSearchValueConverter.cs index d1e82747a4..2ad2af6cfd 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/RangeToNumberSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/RangeToNumberSearchValueConverter.cs @@ -6,7 +6,7 @@ using System; using System.Collections.Generic; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/RangeToQuantitySearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/RangeToQuantitySearchValueConverter.cs index df4ba352ce..4ed2c75482 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/RangeToQuantitySearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/RangeToQuantitySearchValueConverter.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Model; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ReferenceToUriSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ReferenceToUriSearchValueConverter.cs index 5cfd78665f..be4939e6e5 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ReferenceToUriSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ReferenceToUriSearchValueConverter.cs @@ -6,7 +6,7 @@ using System; using System.Collections.Generic; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ResourceReferenceToReferenceSearchValueConverter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ResourceReferenceToReferenceSearchValueConverter.cs index 741ab3d268..343d135ecd 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ResourceReferenceToReferenceSearchValueConverter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/ResourceReferenceToReferenceSearchValueConverter.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/TypedElementExtensions.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/TypedElementExtensions.cs index 9ee2596dc7..852d236742 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/TypedElementExtensions.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Converters/TypedElementExtensions.cs @@ -7,7 +7,7 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; namespace Microsoft.Health.Fhir.Core.Features.Search.Converters diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/TypedElementSearchIndexer.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/TypedElementSearchIndexer.cs index 7d42fb05e2..ec3e676ff8 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/TypedElementSearchIndexer.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/TypedElementSearchIndexer.cs @@ -10,13 +10,15 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; using Microsoft.Extensions.Logging; using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Features.Search.Converters; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; +using Microsoft.Health.Fhir.Core.Logging.Metrics; using Microsoft.Health.Fhir.Core.Models; +using EvaluationContext = Hl7.FhirPath.EvaluationContext; using SearchParamType = Microsoft.Health.Fhir.ValueSets.SearchParamType; namespace Microsoft.Health.Fhir.Core.Features.Search @@ -30,10 +32,10 @@ public class TypedElementSearchIndexer : ISearchIndexer private readonly ITypedElementToSearchValueConverterManager _fhirElementTypeConverterManager; private readonly IReferenceToElementResolver _referenceToElementResolver; private readonly IModelInfoProvider _modelInfoProvider; + private readonly IFhirPathProvider _fhirPathProvider; + private readonly IFailureMetricHandler _failureMetricHandler; private readonly ILogger _logger; private readonly ConcurrentDictionary> _targetTypesLookup = new(); - private static readonly FhirPathCompiler _compiler = new(); - private readonly ConcurrentDictionary _expressions = new(); /// /// Initializes a new instance of the class. @@ -42,24 +44,32 @@ public class TypedElementSearchIndexer : ISearchIndexer /// The FHIR element type converter manager. /// Used for parsing reference strings /// Model info provider + /// FHIRPath provider /// The logger. + /// The failure metric handler. public TypedElementSearchIndexer( ISupportedSearchParameterDefinitionManager searchParameterDefinitionManager, ITypedElementToSearchValueConverterManager fhirElementTypeConverterManager, IReferenceToElementResolver referenceToElementResolver, IModelInfoProvider modelInfoProvider, - ILogger logger) + IFhirPathProvider fhirPathProvider, + ILogger logger, + IFailureMetricHandler failureMetricHandler) { EnsureArg.IsNotNull(searchParameterDefinitionManager, nameof(searchParameterDefinitionManager)); EnsureArg.IsNotNull(fhirElementTypeConverterManager, nameof(fhirElementTypeConverterManager)); EnsureArg.IsNotNull(referenceToElementResolver, nameof(referenceToElementResolver)); EnsureArg.IsNotNull(modelInfoProvider, nameof(modelInfoProvider)); + EnsureArg.IsNotNull(fhirPathProvider, nameof(fhirPathProvider)); EnsureArg.IsNotNull(logger, nameof(logger)); + EnsureArg.IsNotNull(failureMetricHandler, nameof(failureMetricHandler)); _searchParameterDefinitionManager = searchParameterDefinitionManager; _fhirElementTypeConverterManager = fhirElementTypeConverterManager; _referenceToElementResolver = referenceToElementResolver; _modelInfoProvider = modelInfoProvider; + _fhirPathProvider = fhirPathProvider; + _failureMetricHandler = failureMetricHandler; _logger = logger; } @@ -105,11 +115,11 @@ private IEnumerable ProcessCompositeSearchParameter(SearchPara SearchParameterInfo compositeSearchParameterInfo = searchParameter; - CompiledExpression expression = _expressions.GetOrAdd(searchParameter.Expression, s => _compiler.Compile(s)); - - IEnumerable rootObjects = expression.Invoke(resource, context); - - foreach (var rootObject in rootObjects) + foreach (ITypedElement rootObject in EvaluateFhirPath( + searchParameter.Url.ToString(), + resource, + searchParameter.Expression, + context)) { int numberOfComponents = searchParameter.Component.Count; bool skip = false; @@ -202,29 +212,11 @@ private List ExtractSearchValues( var results = new List(); // For simple value type, we can parse the expression directly. - IEnumerable extractedValues = Enumerable.Empty(); - - try - { - CompiledExpression expression = _expressions.GetOrAdd(fhirPathExpression, s => _compiler.Compile(s)); - - extractedValues = expression.Invoke(element, context); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Failed to extract the values using '{FhirPathExpression}' against '{ElementType}'.", - fhirPathExpression, - element.GetType()); - } - - Debug.Assert(extractedValues != null, "The extracted values should not be null."); - if (extractedValues == null) - { - _logger.LogWarning("The extracted values should not be null."); - return results; - } + IEnumerable extractedValues = EvaluateFhirPath( + searchParameterDefinitionUrl, + element, + fhirPathExpression, + context); // If there is target set, then filter the extracted values to only those types. if (searchParameterType == SearchParamType.Reference && @@ -244,23 +236,30 @@ private List ExtractSearchValues( // http://community.fhir.org/t/expression-seems-incorrect-for-reference-search-parameter-thats-only-applicable-to-certain-types/916/2). // Therefore, for now, we will need to compare the reference value itself (which can be internal or external references), and restrict // the values ourselves. - extractedValues = extractedValues.Where(ev => - { - if (ev == null) + extractedValues = extractedValues + .Where(ev => { - _logger.LogWarning( - "The FHIR element should not be null. Expression: '{FhirPathExpression}', ElementType: '{ElementType}'.", - fhirPathExpression, - element.GetType()); - } + if (ev == null) + { + _logger.LogWarning( + "The FHIR element should not be null. Expression: '{FhirPathExpression}', ElementType: '{ElementType}'.", + fhirPathExpression, + element.GetType()); + } - if (ev?.InstanceType != null && ev.InstanceType.Equals("ResourceReference", StringComparison.OrdinalIgnoreCase)) - { - return ev.Scalar("reference") is string rr && targetResourceTypes.Any(trt => rr.Contains(trt, StringComparison.Ordinal)); - } + if (ev?.InstanceType != null && ev.InstanceType.Equals("ResourceReference", StringComparison.OrdinalIgnoreCase)) + { + return EvaluateFhirPath( + searchParameterDefinitionUrl, + ev, + "reference", + null).SingleOrDefault()?.Value is string rr && + targetResourceTypes.Any(trt => rr.Contains(trt, StringComparison.Ordinal)); + } - return true; - }); + return true; + }) + .ToArray(); } foreach (var extractedValue in extractedValues) @@ -314,6 +313,44 @@ private List ExtractSearchValues( return results; } + private ITypedElement[] EvaluateFhirPath( + string searchParameterDefinitionUrl, + ITypedElement element, + string fhirPathExpression, + EvaluationContext context) + { + // Contain provider compile and evaluation failures for every root and component expression + // to preserve write availability; warning telemetry makes any resulting index drift observable. + // OperationCanceledException is rethrown unchanged. + try + { + ICompiledFhirPath expression = _fhirPathProvider.Compile(fhirPathExpression); + return expression.Select(element, context).ToArray(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to extract search parameter '{SearchParameterDefinitionUrl}' using '{FhirPathExpression}' against '{ElementType}'.", + searchParameterDefinitionUrl, + fhirPathExpression, + element.InstanceType); + _failureMetricHandler.EmitException( + new ExceptionMetricNotification + { + OperationName = "FhirPathSearchIndexEvaluation", + ExceptionType = ex.GetType().Name, + Severity = LogLevel.Warning.ToString(), + }); + + return Array.Empty(); + } + } + internal static Type GetSearchValueTypeForSearchParamType(SearchParamType? searchParamType) { switch (searchParamType) diff --git a/src/Microsoft.Health.Fhir.Core/Features/Validation/NarrativeValidator.cs b/src/Microsoft.Health.Fhir.Core/Features/Validation/NarrativeValidator.cs index 4a068d6218..d38fcb4002 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Validation/NarrativeValidator.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Validation/NarrativeValidator.cs @@ -11,7 +11,7 @@ using FluentValidation; using FluentValidation.Results; using Hl7.Fhir.ElementModel; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Models; namespace Microsoft.Health.Fhir.Core.Features.Validation.Narratives @@ -67,7 +67,7 @@ private IEnumerable ValidateResource(ITypedElement typedEleme } var errors = _narrativeHtmlSanitizer.Validate(xhtml); - var fullFhirPath = typedElement.InstanceType + "." + KnownFhirPaths.ResourceNarrative; + var fullFhirPath = typedElement.InstanceType + "." + KnownFhirPaths.ResourceNarrativeDisplayPath; foreach (var error in errors) { diff --git a/src/Microsoft.Health.Fhir.Core/Models/IModelInfoProvider.cs b/src/Microsoft.Health.Fhir.Core/Models/IModelInfoProvider.cs index c93a5fe2e8..fac618e36a 100644 --- a/src/Microsoft.Health.Fhir.Core/Models/IModelInfoProvider.cs +++ b/src/Microsoft.Health.Fhir.Core/Models/IModelInfoProvider.cs @@ -7,8 +7,8 @@ using System.Collections.Generic; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Specification; -using Hl7.FhirPath; using Microsoft.Health.Fhir.Core.Features.Persistence; +using EvaluationContext = Hl7.FhirPath.EvaluationContext; namespace Microsoft.Health.Fhir.Core.Models { diff --git a/src/Microsoft.Health.Fhir.Core/Models/KnownFhirPaths.cs b/src/Microsoft.Health.Fhir.Core/Models/KnownFhirPaths.cs index 04075e0307..b548b9ee2c 100644 --- a/src/Microsoft.Health.Fhir.Core/Models/KnownFhirPaths.cs +++ b/src/Microsoft.Health.Fhir.Core/Models/KnownFhirPaths.cs @@ -20,7 +20,15 @@ public static class KnownFhirPaths public const string BundleType = "Resource.type"; - public const string ResourceNarrative = "text.div"; + /// + /// The FHIRPath expression for a resource narrative. The div identifier is escaped because it is a FHIRPath keyword. + /// + public const string ResourceNarrative = "text.`div`"; + + /// + /// The unescaped display path for a resource narrative. This is for display only and is not the equivalent FHIRPath expression. + /// + public const string ResourceNarrativeDisplayPath = "text.div"; public const string IsSoftDeletedExtension = $"Resource.meta.extension.where(url = '{AzureSoftDeletedExtensionUrl}').where(value='soft-deleted').exists()"; } diff --git a/src/Microsoft.Health.Fhir.Core/Models/ModelInfoProvider.cs b/src/Microsoft.Health.Fhir.Core/Models/ModelInfoProvider.cs index 994500727b..31aa89a282 100644 --- a/src/Microsoft.Health.Fhir.Core/Models/ModelInfoProvider.cs +++ b/src/Microsoft.Health.Fhir.Core/Models/ModelInfoProvider.cs @@ -8,7 +8,7 @@ using EnsureThat; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Specification; -using Hl7.FhirPath; +using EvaluationContext = Hl7.FhirPath.EvaluationContext; namespace Microsoft.Health.Fhir.Core.Models { diff --git a/src/Microsoft.Health.Fhir.Core/Models/ResourceElement.cs b/src/Microsoft.Health.Fhir.Core/Models/ResourceElement.cs index 6fe2b75a44..db13aaa62b 100644 --- a/src/Microsoft.Health.Fhir.Core/Models/ResourceElement.cs +++ b/src/Microsoft.Health.Fhir.Core/Models/ResourceElement.cs @@ -9,7 +9,8 @@ using EnsureThat; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Serialization; -using Hl7.FhirPath; +using Microsoft.Health.Fhir.Core.Features.FhirPath; +using EvaluationContext = Hl7.FhirPath.EvaluationContext; namespace Microsoft.Health.Fhir.Core.Models { @@ -32,7 +33,11 @@ public ResourceElement(ITypedElement instance) Instance = instance; _context = new Lazy(() => - new EvaluationContext().WithResourceOverrides(instance)); + new EvaluationContext + { + Resource = instance, + RootResource = instance, + }); } internal ResourceElement(ITypedElement instance, object resourceInstance) diff --git a/src/Microsoft.Health.Fhir.Core/Models/SearchParameterInfo.cs b/src/Microsoft.Health.Fhir.Core/Models/SearchParameterInfo.cs index c9c262aaa7..e41fa8eb44 100644 --- a/src/Microsoft.Health.Fhir.Core/Models/SearchParameterInfo.cs +++ b/src/Microsoft.Health.Fhir.Core/Models/SearchParameterInfo.cs @@ -11,8 +11,8 @@ using EnsureThat; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Utility; -using Hl7.FhirPath; using Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Features.Search.Registry; using Microsoft.Health.Fhir.ValueSets; diff --git a/src/Microsoft.Health.Fhir.Ignixa/Features/Operations/Import/IgnixaImportResourceParser.cs b/src/Microsoft.Health.Fhir.Ignixa/Features/Operations/Import/IgnixaImportResourceParser.cs index a37a147c97..89c8958eda 100644 --- a/src/Microsoft.Health.Fhir.Ignixa/Features/Operations/Import/IgnixaImportResourceParser.cs +++ b/src/Microsoft.Health.Fhir.Ignixa/Features/Operations/Import/IgnixaImportResourceParser.cs @@ -63,12 +63,10 @@ public ImportResource Parse(long index, long offset, int length, string rawResou ImportResourceIdValidator.Validate(resource.Id); CheckConditionalReferenceInResource(resource, importMode); - resource.Meta ??= new MetaJsonNode(); - - var lastUpdatedIsNull = importMode == ImportMode.InitialLoad || resource.Meta.LastUpdated == null; - var lastUpdated = lastUpdatedIsNull ? Clock.UtcNow : resource.Meta.LastUpdated.Value; - resource.Meta.LastUpdated = new DateTimeOffset(lastUpdated.DateTime.TruncateToMillisecond(), lastUpdated.Offset); - if (!lastUpdatedIsNull && resource.Meta.LastUpdated.Value > Clock.UtcNow.AddSeconds(10)) // 10 sec is the max for the computers in the domain + var lastUpdatedIsNull = importMode == ImportMode.InitialLoad || resource.Meta.LastUpdatedOffset == null; + var lastUpdated = lastUpdatedIsNull ? Clock.UtcNow : resource.Meta.LastUpdatedOffset.Value; + resource.Meta.LastUpdatedOffset = new DateTimeOffset(lastUpdated.DateTime.TruncateToMillisecond(), lastUpdated.Offset); + if (!lastUpdatedIsNull && resource.Meta.LastUpdatedOffset.Value > Clock.UtcNow.AddSeconds(10)) // 10 sec is the max for the computers in the domain { throw new NotSupportedException("LastUpdated in the resource cannot be in the future."); } @@ -107,13 +105,10 @@ public ImportResource Parse(long index, long offset, int length, string rawResou element = resource.ToElement(_schemaContext.Schema); } - // Phase-2a flip point: the one-arg ResourceElement ctor below leaves ResourceInstance unset, so - // RawResourceFactory can't see the native ResourceJsonNode and falls through to a full ToPoco() - // rebuild plus Firely's FhirJsonSerializer - the same cost Firely mode pays, on top of the Ignixa - // parse above. The next phase should carry the node through via the two-arg ResourceElement ctor - // and add a native-serialize IRawResourceFactory decorator that uses it when present; that's the - // biggest single perf win per the sdk-migration import-performance-analysis doc. Don't just swap - // the ctor here without adding that decorator in the same change, or nothing downstream will use it. + // The one-argument ResourceElement constructor leaves ResourceInstance unset, so RawResourceFactory + // cannot use the native ResourceJsonNode and falls back to a Firely POCO rebuild and serialization. + // A future native persistence-codec seam must carry the node through the two-argument constructor + // and consume it in IRawResourceFactory in the same change. var resourceElement = new ResourceElement(element.ToTypedElement()); var resourceWrapper = _resourceFactory.Create(resourceElement, isDeleted, true, keepVersion); @@ -134,7 +129,7 @@ public ImportResource Parse(long index, long offset, int length, string rawResou /// private void CheckConditionalReferenceInResource(ResourceJsonNode resource, ImportMode importMode) { - if (importMode == ImportMode.IncrementalLoad || resource.MutableNode is not JsonObject root) + if (importMode == ImportMode.IncrementalLoad || resource.ToSourceNavigator().Meta() is not JsonObject root) { return; } @@ -156,7 +151,7 @@ private void CheckConditionalReferenceInResource(ResourceJsonNode resource, Impo { foreach (var item in array) { - ThrowIfConditionalReference(item, resource.FhirVersion); + ThrowIfConditionalReference(item); } } else @@ -166,23 +161,21 @@ private void CheckConditionalReferenceInResource(ResourceJsonNode resource, Impo // Match that leniency here (field.IsCollection but value isn't a JsonArray) instead of // rejecting it - ThrowIfConditionalReference still throws below if this value isn't even // a JSON object. - ThrowIfConditionalReference(value, resource.FhirVersion); + ThrowIfConditionalReference(value); } } } /// - /// Reads the reference field through the typed model instead of casting - /// through raw . A missing "reference" property (e.g. an identifier-only or - /// display-only reference, both valid FHIR) yields a null and - /// is skipped, matching the Firely parser. A non-string "reference" scalar (e.g. "reference": 123) - /// is deliberately not guarded against - throws in that case. + /// Reads the reference field from the released JSON facade. A missing "reference" property (e.g. an + /// identifier-only or display-only reference, both valid FHIR) is skipped, matching the Firely parser. + /// A non-string "reference" scalar (e.g. "reference": 123) deliberately throws. /// A reference field that is present but isn't a JSON object at all (schema-invalid, e.g. a bare string /// or number) also throws here rather than being silently skipped - confirmed empirically that /// resource.ToElement(schema) does NOT reject this shape on its own, so this is the only place /// that catches it. A null array item (e.g. [null, {...}]) is treated as absent, not malformed. /// - private static void ThrowIfConditionalReference(JsonNode referenceNode, FhirVersion? fhirVersion) + private static void ThrowIfConditionalReference(JsonNode referenceNode) { if (referenceNode is null) { @@ -194,7 +187,7 @@ private static void ThrowIfConditionalReference(JsonNode referenceNode, FhirVers throw new FormatException($"Expected a Reference object but found {referenceNode.GetValueKind()}."); } - var reference = new ReferenceJsonNode(referenceObject, fhirVersion).Reference; + var reference = referenceObject["reference"]?.GetValue(); if (!string.IsNullOrWhiteSpace(reference) && reference.Contains('?', StringComparison.Ordinal)) { throw new NotSupportedException($"Conditional reference is not supported for $import in {ImportMode.InitialLoad}."); diff --git a/src/Microsoft.Health.Fhir.Ignixa/IgnixaCompiledFhirPath.cs b/src/Microsoft.Health.Fhir.Ignixa/IgnixaCompiledFhirPath.cs new file mode 100644 index 0000000000..c9eafd8d50 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Ignixa/IgnixaCompiledFhirPath.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using Hl7.Fhir.ElementModel; +using Ignixa.Abstractions; +using Ignixa.Extensions.FirelySdk; +using Ignixa.FhirPath.Evaluation; +using Ignixa.FhirPath.Expressions; +using Ignixa.FhirPath.Parser; +using Microsoft.Health.Fhir.Core.Features.FhirPath; +using FirelyEvaluationContext = Hl7.FhirPath.EvaluationContext; +using IgnixaEvaluationContext = Ignixa.FhirPath.Evaluation.EvaluationContext; + +namespace Microsoft.Health.Fhir.Ignixa +{ + /// + /// Executes an Ignixa-compiled FHIRPath expression. + /// + public sealed class IgnixaCompiledFhirPath : ICompiledFhirPath + { + private readonly Expression _expression; + private readonly FhirPathEvaluator _evaluator; + private readonly Func> _compiledDelegate; + private readonly IgnixaEvaluationContextBridge _contextBridge; + + /// + /// Initializes a new instance of the class. + /// + /// The source expression. + /// The evaluation-context bridge. + public IgnixaCompiledFhirPath(string expression, IgnixaEvaluationContextBridge contextBridge) + { + Expression = expression ?? throw new ArgumentNullException(nameof(expression)); + _contextBridge = contextBridge ?? throw new ArgumentNullException(nameof(contextBridge)); + _expression = new FhirPathParser(preserveTrivia: false).Parse(expression); + _evaluator = new FhirPathEvaluator(); + _compiledDelegate = new FhirPathDelegateCompiler(_evaluator).TryCompile(_expression); + } + + /// + public string Expression { get; } + + /// + public IEnumerable Select(ITypedElement input, FirelyEvaluationContext context = null) + { + ArgumentNullException.ThrowIfNull(input); + + ScopedNode scopedInput = input.ToScopedNode(); + IElement ignixaInput = scopedInput.ToIgnixaElement(); + IgnixaEvaluationContext ignixaContext = _contextBridge.Create(scopedInput, context); + IEnumerable result = _compiledDelegate is null + ? _evaluator.Evaluate(ignixaInput, _expression, ignixaContext) + : _compiledDelegate(ignixaInput, ignixaContext); + + return result.ToTypedElements(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Ignixa/IgnixaEvaluationContextBridge.cs b/src/Microsoft.Health.Fhir.Ignixa/IgnixaEvaluationContextBridge.cs new file mode 100644 index 0000000000..a15525eced --- /dev/null +++ b/src/Microsoft.Health.Fhir.Ignixa/IgnixaEvaluationContextBridge.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Hl7.Fhir.ElementModel; +using Ignixa.Extensions.FirelySdk; +using Ignixa.FhirPath.Evaluation; +using FirelyEvaluationContext = Hl7.FhirPath.EvaluationContext; +using FirelyFhirEvaluationContext = Hl7.Fhir.FhirPath.FhirEvaluationContext; +using IgnixaFhirEvaluationContext = Ignixa.FhirPath.Evaluation.FhirEvaluationContext; + +namespace Microsoft.Health.Fhir.Ignixa +{ + /// + /// Translates Firely evaluation state to the Ignixa evaluation model. + /// + public sealed class IgnixaEvaluationContextBridge + { + private readonly IgnixaSchemaContext _schemaContext; + + /// + /// Initializes a new instance of the class. + /// + /// The active FHIR schema. + public IgnixaEvaluationContextBridge(IgnixaSchemaContext schemaContext) + { + _schemaContext = schemaContext ?? throw new ArgumentNullException(nameof(schemaContext)); + } + + /// + /// Creates an Ignixa context that preserves Firely's scoped-node and resolver behavior. + /// + /// The scoped input element. + /// The caller-supplied Firely context. + /// The translated context. + public EvaluationContext Create(ScopedNode input, FirelyEvaluationContext context) + { + ArgumentNullException.ThrowIfNull(input); + context ??= new FirelyEvaluationContext(); + + context.Resource ??= GetResource(input); + context.RootResource ??= GetRootResource(input); + var ignixaInput = input.ToIgnixaElement(); + + IgnixaFhirEvaluationContext result = new() + { + Schema = _schemaContext.Schema, + ContextNode = ignixaInput, + Resource = context.Resource?.ToIgnixaElement(), + RootResource = context.RootResource?.ToIgnixaElement(), + ElementResolver = context is FirelyFhirEvaluationContext fhirContext && fhirContext.ElementResolver is not null + ? reference => fhirContext.ElementResolver(reference)?.ToIgnixaElement() + : null, + }; + + foreach (KeyValuePair> variable in context.Environment) + { + result = result with + { + Environment = result.Environment.SetItem( + variable.Key, + variable.Value.Select(element => element.ToIgnixaElement()).ToImmutableList()), + }; + } + + return result; + } + + private static ScopedNode GetResource(ScopedNode input) + => input.AtResource ? input : input.ParentResource; + + private static ScopedNode GetRootResource(ScopedNode input) + { + ScopedNode resource = input.AtResource ? input : input.ParentResource; + return resource?.Name == "contained" ? resource.ParentResource : resource; + } + } +} diff --git a/src/Microsoft.Health.Fhir.Ignixa/IgnixaFhirPathProvider.cs b/src/Microsoft.Health.Fhir.Ignixa/IgnixaFhirPathProvider.cs new file mode 100644 index 0000000000..48605fb064 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Ignixa/IgnixaFhirPathProvider.cs @@ -0,0 +1,71 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Microsoft.Health.Fhir.Core.Features.FhirPath; + +namespace Microsoft.Health.Fhir.Ignixa +{ + /// + /// Compiles expressions with the Ignixa FHIRPath engine. + /// + public sealed class IgnixaFhirPathProvider : IFhirPathProvider + { + private const int CacheSize = 4096; + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + private readonly Queue _insertionOrder = new(); + private readonly object _cacheMutationSync = new(); + private readonly IgnixaEvaluationContextBridge _contextBridge; + + /// + /// Initializes a new instance of the class. + /// + /// The active FHIR schema. + public IgnixaFhirPathProvider(IgnixaSchemaContext schemaContext) + { + _contextBridge = new IgnixaEvaluationContextBridge(schemaContext); + } + + /// + public ICompiledFhirPath Compile(string expression) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + + if (_cache.TryGetValue(expression, out ICompiledFhirPath compiled)) + { + return compiled; + } + + lock (_cacheMutationSync) + { + if (_cache.TryGetValue(expression, out compiled)) + { + return compiled; + } + + compiled = new IgnixaCompiledFhirPath(expression, _contextBridge); + if (!_cache.TryAdd(expression, compiled)) + { + throw new InvalidOperationException("The compiled FHIRPath cache changed while holding its mutation lock."); + } + + _insertionOrder.Enqueue(expression); + + if (_insertionOrder.Count > CacheSize) + { + string oldestExpression = _insertionOrder.Dequeue(); + if (!_cache.TryRemove(oldestExpression, out _)) + { + throw new InvalidOperationException("The compiled FHIRPath cache and its eviction queue are inconsistent."); + } + } + + return compiled; + } + } + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Microsoft.Health.Fhir.Shared.Api.UnitTests.projitems b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Microsoft.Health.Fhir.Shared.Api.UnitTests.projitems index 97d77516bc..1e91536c4b 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Microsoft.Health.Fhir.Shared.Api.UnitTests.projitems +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Microsoft.Health.Fhir.Shared.Api.UnitTests.projitems @@ -79,6 +79,8 @@ + + diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/FhirPathProviderTestCollection.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/FhirPathProviderTestCollection.cs new file mode 100644 index 0000000000..3d59ef8842 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/FhirPathProviderTestCollection.cs @@ -0,0 +1,15 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Xunit; + +namespace Microsoft.Health.Fhir.Api.UnitTests.Modules +{ + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class FhirPathProviderTestCollection + { + public const string Name = nameof(FhirPathProviderTestCollection); + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/OperationsModuleTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/OperationsModuleTests.cs index eccd33d2a8..1b75a242d0 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/OperationsModuleTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/OperationsModuleTests.cs @@ -41,7 +41,7 @@ public void GivenDefaultConfiguration_WhenModuleLoads_ThenFirelyParserIsRegister public void GivenIgnixaConfiguration_WhenModuleLoads_ThenIgnixaParserIsRegistered() { var configuration = new FhirServerConfiguration(); - configuration.CoreFeatures.FhirSdkProvider = FhirSdkProvider.Ignixa; + configuration.CoreFeatures.FhirSdkProvider.Import = FhirSdkProvider.Ignixa; var services = new ServiceCollection(); new OperationsModule(configuration).Load(services); @@ -55,7 +55,7 @@ public void GivenIgnixaConfiguration_WhenModuleLoads_ThenIgnixaParserIsRegistere public void GivenUnknownProvider_WhenModuleLoads_ThenStartupFails() { var configuration = new FhirServerConfiguration(); - configuration.CoreFeatures.FhirSdkProvider = (FhirSdkProvider)999; + configuration.CoreFeatures.FhirSdkProvider.Import = (FhirSdkProvider)999; Assert.Throws( () => new OperationsModule(configuration).Load(new ServiceCollection())); diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/SearchModuleTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/SearchModuleTests.cs new file mode 100644 index 0000000000..7719163260 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Modules/SearchModuleTests.cs @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using Hl7.FhirPath; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Health.Fhir.Api.Configs; +using Microsoft.Health.Fhir.Api.Modules; +using Microsoft.Health.Fhir.Core.Configs; +using Microsoft.Health.Fhir.Core.Features.FhirPath; +using Microsoft.Health.Fhir.Ignixa; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.Api.UnitTests.Modules +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + [Collection(FhirPathProviderTestCollection.Name)] + public class SearchModuleTests : IDisposable + { + private readonly IFhirPathProvider _originalAmbientProvider = FhirPathProvider.Instance; + + [Fact] + public void GivenDefaultConfiguration_WhenModuleLoads_ThenAmbientAndDependencyInjectionUseSameFirelySingleton() + { + var services = new ServiceCollection(); + + new SearchModule(new FhirServerConfiguration()).Load(services); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFhirPathProvider dependencyInjectionProvider = serviceProvider.GetRequiredService(); + Assert.IsType(dependencyInjectionProvider); + Assert.Same(FhirPathProvider.Instance, dependencyInjectionProvider); + } + + [Fact] + public void GivenUnknownFhirPathProvider_WhenModuleLoads_ThenStartupFails() + { + var configuration = new FhirServerConfiguration(); + configuration.CoreFeatures.FhirSdkProvider.FhirPath = (FhirSdkProvider)999; + + Assert.Throws( + () => new SearchModule(configuration).Load(new ServiceCollection())); + } + + [Fact] + public void GivenIgnixaConfiguration_WhenModuleLoads_ThenFirelyPatchFunctionsRemainRegistered() + { + var configuration = new FhirServerConfiguration(); + configuration.CoreFeatures.FhirSdkProvider.FhirPath = FhirSdkProvider.Ignixa; + var services = new ServiceCollection(); + + new SearchModule(configuration).Load(services); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + var compiler = new FhirPathCompiler(); + + Assert.IsType(serviceProvider.GetRequiredService()); + Assert.NotNull(compiler.Compile("id.hasValue()")); + Assert.NotNull(compiler.Compile("managingOrganization.resolve().hasValue()")); + } + + public void Dispose() + => FhirPathProvider.SetProviderFactory(() => _originalAmbientProvider); + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Modules/FhirModule.cs b/src/Microsoft.Health.Fhir.Shared.Api/Modules/FhirModule.cs index 46e956d1ec..5537699fd6 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Modules/FhirModule.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Modules/FhirModule.cs @@ -6,10 +6,8 @@ using System; using System.Collections.Generic; using EnsureThat; -using Hl7.Fhir.FhirPath; using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; -using Hl7.FhirPath; using Medino; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; @@ -65,8 +63,6 @@ public void Load(IServiceCollection services) services.AddSingleton(xmlSerializer); services.AddSingleton(); - FhirPathCompiler.DefaultSymbolTable.AddFhirExtensions(); - ResourceElement SetMetadata(Resource resource, string versionId, DateTimeOffset lastModified) { resource.VersionId = versionId; @@ -141,9 +137,6 @@ ResourceElement SetMetadata(Resource resource, string versionId, DateTimeOffset services.AddSingleton(); services.AddSingleton(); - // Support for resolve() - FhirPathCompiler.DefaultSymbolTable.AddFhirExtensions(); - services.Add() .Singleton() .AsSelf() diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Modules/FhirSdkProviderStartupLogger.cs b/src/Microsoft.Health.Fhir.Shared.Api/Modules/FhirSdkProviderStartupLogger.cs index 257186248b..3d284a41a3 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Modules/FhirSdkProviderStartupLogger.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Modules/FhirSdkProviderStartupLogger.cs @@ -17,7 +17,7 @@ namespace Microsoft.Health.Fhir.Api.Modules /// public sealed class FhirSdkProviderStartupLogger : IHostedService { - private readonly FhirSdkProvider _provider; + private readonly FhirSdkProviderConfiguration _configuration; private readonly ILogger _logger; /// @@ -29,7 +29,7 @@ public FhirSdkProviderStartupLogger( IOptions configuration, ILogger logger) { - _provider = configuration.Value.FhirSdkProvider; + _configuration = configuration.Value.FhirSdkProvider; _logger = logger; } @@ -37,8 +37,10 @@ public FhirSdkProviderStartupLogger( public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation( - "FHIR SDK provider configured: {FhirSdkProvider}; migrated seams: Import.", - _provider); + "FHIR SDK providers configured: Default={DefaultProvider}; Import={ImportProvider}; FHIRPath={FhirPathProvider}. FHIRPath Patch remains Firely-backed.", + _configuration.Default, + _configuration.EffectiveImport, + _configuration.EffectiveFhirPath); return Task.CompletedTask; } diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Modules/OperationsModule.cs b/src/Microsoft.Health.Fhir.Shared.Api/Modules/OperationsModule.cs index 65ecc23645..d26f3e4599 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Modules/OperationsModule.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Modules/OperationsModule.cs @@ -40,7 +40,7 @@ public OperationsModule(FhirServerConfiguration fhirServerConfiguration) { EnsureArg.IsNotNull(fhirServerConfiguration, nameof(fhirServerConfiguration)); - _fhirSdkProvider = fhirServerConfiguration.CoreFeatures.FhirSdkProvider; + _fhirSdkProvider = fhirServerConfiguration.CoreFeatures.FhirSdkProvider.EffectiveImport; } public void Load(IServiceCollection services) diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Modules/SearchModule.cs b/src/Microsoft.Health.Fhir.Shared.Api/Modules/SearchModule.cs index 387d502ed0..dd66fcca55 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Modules/SearchModule.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Modules/SearchModule.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using EnsureThat; +using Hl7.Fhir.FhirPath; using Medino; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -14,9 +15,11 @@ using Microsoft.Health.Fhir.Api.Configs; using Microsoft.Health.Fhir.Api.Features.Filters; using Microsoft.Health.Fhir.Api.Features.Routing; +using Microsoft.Health.Fhir.Core.Configs; using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Compartment; using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Routing; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Features.Search.Access; @@ -32,6 +35,7 @@ using Microsoft.Health.Fhir.Core.Messages.Storage; using Microsoft.Health.Fhir.Core.Messages.Upsert; using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.Ignixa; using Microsoft.Health.Fhir.Shared.Core.Features.Search.Parameters; namespace Microsoft.Health.Fhir.Api.Modules @@ -55,6 +59,18 @@ public void Load(IServiceCollection services) { EnsureArg.IsNotNull(services, nameof(services)); + // FHIRPath Patch remains Firely-backed even when the evaluation seam uses Ignixa. + ElementNavFhirExtensions.PrepareFhirSymbolTableFunctions(); + + Func providerFactory = _configuration.CoreFeatures.FhirSdkProvider.EffectiveFhirPath switch + { + FhirSdkProvider.Firely => () => new FirelyFhirPathProvider(), + FhirSdkProvider.Ignixa => () => new IgnixaFhirPathProvider(new IgnixaSchemaContext(ModelInfoProvider.Instance)), + var provider => throw new InvalidOperationException($"Unsupported FHIR SDK provider: {provider}."), + }; + + FhirPathProvider.SetProviderFactory(providerFactory); + services.AddSingleton(_ => FhirPathProvider.Instance); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/FhirPath/FhirPathProviderTestCollection.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/FhirPath/FhirPathProviderTestCollection.cs new file mode 100644 index 0000000000..d6059cacad --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/FhirPath/FhirPathProviderTestCollection.cs @@ -0,0 +1,15 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Xunit; + +namespace Microsoft.Health.Fhir.Core.UnitTests.Features.FhirPath +{ + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class FhirPathProviderTestCollection + { + public const string Name = nameof(FhirPathProviderTestCollection); + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/FhirPath/FhirPathProviderTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/FhirPath/FhirPathProviderTests.cs new file mode 100644 index 0000000000..50ad82e526 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/FhirPath/FhirPathProviderTests.cs @@ -0,0 +1,542 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Hl7.Fhir.ElementModel; +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.Core.Features.FhirPath; +using Microsoft.Health.Fhir.Core.Features.Persistence; +using Microsoft.Health.Fhir.Core.Features.Resources.Patch; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; +using Microsoft.Health.Fhir.Core.Logging.Metrics; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.Core.UnitTests.Features.Search; +using Microsoft.Health.Fhir.Ignixa; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Xunit; +using EvaluationContext = Hl7.FhirPath.EvaluationContext; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.Health.Fhir.Core.UnitTests.Features.FhirPath +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + [Collection(FhirPathProviderTestCollection.Name)] + public class FhirPathProviderTests : IDisposable + { + private readonly IFhirPathProvider _originalAmbientProvider = FhirPathProvider.Instance; + + [Fact] + public void GivenProviderFactory_WhenProviderIsReadMultipleTimes_ThenItIsCreatedLazilyOnce() + { + var expectedProvider = Substitute.For(); + int factoryInvocationCount = 0; + + FhirPathProvider.SetProviderFactory(() => + { + Interlocked.Increment(ref factoryInvocationCount); + return expectedProvider; + }); + + Assert.Equal(0, Volatile.Read(ref factoryInvocationCount)); + Assert.Same(expectedProvider, FhirPathProvider.Instance); + Assert.Same(expectedProvider, FhirPathProvider.Instance); + Assert.Equal(1, Volatile.Read(ref factoryInvocationCount)); + } + + [Fact] + public void GivenMaterializedProvider_WhenFactoryIsReplaced_ThenNewProviderIsCreatedLazily() + { + var originalProvider = Substitute.For(); + var replacementProvider = Substitute.For(); + int originalFactoryInvocationCount = 0; + int replacementFactoryInvocationCount = 0; + + FhirPathProvider.SetProviderFactory(() => + { + Interlocked.Increment(ref originalFactoryInvocationCount); + return originalProvider; + }); + Assert.Same(originalProvider, FhirPathProvider.Instance); + + FhirPathProvider.SetProviderFactory(() => + { + Interlocked.Increment(ref replacementFactoryInvocationCount); + return replacementProvider; + }); + + Assert.Equal(1, Volatile.Read(ref originalFactoryInvocationCount)); + Assert.Equal(0, Volatile.Read(ref replacementFactoryInvocationCount)); + Assert.Same(replacementProvider, FhirPathProvider.Instance); + Assert.Equal(1, Volatile.Read(ref replacementFactoryInvocationCount)); + } + + [Fact] + public void GivenProviderFactoryReturningNull_WhenProviderIsRead_ThenAnExceptionIsThrown() + { + FhirPathProvider.SetProviderFactory(static () => null); + + Assert.Throws(() => FhirPathProvider.Instance); + } + + [Fact] + public async Task GivenConcurrentProviderReads_WhenProviderHasNotBeenCreated_ThenItIsCreatedOnce() + { + var expectedProvider = Substitute.For(); + int factoryInvocationCount = 0; + FhirPathProvider.SetProviderFactory(() => + { + Interlocked.Increment(ref factoryInvocationCount); + return expectedProvider; + }); + + System.Threading.Tasks.Task[] reads = Enumerable.Range(0, 32) + .Select(_ => System.Threading.Tasks.Task.Run(() => FhirPathProvider.Instance)) + .ToArray(); + + IFhirPathProvider[] providers = await System.Threading.Tasks.Task.WhenAll(reads); + + Assert.All(providers, provider => Assert.Same(expectedProvider, provider)); + Assert.Equal(1, Volatile.Read(ref factoryInvocationCount)); + } + + [Fact] + public async Task GivenConcurrentIgnixaCompileRequests_WhenCacheIsCold_ThenCompiledInstanceIsShared() + { + IFhirPathProvider provider = CreateIgnixaProvider(); + const int RequestCount = 8; + using var gate = new Barrier(RequestCount); + + System.Threading.Tasks.Task[] requests = Enumerable.Range(0, RequestCount) + .Select(_ => System.Threading.Tasks.Task.Factory.StartNew( + () => + { + if (!gate.SignalAndWait(TimeSpan.FromSeconds(30))) + { + throw new TimeoutException("Concurrent compile requests did not become ready."); + } + + return provider.Compile("Patient.id"); + }, + CancellationToken.None, + System.Threading.Tasks.TaskCreationOptions.LongRunning, + System.Threading.Tasks.TaskScheduler.Default)) + .ToArray(); + + ICompiledFhirPath[] compiledExpressions = await System.Threading.Tasks.Task.WhenAll(requests); + + Assert.All(compiledExpressions, compiled => Assert.Same(compiledExpressions[0], compiled)); + } + + [Fact] + public void GivenFirelyProvider_WhenHelpersEvaluate_ThenFirely5114BehaviorIsPreserved() + { + var patient = new Patient + { + Id = "patient-1", + Active = true, + Name = + [ + new HumanName { Family = "One" }, + new HumanName { Family = "Two" }, + ], + }.ToTypedElement(); + + FhirPathProvider.SetProviderFactory(static () => new FirelyFhirPathProvider()); + + Assert.Equal(2, patient.Select("name").Count()); + Assert.Null(patient.Scalar("{}")); + Assert.Throws(() => patient.Scalar("name")); + Assert.True(patient.Predicate("{}")); + Assert.False(patient.Predicate("active = false")); + Assert.True(patient.Predicate("'content'")); + Assert.False(patient.IsTrue("{}")); + Assert.True(patient.IsTrue("'content'")); + Assert.False(patient.IsBoolean("{}", false)); + Assert.True(patient.IsBoolean("active", true)); + + EvaluationContext context = ModelInfoProvider.Instance.GetEvaluationContext(); + context.Resource = patient; + Assert.Equal("patient-1", patient.Scalar("%resource.id", context)); + Assert.Equal("patient-1", patient.Scalar("%rootResource.id", context)); + Assert.NotNull(context.RootResource); + } + + [Fact] + public void GivenEitherProvider_WhenContextVariablesAndResolverEvaluate_ThenResultsMatch() + { + var patient = new Patient + { + Id = "patient-1", + BirthDate = "1970-01-01", + ManagingOrganization = new ResourceReference("Organization/org-1"), + }.ToTypedElement(); + var organization = new Organization { Id = "org-1" }.ToTypedElement(); + EvaluationContext context = ModelInfoProvider.Instance.GetEvaluationContext( + reference => reference == "Organization/org-1" ? organization : null); + context.Resource = patient; + context.RootResource = patient; + + IFhirPathProvider firely = new FirelyFhirPathProvider(); + IFhirPathProvider ignixa = CreateIgnixaProvider(); + string[] expressions = + [ + "%context.id", + "%resource.id", + "%rootResource.id", + "managingOrganization.resolve().id", + "birthDate < @2000-01-01", + ]; + + foreach (string expression in expressions) + { + object[] firelyValues = firely.Compile(expression).Select(patient, context).Select(x => x.Value).ToArray(); + object[] ignixaValues = ignixa.Compile(expression).Select(patient, context).Select(x => x.Value).ToArray(); + + Assert.Equal(firelyValues, ignixaValues); + } + } + + [Fact] + public void GivenEitherProvider_WhenCallerContextHasNoResource_ThenContextIsPopulated() + { + var patient = new Patient { Id = "patient-1" }.ToTypedElement(); + IFhirPathProvider[] providers = + [ + new FirelyFhirPathProvider(), + CreateIgnixaProvider(), + ]; + + foreach (IFhirPathProvider provider in providers) + { + EvaluationContext context = ModelInfoProvider.Instance.GetEvaluationContext(); + + string[] values = Normalize(provider.Compile("%resource.id | %rootResource.id").Select(patient, context)); + + Assert.NotNull(context.Resource); + Assert.NotNull(context.RootResource); + Assert.Equal("Patient", context.Resource.InstanceType); + Assert.Equal("Patient", context.RootResource.InstanceType); + Assert.Equal(["System.String|patient-1"], values); + } + } + + [Fact] + public async Task GivenVersionedResourceCorpus_WhenGeneratedAndResolverExpressionsAreEvaluated_ThenResultsMatch() + { + var fixture = new SearchParameterFixtureData(); + SearchParameterDefinitionManager definitions = await fixture.GetSearchDefinitionManagerAsync(); + IFhirPathProvider firely = new FirelyFhirPathProvider(); + IFhirPathProvider ignixa = CreateIgnixaProvider(); + int nonEmptyExpressionCount = 0; + int evaluatedExpressionCount = 0; + int resolveExpressionCount = 0; + int nonEmptyResolveExpressionCount = 0; + var resolver = new CorpusReferenceToElementResolver(); + + foreach (ResourceElement resource in GetResourceCorpus()) + { + EvaluationContext context = ModelInfoProvider.Instance.GetEvaluationContext(resolver.Resolve); + context.Resource = resource.Instance; + context.RootResource = resource.Instance; + string[] expressions = definitions.GetSearchParameters(resource.InstanceType) + .Where(parameter => parameter.IsSupported) + .Where(parameter => parameter.Code != SearchParameterNames.ResourceType) + .Select(parameter => parameter.Expression) + .Where(expression => !string.IsNullOrWhiteSpace(expression)) + .Concat( + resource.InstanceType == KnownResourceTypes.Patient + ? new[] { "managingOrganization.resolve().id" } + : Array.Empty()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + foreach (string expression in expressions) + { + evaluatedExpressionCount++; + string[] firelyValues = Normalize(firely.Compile(expression).Select(resource.Instance, context)); + string[] ignixaValues = Normalize(ignixa.Compile(expression).Select(resource.Instance, context)); + + Assert.Equal(firelyValues, ignixaValues); + + if (firelyValues.Length > 0) + { + nonEmptyExpressionCount++; + } + + if (expression.Contains("resolve()", StringComparison.Ordinal)) + { + resolveExpressionCount++; + nonEmptyResolveExpressionCount += firelyValues.Length > 0 ? 1 : 0; + } + } + } + + Assert.True(evaluatedExpressionCount >= 100, $"Expected at least 100 generated expressions, but evaluated {evaluatedExpressionCount}."); + Assert.True(nonEmptyExpressionCount >= 10, $"Expected at least 10 non-empty generated-expression results, but observed {nonEmptyExpressionCount}."); + Assert.True(resolveExpressionCount >= 2, $"Expected at least two resolve() corpus evaluations, but observed {resolveExpressionCount}."); + Assert.True(nonEmptyResolveExpressionCount >= 1, "The parity corpus must produce a non-empty resolve() result."); + } + + [Fact] + public async Task GivenEveryGeneratedSearchParameterExpression_WhenEvaluatedByBothProviders_ThenResultsMatch() + { + var fixture = new SearchParameterFixtureData(); + SearchParameterDefinitionManager definitions = await fixture.GetSearchDefinitionManagerAsync(); + IFhirPathProvider firely = new FirelyFhirPathProvider(); + IFhirPathProvider ignixa = CreateIgnixaProvider(); + ITypedElement input = new Patient { Id = "synthetic-patient" }.ToTypedElement(); + string[] parentExpressions = definitions.AllSearchParameters + .Where(parameter => parameter.Code != SearchParameterNames.ResourceType) + .Select(parameter => parameter.Expression) + .Where(expression => !string.IsNullOrWhiteSpace(expression)) + .Distinct(StringComparer.Ordinal) + .OrderBy(expression => expression, StringComparer.Ordinal) + .ToArray(); + string[] componentExpressions = definitions.AllSearchParameters + .Where(parameter => parameter.Code != SearchParameterNames.ResourceType) + .SelectMany(parameter => parameter.Component ?? Array.Empty()) + .Select(component => component.Expression) + .Where(expression => !string.IsNullOrWhiteSpace(expression)) + .Distinct(StringComparer.Ordinal) + .OrderBy(expression => expression, StringComparer.Ordinal) + .ToArray(); + + Assert.True(parentExpressions.Length >= 500, $"Expected at least 500 generated parent expressions, but evaluated {parentExpressions.Length}."); + Assert.NotEmpty(componentExpressions); + + string[] expressions = parentExpressions + .Concat(componentExpressions) + .Distinct(StringComparer.Ordinal) + .OrderBy(expression => expression, StringComparer.Ordinal) + .ToArray(); + + foreach (string expression in expressions) + { + EvaluationContext firelyContext = ModelInfoProvider.Instance.GetEvaluationContext(); + EvaluationContext ignixaContext = ModelInfoProvider.Instance.GetEvaluationContext(); + string[] firelyValues = Normalize(firely.Compile(expression).Select(input, firelyContext)); + string[] ignixaValues = Normalize(ignixa.Compile(expression).Select(input, ignixaContext)); + + Assert.Equal(firelyValues, ignixaValues); + } + } + + [Fact] + public void GivenRepositoryOwnedLiteralExpressions_WhenEvaluatedByBothProviders_ThenResultsMatch() + { + IFhirPathProvider firely = new FirelyFhirPathProvider(); + IFhirPathProvider ignixa = CreateIgnixaProvider(); + ITypedElement narrative = Samples.GetJsonSample("BasicExampleNarrative").Instance; + ITypedElement bundle = Samples.GetDefaultTransaction().Instance; + const string capabilityJson = + """{"resourceType":"CapabilityStatement","rest":[{"resource":[{"type":"Patient","versioning":"versioned-update","updateCreate":true,"readHistory":true,"interaction":[{"code":"read"}]}]}]}"""; + ITypedElement capability = ModelInfoProvider.Instance.ToTypedElement( + new RawResource(capabilityJson, FhirResourceFormat.Json, isMetaSet: false)); + const string immutableResourceJson = + """{"resourceType":"Patient","id":"patient-1","meta":{"lastUpdated":"2024-01-02T03:04:05Z","versionId":"1"},"text":{"status":"generated","div":"
Narrative
"}}"""; + ITypedElement immutableResource = ModelInfoProvider.Instance.ToTypedElement( + new RawResource(immutableResourceJson, FhirResourceFormat.Json, isMetaSet: false)); + ITypedElement address = new Address + { + City = "Seattle", + Country = "USA", + District = "King", + Line = ["1 Main Street"], + PostalCode = "98101", + State = "WA", + Text = "1 Main Street, Seattle", + }.ToTypedElement(); + (ITypedElement Input, string Expression)[] cases = + [ + (narrative, KnownFhirPaths.ResourceNarrative), + (bundle, KnownFhirPaths.BundleEntries), + (bundle, KnownFhirPaths.BundleType), + (bundle, KnownFhirPaths.BundleNextLink), + (bundle, KnownFhirPaths.BundleSelfLink), + (narrative, KnownFhirPaths.IsSoftDeletedExtension), + (capability, "CapabilityStatement.rest.resource.where(type = 'Patient').interaction.where(code = 'read').exists()"), + (capability, "CapabilityStatement.rest.resource.where(type = 'Patient').where(versioning = 'versioned-update').exists()"), + (capability, "CapabilityStatement.rest.resource.where(type = 'Patient').updateCreate = true"), + (capability, "CapabilityStatement.rest.resource.where(type = 'Patient').readHistory"), + (address, "city"), + (address, "country"), + (address, "district"), + (address, "line"), + (address, "postalCode"), + (address, "state"), + (address, "text"), + ]; + int nonEmptyResultCount = 0; + + foreach ((ITypedElement input, string expression) in cases) + { + string[] firelyValues = Normalize(firely.Compile(expression).Select(input)); + string[] ignixaValues = Normalize(ignixa.Compile(expression).Select(input)); + + Assert.Equal(firelyValues, ignixaValues); + if (expression == KnownFhirPaths.ResourceNarrative) + { + Assert.NotEmpty(firelyValues); + } + + nonEmptyResultCount += firelyValues.Length > 0 ? 1 : 0; + } + + foreach (string expression in PatchPayload.ImmutableProperties) + { + string[] firelyValues = Normalize(firely.Compile(expression).Select(immutableResource)); + string[] ignixaValues = Normalize(ignixa.Compile(expression).Select(immutableResource)); + + Assert.Equal(firelyValues, ignixaValues); + Assert.NotEmpty(firelyValues); + } + + Assert.True(nonEmptyResultCount >= 10, $"Expected at least 10 non-empty literal-expression results, but observed {nonEmptyResultCount}."); + } + + [Fact] + public async Task GivenResourceCorpus_WhenIndexedByBothProviders_ThenSearchIndexEntriesMatch() + { + var fixture = new SearchParameterFixtureData(); + SearchParameterDefinitionManager definitions = await fixture.GetSearchDefinitionManagerAsync(); + var supportedDefinitions = new SupportedSearchParameterDefinitionManager(definitions); + var converters = await SearchParameterFixtureData.GetFhirTypedElementToSearchValueConverterManagerAsync(); + var resolver = new CorpusReferenceToElementResolver(); + var firelyFailures = Substitute.For(); + var ignixaFailures = Substitute.For(); + IFhirPathProvider firelyProvider = new FirelyFhirPathProvider(); + IFhirPathProvider ignixaProvider = CreateIgnixaProvider(); + var firelyIndexer = new TypedElementSearchIndexer( + supportedDefinitions, + converters, + resolver, + ModelInfoProvider.Instance, + firelyProvider, + NullLogger.Instance, + firelyFailures); + var ignixaIndexer = new TypedElementSearchIndexer( + supportedDefinitions, + converters, + resolver, + ModelInfoProvider.Instance, + ignixaProvider, + NullLogger.Instance, + ignixaFailures); + var firelyEntriesByResourceType = new Dictionary>(StringComparer.Ordinal); + int totalFirelyEntryCount = 0; + try + { + foreach (ResourceElement resource in GetResourceCorpus()) + { + FhirPathProvider.SetProviderFactory(() => firelyProvider); + IReadOnlyCollection firelyEntries = firelyIndexer.Extract(resource); + + FhirPathProvider.SetProviderFactory(() => ignixaProvider); + IReadOnlyCollection ignixaEntries = ignixaIndexer.Extract(resource); + firelyEntriesByResourceType.TryAdd(resource.InstanceType, firelyEntries); + totalFirelyEntryCount += firelyEntries.Count; + + string[] firelyValues = firelyEntries.Select(Normalize).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + string[] ignixaValues = ignixaEntries.Select(Normalize).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + + Assert.Equal(firelyValues, ignixaValues); + } + } + finally + { + FhirPathProvider.SetProviderFactory(() => _originalAmbientProvider); + } + + firelyFailures.DidNotReceive().EmitException(Arg.Any()); + ignixaFailures.DidNotReceive().EmitException(Arg.Any()); + Assert.True( + totalFirelyEntryCount >= 10, + "The index parity corpus must produce at least 10 search index entries."); + Assert.Contains( + firelyEntriesByResourceType[KnownResourceTypes.Patient], + entry => entry.SearchParameter.Code == "name" && + entry.Value is StringSearchValue value && + value.String == "Chalmers"); + Assert.Contains( + firelyEntriesByResourceType[KnownResourceTypes.Observation], + entry => entry.SearchParameter.Code == "code" && + entry.Value is TokenSearchValue value && + value.System == "http://loinc.org" && + value.Code == "29463-7"); + } + + private static IFhirPathProvider CreateIgnixaProvider() + => new IgnixaFhirPathProvider(new IgnixaSchemaContext(ModelInfoProvider.Instance)); + + private static ResourceElement[] GetResourceCorpus() + => + [ + Samples.GetDefaultPatient(), + Samples.GetDefaultOrganization(), + Samples.GetDefaultObservation(), + Samples.GetDefaultCoverage(), + Samples.GetDefaultPractitioner(), + Samples.GetDefaultMedication(), + new Patient + { + Id = "patient-with-reference", + ManagingOrganization = new ResourceReference("Organization/org-1"), + }.ToResourceElement(), + ]; + + private static string[] Normalize(IEnumerable elements) + => elements + .Select(element => $"{element.Value?.GetType().FullName}|{element.Value}") + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + + private static string Normalize(SearchIndexEntry entry) + => $"{entry.SearchParameter.Url}|{entry.SearchParameter.Code}|{JsonConvert.SerializeObject(entry.Value)}"; + + public void Dispose() + => FhirPathProvider.SetProviderFactory(() => _originalAmbientProvider); + + private sealed class CorpusReferenceToElementResolver : IReferenceToElementResolver + { + public ITypedElement Resolve(string reference) + { + if (string.IsNullOrWhiteSpace(reference)) + { + return null; + } + + string[] parts = reference.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 || !ModelInfoProvider.Instance.IsKnownResource(parts[^2])) + { + return null; + } + + ISourceNode node = FhirJsonNode.Create( + JObject.FromObject( + new + { + resourceType = parts[^2], + id = parts[^1], + })); + + return node.ToTypedElement(ModelInfoProvider.Instance.StructureDefinitionSummaryProvider); + } + } + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/LightweightReferenceToElementResolverTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/LightweightReferenceToElementResolverTests.cs index c940128443..3509c31bd1 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/LightweightReferenceToElementResolverTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/LightweightReferenceToElementResolverTests.cs @@ -41,7 +41,7 @@ public LightweightReferenceToElementResolverTests() { ElementResolver = _resolver.Resolve, }; - FhirPathCompiler.DefaultSymbolTable.AddFhirExtensions(); + ElementNavFhirExtensions.PrepareFhirSymbolTableFunctions(); } [InlineData("Patient/1234")] diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchParameters/SearchParameterFixtureData.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchParameters/SearchParameterFixtureData.cs index d2a3b2d60d..ddd47b672c 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchParameters/SearchParameterFixtureData.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchParameters/SearchParameterFixtureData.cs @@ -38,7 +38,7 @@ public class SearchParameterFixtureData static SearchParameterFixtureData() { - FhirPathCompiler.DefaultSymbolTable.AddFhirExtensions(); + ElementNavFhirExtensions.PrepareFhirSymbolTableFunctions(); } public static FhirPathCompiler Compiler { get; } = new FhirPathCompiler(); diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/TypedElementSearchIndexerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/TypedElementSearchIndexerTests.cs index 5a6bb0d69f..90d0dbb0b4 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/TypedElementSearchIndexerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/TypedElementSearchIndexerTests.cs @@ -13,10 +13,12 @@ using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Definition; using Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Features.Search.Converters; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; +using Microsoft.Health.Fhir.Core.Logging.Metrics; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.Tests.Common; using Microsoft.Health.Test.Utilities; @@ -51,7 +53,14 @@ public TypedElementSearchIndexerTests() var modelInfoProvider = ModelInfoProvider.Instance; var logger = Substitute.For>(); - _searchIndexer = new TypedElementSearchIndexer(supportedSearchParameterDefinitionManager, typedElementToSearchValueConverterManager, referenceToElementResolver, modelInfoProvider, logger); + _searchIndexer = new TypedElementSearchIndexer( + supportedSearchParameterDefinitionManager, + typedElementToSearchValueConverterManager, + referenceToElementResolver, + modelInfoProvider, + new FirelyFhirPathProvider(), + logger, + Substitute.For()); List baseResourceTypes = new List() { "Resource" }; List targetResourceTypes = new List() { "Coverage", "Observation", "Claim", "Patient" }; @@ -86,6 +95,205 @@ public void GivenAValidResource_WhenExtract_ThenValidSearchIndexEntriesAreCreate Assert.True(coverageResource.Status.Value.ToString().Equals(tokenSearchValue.Code, StringComparison.CurrentCultureIgnoreCase)); } + [Fact] + public void GivenFhirPathEvaluationFailure_WhenExtract_ThenFailureIsReportedAsMetric() + { + var definitions = Substitute.For(); + definitions.GetSearchParameters("Patient").Returns( + [ + new SearchParameterInfo( + "name", + "name", + (ValueSets.SearchParamType)SearchParamType.String, + new Uri(ResourceName), + expression: "Patient.name"), + ]); + var compiledExpression = Substitute.For(); + compiledExpression + .Select(Arg.Any(), Arg.Any()) + .Returns(_ => throw new NotSupportedException("Unsupported expression.")); + var provider = Substitute.For(); + provider.Compile("Patient.name").Returns(compiledExpression); + var metricHandler = Substitute.For(); + var indexer = new TypedElementSearchIndexer( + definitions, + Substitute.For(), + Substitute.For(), + ModelInfoProvider.Instance, + provider, + Substitute.For>(), + metricHandler); + ResourceElement patient = new Patient { Id = "patient-1" }.ToResourceElement(); + + IReadOnlyCollection entries = indexer.Extract(patient); + + Assert.Empty(entries); + metricHandler.Received(1).EmitException( + Arg.Is( + notification => notification.OperationName == "FhirPathSearchIndexEvaluation" && + notification.ExceptionType == nameof(NotSupportedException))); + } + + [Fact] + public void GivenReferenceTargetFilterEvaluationFailure_WhenExtract_ThenFailureIsReportedAsMetric() + { + var definitions = Substitute.For(); + definitions.GetSearchParameters("Patient").Returns( + [ + new SearchParameterInfo( + "organization", + "organization", + (ValueSets.SearchParamType)SearchParamType.Reference, + new Uri("http://hl7.org/fhir/SearchParameter/Patient-organization"), + expression: "Patient.managingOrganization", + targetResourceTypes: ["Organization"]), + ]); + var referenceExpression = Substitute.For(); + referenceExpression + .Select(Arg.Any(), Arg.Any()) + .Returns(_ => throw new NotSupportedException("Unsupported expression.")); + var resourceReference = Substitute.For(); + resourceReference.InstanceType.Returns("ResourceReference"); + var mainExpression = Substitute.For(); + mainExpression + .Select(Arg.Any(), Arg.Any()) + .Returns([resourceReference]); + var provider = Substitute.For(); + provider.Compile("Patient.managingOrganization").Returns(mainExpression); + provider.Compile("reference").Returns(referenceExpression); + var metricHandler = Substitute.For(); + var indexer = new TypedElementSearchIndexer( + definitions, + Substitute.For(), + Substitute.For(), + ModelInfoProvider.Instance, + provider, + Substitute.For>(), + metricHandler); + ResourceElement patient = new Patient + { + Id = "patient-1", + ManagingOrganization = new ResourceReference("Organization/organization-1"), + }.ToResourceElement(); + + IReadOnlyCollection entries = indexer.Extract(patient); + + Assert.Empty(entries); + metricHandler.Received(1).EmitException( + Arg.Is( + notification => notification.OperationName == "FhirPathSearchIndexEvaluation" && + notification.ExceptionType == nameof(NotSupportedException))); + } + + [Fact] + public void GivenCompositeRootFhirPathEvaluationFailure_WhenExtract_ThenFailureIsReportedAsMetric() + { + var definitions = Substitute.For(); + definitions.GetSearchParameters("Patient").Returns( + [ + new SearchParameterInfo( + "composite", + "composite", + (ValueSets.SearchParamType)SearchParamType.Composite, + new Uri("http://hl7.org/fhir/SearchParameter/composite"), + components: Array.Empty(), + expression: "Patient.name"), + ]); + var compiledExpression = Substitute.For(); + compiledExpression + .Select(Arg.Any(), Arg.Any()) + .Returns(_ => throw new NotSupportedException("Unsupported expression.")); + var provider = Substitute.For(); + provider.Compile("Patient.name").Returns(compiledExpression); + var metricHandler = Substitute.For(); + var indexer = new TypedElementSearchIndexer( + definitions, + Substitute.For(), + Substitute.For(), + ModelInfoProvider.Instance, + provider, + Substitute.For>(), + metricHandler); + ResourceElement patient = new Patient { Id = "patient-1" }.ToResourceElement(); + + IReadOnlyCollection entries = indexer.Extract(patient); + + Assert.Empty(entries); + metricHandler.Received(1).EmitException( + Arg.Is( + notification => notification.OperationName == "FhirPathSearchIndexEvaluation" && + notification.ExceptionType == nameof(NotSupportedException))); + } + + [Fact] + public void GivenFhirPathEvaluationCancellation_WhenExtract_ThenCancellationIsRethrown() + { + var definitions = Substitute.For(); + definitions.GetSearchParameters("Patient").Returns( + [ + new SearchParameterInfo( + "name", + "name", + (ValueSets.SearchParamType)SearchParamType.String, + new Uri(ResourceName), + expression: "Patient.name"), + ]); + var compiledExpression = Substitute.For(); + compiledExpression + .Select(Arg.Any(), Arg.Any()) + .Returns(_ => throw new OperationCanceledException()); + var provider = Substitute.For(); + provider.Compile("Patient.name").Returns(compiledExpression); + var metricHandler = Substitute.For(); + var indexer = new TypedElementSearchIndexer( + definitions, + Substitute.For(), + Substitute.For(), + ModelInfoProvider.Instance, + provider, + Substitute.For>(), + metricHandler); + ResourceElement patient = new Patient { Id = "patient-1" }.ToResourceElement(); + + Assert.Throws(() => indexer.Extract(patient)); + metricHandler.DidNotReceive().EmitException(Arg.Any()); + } + + [Fact] + public void GivenRepeatedExtraction_WhenExpressionsAreCompiled_ThenCachingIsDelegatedToProvider() + { + var definitions = Substitute.For(); + definitions.GetSearchParameters("Patient").Returns( + [ + new SearchParameterInfo( + "name", + "name", + (ValueSets.SearchParamType)SearchParamType.String, + new Uri(ResourceName), + expression: "Patient.name"), + ]); + var compiledExpression = Substitute.For(); + compiledExpression + .Select(Arg.Any(), Arg.Any()) + .Returns([]); + var provider = Substitute.For(); + provider.Compile("Patient.name").Returns(compiledExpression); + var indexer = new TypedElementSearchIndexer( + definitions, + Substitute.For(), + Substitute.For(), + ModelInfoProvider.Instance, + provider, + Substitute.For>(), + Substitute.For()); + ResourceElement patient = new Patient { Id = "patient-1" }.ToResourceElement(); + + indexer.Extract(patient); + indexer.Extract(patient); + + provider.Received(2).Compile("Patient.name"); + } + [Fact] public void GivenAValidResourceWithDuplicateSearchIndices_WhenExtract_ThenDistincSearchIndexEntriesAreCreated() { diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Validation/Narratives/NarrativeValidatorTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Validation/Narratives/NarrativeValidatorTests.cs index c5364cc585..49c41057ae 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Validation/Narratives/NarrativeValidatorTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Validation/Narratives/NarrativeValidatorTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using Microsoft.Health.Fhir.Core.Configs; using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features.Validation; using Microsoft.Health.Fhir.Core.Features.Validation.Narratives; using Microsoft.Health.Fhir.Tests.Common; using Microsoft.Health.Test.Utilities; @@ -38,6 +39,9 @@ public void GivenAnInvalidNarrative_WhenProcessingAResource_ThenAValidationMessa var result = _validator.Validate(instanceToValidate); Assert.False(result.IsValid); + FhirValidationFailure failure = Assert.IsType(Assert.Single(result.Errors)); + Assert.Equal("Observation.text.div", failure.PropertyName); + Assert.Equal(["Observation.text.div"], failure.IssueComponent.Expression); } [Theory] diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems index 46384b017e..22558aad60 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems @@ -21,6 +21,8 @@ + + diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/BulkUpdateService.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/BulkUpdateService.cs index 27b038e25c..bd8c89c28d 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/BulkUpdateService.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Upsert/BulkUpdateService.cs @@ -480,7 +480,7 @@ private static void ApplyPatchToResources( || ex.Message.StartsWith("Invalid input for", StringComparison.OrdinalIgnoreCase) || ex.Message.StartsWith("While building a POCO:", StringComparison.OrdinalIgnoreCase)) { - // Core.Resources.PatchImmutablePropertiesIsNotValid => PatchPayload.ImmutableProperties "Resource.id", "Resource.meta.lastUpdated", "Resource.meta.versionId", "Resource.text.div", "Resource.text.status" + // Core.Resources.PatchImmutablePropertiesIsNotValid => PatchPayload.ImmutableProperties "Resource.id", "Resource.meta.lastUpdated", "Resource.meta.versionId", "Resource.text.`div`", "Resource.text.status" // Invalid input for path => patient.birthdate, value=not-a-date // While building a POCO: => path=patient.gender, value=not-a-gender // Remember the error for this resource type and skip processing the entire group. diff --git a/src/Microsoft.Health.Fhir.Shared.Core/VersionSpecificModelInfoProvider.cs b/src/Microsoft.Health.Fhir.Shared.Core/VersionSpecificModelInfoProvider.cs index 125c3468d2..60cbeddaac 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/VersionSpecificModelInfoProvider.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/VersionSpecificModelInfoProvider.cs @@ -9,15 +9,15 @@ using System.Linq; using EnsureThat; using Hl7.Fhir.ElementModel; -using Hl7.Fhir.FhirPath; using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; using Hl7.Fhir.Specification; -using Hl7.FhirPath; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Features.Validation; using Microsoft.Health.Fhir.Core.Models; using Newtonsoft.Json; +using EvaluationContext = Hl7.FhirPath.EvaluationContext; +using FhirEvaluationContext = Hl7.Fhir.FhirPath.FhirEvaluationContext; namespace Microsoft.Health.Fhir.Core { diff --git a/src/Microsoft.Health.Fhir.Shared.Web/appsettings.json b/src/Microsoft.Health.Fhir.Shared.Web/appsettings.json index 8ef4b04cc4..cdf3bcd3f3 100644 --- a/src/Microsoft.Health.Fhir.Shared.Web/appsettings.json +++ b/src/Microsoft.Health.Fhir.Shared.Web/appsettings.json @@ -22,7 +22,9 @@ "SupportsAnonymizedExport": true }, "CoreFeatures": { - "FhirSdkProvider": "Firely", + "FhirSdkProvider": { + "Default": "Firely" + }, "SupportsBatch": true, "SupportsTransaction": true, "SupportsSelectableSearchParameters": true, diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchSharedFixture.cs b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchSharedFixture.cs index 47e49d3b2e..a20125efb1 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchSharedFixture.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchSharedFixture.cs @@ -15,11 +15,13 @@ using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Context; using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Features.Search.Converters; using Microsoft.Health.Fhir.Core.Features.Search.Registry; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; +using Microsoft.Health.Fhir.Core.Logging.Metrics; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.Core.UnitTests.Extensions; using Microsoft.Health.Fhir.Tests.Common; @@ -64,7 +66,9 @@ public async Task InitializeAsync() typedElementToSearchValueConverterManager, Substitute.For(), ModelInfoProvider.Instance, - NullLogger.Instance); + new FirelyFhirPathProvider(), + NullLogger.Instance, + Substitute.For()); _searchParameterDefinitionManager = _fixture.SearchParameterDefinitionManager; _scopedDataStore = _fixture.DataStore.CreateMockScope(); diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchTests.cs b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchTests.cs index 382dbee003..d746d20c73 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchTests.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Features/Smart/SmartSearchTests.cs @@ -726,7 +726,24 @@ public async Task GivenFhirUserClaimPatient_WhenAllResourcesRequested_UniversalR Assert.Contains(results.Results, r => r.Resource.ResourceTypeName == KnownResourceTypes.Location); Assert.Contains(results.Results, r => r.Resource.ResourceTypeName == KnownResourceTypes.Practitioner); Assert.Contains(results.Results, r => r.Resource.ResourceTypeName == KnownResourceTypes.Device); - Assert.Equal(40, results.Results.Count()); + + Assert.Contains(results.Results, r => r.Resource.ResourceId == "smart-patient-A"); + + // A direct legacy/provider comparison found six resources with changed patient-parameter output, + // but only this Immunization changed Patient A compartment membership. The provider path therefore + // adds exactly this resource to the previous 40-result set. + Assert.Contains( + results.Results, + r => r.Resource.ResourceTypeName == KnownResourceTypes.Immunization && + r.Resource.ResourceId == "smart-immunization-A1"); + Assert.Contains(results.Results, r => r.Resource.ResourceId == "smart-device-A1"); + Assert.Contains(results.Results, r => r.Resource.ResourceId == "smart-device-B1"); + Assert.Contains(results.Results, r => r.Resource.ResourceId == "smart-device-C1"); + Assert.DoesNotContain(results.Results, r => r.Resource.ResourceId == "smart-patient-B"); + Assert.DoesNotContain(results.Results, r => r.Resource.ResourceId == "smart-patient-C"); + Assert.DoesNotContain(results.Results, r => r.Resource.ResourceId == "smart-patient-D"); + Assert.DoesNotContain(results.Results, r => r.Resource.ResourceId == "smart-device-B2"); + Assert.Equal(41, results.Results.Count()); } [SkippableFact] diff --git a/tools/Microsoft.Health.Fhir.R4.ResourceParser/Code/MinimalSearchParameterDefinitionBuilder.cs b/tools/Microsoft.Health.Fhir.R4.ResourceParser/Code/MinimalSearchParameterDefinitionBuilder.cs index ab2210557b..9affc014a8 100644 --- a/tools/Microsoft.Health.Fhir.R4.ResourceParser/Code/MinimalSearchParameterDefinitionBuilder.cs +++ b/tools/Microsoft.Health.Fhir.R4.ResourceParser/Code/MinimalSearchParameterDefinitionBuilder.cs @@ -10,10 +10,10 @@ using EnsureThat; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Utility; -using Hl7.FhirPath; using Microsoft.Health.Fhir.Core.Data; using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.ValueSets; diff --git a/tools/Microsoft.Health.Fhir.R4.ResourceParser/ResourceWrapperParser.cs b/tools/Microsoft.Health.Fhir.R4.ResourceParser/ResourceWrapperParser.cs index f20c7e3c4b..75745778bc 100644 --- a/tools/Microsoft.Health.Fhir.R4.ResourceParser/ResourceWrapperParser.cs +++ b/tools/Microsoft.Health.Fhir.R4.ResourceParser/ResourceWrapperParser.cs @@ -3,6 +3,7 @@ // Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. // ------------------------------------------------------------------------------------------------- +using System.Diagnostics.Metrics; using Hl7.Fhir.ElementModel; using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; @@ -13,10 +14,12 @@ using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Context; using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.Core.Features.FhirPath; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Features.Search.Converters; using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; +using Microsoft.Health.Fhir.Core.Logging.Metrics.Handlers; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.R4.ResourceParser.Code; @@ -24,6 +27,7 @@ namespace Microsoft.Health.Fhir.R4.ResourceParser { public class ResourceWrapperParser { + private static readonly ResourceParserMeterFactory MeterFactory = new(); private ResourceWrapperFactory _resourceWrapperFactory; private FhirJsonParser _fhirJsonParser; private FhirJsonSerializer _fhirJsonSerializer; @@ -48,7 +52,14 @@ public ResourceWrapperParser() var referenceToElementResolver = new LightweightReferenceToElementResolver(referenceSearchValueParser, modelInfoProvider); var logger = new NullLogger(); - var searchIndexer = new TypedElementSearchIndexer(supportedSearchParameterDefinitionManager, fhirTypedElementToSearchValueConverterManager, referenceToElementResolver, modelInfoProvider, logger); + var searchIndexer = new TypedElementSearchIndexer( + supportedSearchParameterDefinitionManager, + fhirTypedElementToSearchValueConverterManager, + referenceToElementResolver, + modelInfoProvider, + new FirelyFhirPathProvider(), + logger, + new DefaultFailureMetricHandler(MeterFactory)); var compartmentDefinitionManager = new CompartmentDefinitionManager(modelInfoProvider); @@ -127,5 +138,14 @@ private static List MakeConverters(RequestC return fhirTypedElementConverters; } + + private sealed class ResourceParserMeterFactory : IMeterFactory + { + public Meter Create(MeterOptions options) => new(options); + + public void Dispose() + { + } + } } }