From 87c20168c75f4ec1b31d29035c7e269ca07288b8 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Tue, 19 May 2026 11:08:25 -0300 Subject: [PATCH 1/2] feat(ocl): auto-register factories for OCL sources discovered post-startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New OCL sources created after the server starts were silently falling back to FhirCodeSystemProvider, which reads the raw 'content' field and returns contentMode() = 'not-present', causing ValueSet expansions to fail with "The code system definition has no content". Root cause: the 60-second refresh cycle detected new sources via getCodeSystemChanges() and called addCodeSystem(), but never created an OCLSourceCodeSystemFactory — so getCodeSystemProvider() never found a factory and fell through to the raw CodeSystem. Fix (entirely within tx/ocl/): - OCLSourceCodeSystemFactory stores the first i18n instance in a static field (#sharedI18n) so discovery-time factory creation can reuse it without requiring library.js to pass i18n again - Added static createForDiscoveredSource(httpClient, meta) that creates and auto-registers a factory for a newly detected source; factories self-register in the static factoriesByKey map on construction - #scheduleRefresh now calls createForDiscoveredSource for each entry in changes.added that does not yet have a factory - patchProviderForOCLFactorySync (new patch in shared/patches.cjs) monkey-patches Provider.prototype.updateCodeSystemList to sync any factory in factoriesByKey that is not yet in provider.codeSystemFactories, following the same idiomatic pattern used for SearchWorker and ValueSetExpander patches No files outside tx/ocl/ were modified. Co-Authored-By: Claude Sonnet 4.6 --- tests/ocl/ocl-factory-discovery.test.js | 325 ++++++++++++++++++++++++ tx/ocl/cs-ocl.cjs | 24 +- tx/ocl/shared/patches.cjs | 49 +++- 3 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 tests/ocl/ocl-factory-discovery.test.js diff --git a/tests/ocl/ocl-factory-discovery.test.js b/tests/ocl/ocl-factory-discovery.test.js new file mode 100644 index 00000000..6358bb44 --- /dev/null +++ b/tests/ocl/ocl-factory-discovery.test.js @@ -0,0 +1,325 @@ +'use strict'; + +const { OCLCodeSystemProvider, OCLSourceCodeSystemFactory } = require('../../tx/ocl/cs-ocl'); +const { Provider } = require('../../tx/provider'); +const { TestUtilities } = require('../test-utilities'); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeHttpClient() { + return { get: jest.fn().mockRejectedValue(new Error('not mocked')) }; +} + +function makeFactoryMeta(overrides = {}) { + const canonicalUrl = overrides.canonicalUrl ?? 'https://example.org/fhir/CodeSystem/TestSource'; + return { + canonicalUrl, + version: 'HEAD', + id: 'TestSource', + shortCode: 'TestSource', + owner: 'TestOrg', + name: 'TestSource', + description: null, + conceptsUrl: '/orgs/TestOrg/sources/TestSource/concepts/', + checksum: 'abc123', + codeSystem: { + jsonObj: { content: 'not-present', url: canonicalUrl } + }, + ...overrides + }; +} + +/** Create a minimal Provider-like instance ready for updateCodeSystemList calls */ +function makeProvider(extraFactories = new Map(), mockChanges = {}) { + const provider = new Provider(); + provider.codeSystemFactories = new Map(extraFactories); + provider.codeSystems = new Map(); + provider.codeSystemProviders = [{ + getCodeSystemChanges: jest.fn().mockResolvedValue({ + added: [], + changed: [], + deleted: [], + ...mockChanges + }) + }]; + provider.fhirVersion = '4.0.0'; + provider.context = null; + return provider; +} + +// ─── test setup / teardown ──────────────────────────────────────────────────── + +let i18n; + +beforeAll(async () => { + i18n = await TestUtilities.loadTranslations(); +}); + +afterEach(() => { + // Limpa o mapa estático de factories para isolar os testes + OCLSourceCodeSystemFactory.factoriesByKey.clear(); + jest.restoreAllMocks(); +}); + +// ─── OCLSourceCodeSystemFactory.createForDiscoveredSource ──────────────────── + +describe('OCLSourceCodeSystemFactory.createForDiscoveredSource', () => { + it('retorna null antes de qualquer factory ser construída (sem #sharedI18n)', () => { + // Módulo isolado garante que #sharedI18n começa como null + let resultado; + jest.isolateModules(() => { + const { OCLSourceCodeSystemFactory: Fresh } = require('../../tx/ocl/cs-ocl'); + resultado = Fresh.createForDiscoveredSource(makeHttpClient(), makeFactoryMeta()); + }); + expect(resultado).toBeNull(); + }); + + it('retorna uma instância de OCLSourceCodeSystemFactory após #sharedI18n ser inicializado', () => { + // Primeira construção inicializa #sharedI18n + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/Init' })); + + const meta = makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/Discovered' }); + const factory = OCLSourceCodeSystemFactory.createForDiscoveredSource(makeHttpClient(), meta); + + expect(factory).not.toBeNull(); + expect(factory).toBeInstanceOf(OCLSourceCodeSystemFactory); + }); + + it('factory criada tem o system URL correto do meta', () => { + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/Init2' })); + + const canonicalUrl = 'https://mangara.hsl.org.br/fhir/CodeSystem/LocationColposcopia'; + const factory = OCLSourceCodeSystemFactory.createForDiscoveredSource( + makeHttpClient(), + makeFactoryMeta({ canonicalUrl }) + ); + + expect(factory.system()).toBe(canonicalUrl); + }); + + it('factory criada tem a versão correta do meta', () => { + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/Init3' })); + + const factory = OCLSourceCodeSystemFactory.createForDiscoveredSource( + makeHttpClient(), + makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/VersionTest', version: '2.0.0' }) + ); + + expect(factory.version()).toBe('2.0.0'); + }); +}); + +// ─── Auto-registro em factoriesByKey ───────────────────────────────────────── + +describe('OCLSourceCodeSystemFactory - auto-registro em factoriesByKey', () => { + it('nova factory via construtor registra-se automaticamente em factoriesByKey', () => { + const canonicalUrl = 'https://example.org/CodeSystem/AutoReg'; + expect(OCLSourceCodeSystemFactory.hasFactory(canonicalUrl)).toBe(false); + + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl })); + + expect(OCLSourceCodeSystemFactory.hasFactory(canonicalUrl)).toBe(true); + }); + + it('factory criada via createForDiscoveredSource também se registra em factoriesByKey', () => { + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/InitDisc' })); + + const canonicalUrl = 'https://example.org/CodeSystem/DiscoveryReg'; + expect(OCLSourceCodeSystemFactory.hasFactory(canonicalUrl)).toBe(false); + + OCLSourceCodeSystemFactory.createForDiscoveredSource(makeHttpClient(), makeFactoryMeta({ canonicalUrl })); + + expect(OCLSourceCodeSystemFactory.hasFactory(canonicalUrl)).toBe(true); + }); + + it('não cria duplicatas no factoriesByKey quando a mesma URL é usada', () => { + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/InitDup' })); + + const canonicalUrl = 'https://example.org/CodeSystem/Duplicate'; + OCLSourceCodeSystemFactory.createForDiscoveredSource(makeHttpClient(), makeFactoryMeta({ canonicalUrl })); + + const beforeSize = OCLSourceCodeSystemFactory.factoriesByKey.size; + + // Segunda chamada com mesma URL - já existe + OCLSourceCodeSystemFactory.createForDiscoveredSource(makeHttpClient(), makeFactoryMeta({ canonicalUrl })); + + expect(OCLSourceCodeSystemFactory.factoriesByKey.size).toBe(beforeSize); + }); +}); + +// ─── patchProviderForOCLFactorySync via updateCodeSystemList ───────────────── + +describe('patchProviderForOCLFactorySync — sincronização de factories', () => { + it('factory recém-adicionada em factoriesByKey é sincronizada para provider.codeSystemFactories', async () => { + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/InitSync' })); + + const canonicalUrl = 'https://example.org/CodeSystem/NewInRefresh'; + OCLSourceCodeSystemFactory.createForDiscoveredSource(makeHttpClient(), makeFactoryMeta({ canonicalUrl })); + + const provider = makeProvider(); + expect(provider.codeSystemFactories.has(canonicalUrl)).toBe(false); + + await provider.updateCodeSystemList(); + + expect(provider.codeSystemFactories.has(canonicalUrl)).toBe(true); + }); + + it('factory já registrada em codeSystemFactories não é re-adicionada', async () => { + const initFactory = new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/InitNoRe' })); + + const canonicalUrl = 'https://example.org/CodeSystem/AlreadyThere'; + const factory = OCLSourceCodeSystemFactory.createForDiscoveredSource( + makeHttpClient(), + makeFactoryMeta({ canonicalUrl }) + ); + + // Pre-register all factories that exist in factoriesByKey so none are "new" + const initUrl = 'https://example.org/CodeSystem/InitNoRe'; + const provider = makeProvider(new Map([ + [initUrl, initFactory], + [`${initUrl}|HEAD`, initFactory], + [canonicalUrl, factory], + [`${canonicalUrl}|HEAD`, factory] + ])); + + const sizeBefore = provider.codeSystemFactories.size; + await provider.updateCodeSystemList(); + + expect(provider.codeSystemFactories.size).toBe(sizeBefore); + }); + + it('múltiplas factories novas são todas sincronizadas', async () => { + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/InitMulti' })); + + const urls = [ + 'https://example.org/CodeSystem/Multi1', + 'https://example.org/CodeSystem/Multi2', + 'https://example.org/CodeSystem/Multi3' + ]; + + for (const canonicalUrl of urls) { + OCLSourceCodeSystemFactory.createForDiscoveredSource(makeHttpClient(), makeFactoryMeta({ canonicalUrl })); + } + + const provider = makeProvider(); + await provider.updateCodeSystemList(); + + for (const canonicalUrl of urls) { + expect(provider.codeSystemFactories.has(canonicalUrl)).toBe(true); + } + }); + + it('updateCodeSystemList sem factories OCL novas não altera codeSystemFactories', async () => { + // Nenhuma factory no factoriesByKey + const provider = makeProvider(); + const sizeBefore = provider.codeSystemFactories.size; + + await provider.updateCodeSystemList(); + + expect(provider.codeSystemFactories.size).toBe(sizeBefore); + }); +}); + +// ─── Ciclo de refresh do OCLCodeSystemProvider ──────────────────────────────── + +describe('OCLCodeSystemProvider — criação de factory no ciclo de refresh', () => { + it('chama createForDiscoveredSource para source novo em changes.added', async () => { + // Garante #sharedI18n inicializado + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/InitRef' })); + + const spy = jest.spyOn(OCLSourceCodeSystemFactory, 'createForDiscoveredSource'); + + const canonicalUrl = 'https://mangara.hsl.org.br/fhir/CodeSystem/NewSource'; + const mockSource = { + id: 'NewSource', + short_code: 'NewSource', + owner: 'HSL', + owner_type: 'Organization', + url: '/orgs/HSL/sources/NewSource/', + concepts_url: '/orgs/HSL/sources/NewSource/concepts/', + canonical_url: canonicalUrl, + version: 'HEAD', + checksums: { standard: 'c1', smart: 'c2' }, + updated_at: '2026-05-19T00:00:00Z' + }; + + const oclProvider = new OCLCodeSystemProvider({ baseUrl: 'https://oclapi2.example.org', org: 'HSL' }); + oclProvider.httpClient = { + get: jest.fn().mockImplementation((url) => { + // /orgs/ → lista de organizações + if (/\/orgs\/$/.test(url)) { + return Promise.resolve({ data: [{ id: 'HSL', url: '/orgs/HSL/', type: 'Organization' }] }); + } + // /orgs/HSL/ → detalhes da org (não usado na descoberta de sources) + if (/\/orgs\/HSL\/$/.test(url) && !url.includes('/sources')) { + return Promise.resolve({ data: { id: 'HSL' } }); + } + // sources da org + if (url.includes('/sources/') && !url.includes('/concepts/')) { + return Promise.resolve({ data: { results: [mockSource], num_found: 1, num_returned: 1 } }); + } + return Promise.resolve({ data: [] }); + }) + }; + + // Inicializa (sem sources na snapshot inicial — simula state vazio) + await oclProvider.initialize(); + + // Remove o source da snapshot para que o próximo refresh o detecte como novo + oclProvider._sourceStateByCanonical.clear(); + oclProvider._codeSystemsByCanonical.clear(); + + // Dispara o refresh e aguarda conclusão + oclProvider.getCodeSystemChanges('4.0.0', null); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Verifica que createForDiscoveredSource foi chamado para o novo source + const callArgs = spy.mock.calls.find(([, meta]) => meta?.canonicalUrl === canonicalUrl); + expect(callArgs).toBeTruthy(); + }); + + it('não chama createForDiscoveredSource para source já existente com factory', async () => { + new OCLSourceCodeSystemFactory(i18n, makeHttpClient(), makeFactoryMeta({ canonicalUrl: 'https://example.org/CodeSystem/InitExist' })); + + const canonicalUrl = 'https://example.org/CodeSystem/ExistingSource'; + // Pre-cria factory para este source + OCLSourceCodeSystemFactory.createForDiscoveredSource(makeHttpClient(), makeFactoryMeta({ canonicalUrl })); + + const spy = jest.spyOn(OCLSourceCodeSystemFactory, 'createForDiscoveredSource'); + + const mockSource = { + id: 'ExistingSource', + short_code: 'ExistingSource', + owner: 'TestOrg', + url: '/orgs/TestOrg/sources/ExistingSource/', + canonical_url: canonicalUrl, + version: 'HEAD', + checksums: { standard: 'x', smart: 'y' }, + updated_at: '2026-05-19T00:00:00Z' + }; + + const oclProvider = new OCLCodeSystemProvider({ baseUrl: 'https://oclapi2.example.org', org: 'TestOrg' }); + oclProvider.httpClient = { + get: jest.fn().mockImplementation((url) => { + if (/\/orgs\/$/.test(url)) { + return Promise.resolve({ data: [{ id: 'TestOrg', url: '/orgs/TestOrg/', type: 'Organization' }] }); + } + if (url.includes('/sources/')) { + return Promise.resolve({ data: { results: [mockSource], num_found: 1, num_returned: 1 } }); + } + return Promise.resolve({ data: [] }); + }) + }; + + await oclProvider.initialize(); + // Esvazia snapshot para forçar novo refresh + oclProvider._sourceStateByCanonical.clear(); + + oclProvider.getCodeSystemChanges('4.0.0', null); + await new Promise(resolve => setTimeout(resolve, 200)); + + // createForDiscoveredSource não deve ter sido chamado para URL que já tem factory + const callForExisting = spy.mock.calls.find(([, meta]) => meta?.canonicalUrl === canonicalUrl); + expect(callForExisting).toBeUndefined(); + }); +}); diff --git a/tx/ocl/cs-ocl.cjs b/tx/ocl/cs-ocl.cjs index f58cc0d9..edaec11a 100644 --- a/tx/ocl/cs-ocl.cjs +++ b/tx/ocl/cs-ocl.cjs @@ -12,7 +12,7 @@ const { computeCodeSystemFingerprint } = require('./fingerprint/fingerprint'); const { OCLBackgroundJobQueue } = require('./jobs/background-queue'); const { OCLConceptFilterContext } = require('./model/concept-filter-context'); const { toConceptContext } = require('./mappers/concept-mapper'); -const { patchSearchWorkerForOCLCodeFiltering } = require('./shared/patches'); +const { patchSearchWorkerForOCLCodeFiltering, patchProviderForOCLFactorySync } = require('./shared/patches'); const regexUtilities = require("../../library/regex-utilities"); patchSearchWorkerForOCLCodeFiltering(); @@ -149,6 +149,15 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider { const nextSnapshot = this.#buildSourceSnapshot(sources); const changes = this.#diffSnapshots(this._sourceStateByCanonical, nextSnapshot); this.#applySnapshot(nextSnapshot); + for (const cs of changes.added || []) { + const entry = nextSnapshot.get(cs.url); + if (entry?.meta && !OCLSourceCodeSystemFactory.hasFactory(cs.url, cs.version || null)) { + const factory = OCLSourceCodeSystemFactory.createForDiscoveredSource(this.httpClient, entry.meta); + if (factory) { + console.log(`[OCL] Factory created for newly discovered source: ${cs.url}`); + } + } + } this._pendingChanges = changes; } catch (error) { console.error('[OCL] Incremental source refresh failed:', error.message); @@ -1251,6 +1260,14 @@ class OCLSourceCodeSystemProvider extends CodeSystemProvider { class OCLSourceCodeSystemFactory extends CodeSystemFactoryProvider { static factoriesByKey = new Map(); + static #sharedI18n = null; + + static createForDiscoveredSource(httpClient, meta) { + if (!OCLSourceCodeSystemFactory.#sharedI18n) { + return null; + } + return new OCLSourceCodeSystemFactory(OCLSourceCodeSystemFactory.#sharedI18n, httpClient, meta); + } static #normalizeSystem(system) { return normalizeCanonicalSystem(system); @@ -1319,6 +1336,9 @@ class OCLSourceCodeSystemFactory extends CodeSystemFactoryProvider { constructor(i18n, client, meta) { super(i18n); + if (!OCLSourceCodeSystemFactory.#sharedI18n) { + OCLSourceCodeSystemFactory.#sharedI18n = i18n; + } this.httpClient = client; this.meta = meta; this.sharedConceptCache = new Map(); @@ -1824,6 +1844,8 @@ class OCLSourceCodeSystemFactory extends CodeSystemFactoryProvider { } } +patchProviderForOCLFactorySync(() => OCLSourceCodeSystemFactory.factoriesByKey); + module.exports = { OCLCodeSystemProvider, OCLSourceCodeSystemFactory, diff --git a/tx/ocl/shared/patches.cjs b/tx/ocl/shared/patches.cjs index 270a740c..89626703 100644 --- a/tx/ocl/shared/patches.cjs +++ b/tx/ocl/shared/patches.cjs @@ -254,9 +254,56 @@ function patchValueSetExpandWholeSystemForOcl() { } +const OCL_PROVIDER_FACTORY_SYNC_PATCH_FLAG = Symbol.for('fhirsmith.ocl.provider.factory-sync.patch'); + +function patchProviderForOCLFactorySync(getFactoriesByKey) { + let ProviderModule; + try { + ProviderModule = require('../../provider'); + } catch (_error) { + return; + } + + const Provider = ProviderModule?.Provider; + const proto = Provider?.prototype; + if (!proto || proto[OCL_PROVIDER_FACTORY_SYNC_PATCH_FLAG] === true) { + return; + } + if (typeof proto.updateCodeSystemList !== 'function') { + return; + } + + const originalUpdateCodeSystemList = proto.updateCodeSystemList; + proto.updateCodeSystemList = async function patchedUpdateCodeSystemList() { + await originalUpdateCodeSystemList.call(this); + const factoriesByKey = getFactoriesByKey(); + for (const factory of factoriesByKey.values()) { + if (!factory) continue; + const system = typeof factory.system === 'function' ? factory.system() : null; + if (!system) continue; + const ver = typeof factory.version === 'function' ? (factory.version() ?? '') : ''; + const vurl = `${system}|${ver}`; + if (!this.codeSystemFactories.has(vurl)) { + if (!this.codeSystemFactories.has(system)) { + this.codeSystemFactories.set(system, factory); + } + this.codeSystemFactories.set(vurl, factory); + } + } + }; + + Object.defineProperty(proto, OCL_PROVIDER_FACTORY_SYNC_PATCH_FLAG, { + value: true, + writable: false, + configurable: false, + enumerable: false + }); +} + module.exports = { patchSearchWorkerForOCLCodeFiltering, ensureTxParametersHashIncludesFilter, patchValueSetExpandWholeSystemForOcl, - normalizeFilterForCacheKey + normalizeFilterForCacheKey, + patchProviderForOCLFactorySync }; From 596bcae38a4c81314735cba3c1fcabdebf669315 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Fri, 3 Jul 2026 09:27:00 -0300 Subject: [PATCH 2/2] feat(ocl): expose concept extras as properties in $lookup Carry OCL concept extras through toConceptContext (non-empty plain objects only) and implement extendLookup on OCLSourceCodeSystemProvider to emit each entry as a FHIR property parameter with typed value[x] (boolean/integer/decimal/string; arrays and objects JSON-stringified). Honors the $lookup property filter per key. Provider now extends BaseCSServices for the _hasProp helper and is exported for tests. Co-Authored-By: Claude Fable 5 --- tests/ocl/ocl-cs-provider.test.js | 90 +++++++++++++++++++++++++- tests/ocl/ocl-display-language.test.js | 21 ++++++ tx/ocl/cs-ocl.cjs | 34 +++++++++- tx/ocl/mappers/concept-mapper.cjs | 10 ++- 4 files changed, 150 insertions(+), 5 deletions(-) diff --git a/tests/ocl/ocl-cs-provider.test.js b/tests/ocl/ocl-cs-provider.test.js index 5a1d28aa..590869c6 100644 --- a/tests/ocl/ocl-cs-provider.test.js +++ b/tests/ocl/ocl-cs-provider.test.js @@ -1,4 +1,6 @@ -const { OCLCodeSystemProvider } = require('../../tx/ocl/cs-ocl'); +const { OCLCodeSystemProvider, OCLSourceCodeSystemProvider } = require('../../tx/ocl/cs-ocl'); +const { OperationContext } = require('../../tx/operation-context'); +const { Languages } = require('../../library/languages'); describe('OCLCodeSystemProvider', () => { it('should instantiate with default config', () => { @@ -17,6 +19,88 @@ describe('OCLCodeSystemProvider', () => { }); describe('OCLSourceCodeSystemProvider', () => { - // OCLSourceCodeSystemProvider não está exportado diretamente, apenas OCLCodeSystemProvider - // Adicione mais testes para OCLCodeSystemProvider + function makeProvider() { + const opContext = new OperationContext(Languages.fromAcceptLanguage('en')); + const meta = { canonicalUrl: 'http://example.org/ocl/CodeSystem/test', version: '1.0' }; + return new OCLSourceCodeSystemProvider(opContext, null, null, meta); + } + + // Pass a plain context object (with .code) so #ensureContext short-circuits + // without hitting locate()/HTTP. + function makeContext(extras) { + const ctx = { code: 'C-1', display: 'Concept One', definition: null, retired: false, designation: [] }; + if (extras !== undefined) { + ctx.extras = extras; + } + return ctx; + } + + describe('extendLookup', () => { + it('emits one property parameter per extras entry with correct value[x] types', async () => { + const provider = makeProvider(); + const params = []; + const ctx = makeContext({ + who_stage: '3', + order: 5, + weight: 2.5, + experimental: true, + nested: { a: 1 }, + list: ['x', 'y'], + empty: null + }); + + await provider.extendLookup(ctx, [], params); + + const byCode = {}; + for (const p of params) { + expect(p.name).toBe('property'); + const codePart = p.part.find(x => x.name === 'code'); + const valuePart = p.part.find(x => x.name === 'value'); + byCode[codePart.valueCode] = valuePart; + } + + expect(byCode.who_stage).toEqual({ name: 'value', valueString: '3' }); + expect(byCode.order).toEqual({ name: 'value', valueInteger: 5 }); + expect(byCode.weight).toEqual({ name: 'value', valueDecimal: 2.5 }); + expect(byCode.experimental).toEqual({ name: 'value', valueBoolean: true }); + expect(byCode.nested).toEqual({ name: 'value', valueString: '{"a":1}' }); + expect(byCode.list).toEqual({ name: 'value', valueString: '["x","y"]' }); + expect(byCode.empty).toBeUndefined(); + expect(params.length).toBe(6); + }); + + it('includes all extras when props is empty or contains *', async () => { + const provider = makeProvider(); + const ctx = makeContext({ a: '1', b: '2' }); + + const allParams = []; + await provider.extendLookup(ctx, [], allParams); + expect(allParams.length).toBe(2); + + const starParams = []; + await provider.extendLookup(ctx, ['*'], starParams); + expect(starParams.length).toBe(2); + }); + + it('filters extras by requested property names (case-insensitive)', async () => { + const provider = makeProvider(); + const ctx = makeContext({ who_stage: '3', order: 5 }); + + const params = []; + await provider.extendLookup(ctx, ['WHO_STAGE'], params); + expect(params.length).toBe(1); + expect(params[0].part.find(x => x.name === 'code').valueCode).toBe('who_stage'); + + const none = []; + await provider.extendLookup(ctx, ['other'], none); + expect(none.length).toBe(0); + }); + + it('leaves params untouched when the concept has no extras', async () => { + const provider = makeProvider(); + const params = []; + await provider.extendLookup(makeContext(), [], params); + expect(params).toEqual([]); + }); + }); }); diff --git a/tests/ocl/ocl-display-language.test.js b/tests/ocl/ocl-display-language.test.js index 4ddfaf80..a47f6391 100644 --- a/tests/ocl/ocl-display-language.test.js +++ b/tests/ocl/ocl-display-language.test.js @@ -162,6 +162,27 @@ describe('toConceptContext', () => { const ctx = toConceptContext({ id: 'ID-001', display_name: 'Name' }); expect(ctx.code).toBe('ID-001'); }); + + it('carries a non-empty extras object through to the context', () => { + const extras = { who_stage: '3', order: 5, experimental: true, nested: { a: 1 } }; + const ctx = toConceptContext({ code: 'X', extras }); + expect(ctx.extras).toEqual(extras); + }); + + it('omits extras when empty, missing, or not a plain object', () => { + expect(toConceptContext({ code: 'X', extras: {} })).not.toHaveProperty('extras'); + expect(toConceptContext({ code: 'X' })).not.toHaveProperty('extras'); + expect(toConceptContext({ code: 'X', extras: ['a'] })).not.toHaveProperty('extras'); + expect(toConceptContext({ code: 'X', extras: 'text' })).not.toHaveProperty('extras'); + expect(toConceptContext({ code: 'X', extras: null })).not.toHaveProperty('extras'); + }); + + it('extras survive a JSON round-trip (cold-cache serialization)', () => { + const extras = { who_stage: '3', order: 5, flags: ['a', 'b'], nested: { a: 1 } }; + const ctx = toConceptContext({ code: 'X', extras }); + const restored = JSON.parse(JSON.stringify(ctx)); + expect(restored.extras).toEqual(extras); + }); }); // --------------------------------------------------------------------------- diff --git a/tx/ocl/cs-ocl.cjs b/tx/ocl/cs-ocl.cjs index edaec11a..561f6b00 100644 --- a/tx/ocl/cs-ocl.cjs +++ b/tx/ocl/cs-ocl.cjs @@ -1,6 +1,7 @@ const fs = require('fs/promises'); const { AbstractCodeSystemProvider } = require('../cs/cs-provider-api'); const { CodeSystemProvider, CodeSystemFactoryProvider, CodeSystemContentMode, FilterExecutionContext } = require('../cs/cs-api'); +const { BaseCSServices } = require('../cs/cs-base'); const { CodeSystem } = require('../library/codesystem'); const { SearchFilterText } = require('../library/designations'); const { PAGE_SIZE, CONCEPT_PAGE_SIZE, COLD_CACHE_FRESHNESS_MS, OCL_CODESYSTEM_MARKER_EXTENSION } = require('./shared/constants'); @@ -595,7 +596,7 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider { } } -class OCLSourceCodeSystemProvider extends CodeSystemProvider { +class OCLSourceCodeSystemProvider extends BaseCSServices { constructor(opContext, supplements, client, meta, sharedCaches = null) { super(opContext, supplements); this.httpClient = client; @@ -736,6 +737,36 @@ class OCLSourceCodeSystemProvider extends CodeSystemProvider { } } + async extendLookup(ctxt, props, params) { + const context = await this.#ensureContext(ctxt); + const extras = context && context.extras; + if (!extras || typeof extras !== 'object') { + return; + } + for (const [key, value] of Object.entries(extras)) { + if (!key || value === null || value === undefined) { + continue; + } + if (!this._hasProp(props, key, true)) { + continue; + } + const part = [{ name: 'code', valueCode: key }]; + if (typeof value === 'boolean') { + part.push({ name: 'value', valueBoolean: value }); + } else if (typeof value === 'number' && Number.isInteger(value)) { + part.push({ name: 'value', valueInteger: value }); + } else if (typeof value === 'number' && Number.isFinite(value)) { + part.push({ name: 'value', valueDecimal: value }); + } else if (typeof value === 'string') { + part.push({ name: 'value', valueString: value }); + } else { + // Extras may hold arbitrary JSON (arrays, objects); serialize rather than drop data. + part.push({ name: 'value', valueString: JSON.stringify(value) }); + } + params.push({ name: 'property', part }); + } + } + async locate(code) { if (!code || typeof code !== 'string') { return { context: null, message: 'Empty code' }; @@ -1848,6 +1879,7 @@ patchProviderForOCLFactorySync(() => OCLSourceCodeSystemFactory.factoriesByKey); module.exports = { OCLCodeSystemProvider, + OCLSourceCodeSystemProvider, OCLSourceCodeSystemFactory, OCLBackgroundJobQueue }; \ No newline at end of file diff --git a/tx/ocl/mappers/concept-mapper.cjs b/tx/ocl/mappers/concept-mapper.cjs index b88d980a..a0c51b15 100644 --- a/tx/ocl/mappers/concept-mapper.cjs +++ b/tx/ocl/mappers/concept-mapper.cjs @@ -8,13 +8,21 @@ function toConceptContext(concept) { return null; } - return { + const ctx = { code, display: concept.display_name || concept.display || concept.name || null, definition: concept.description || concept.definition || null, retired: concept.retired === true, designation: extractDesignations(concept) }; + + const extras = concept.extras; + if (extras && typeof extras === 'object' && !Array.isArray(extras) + && Object.keys(extras).length > 0) { + ctx.extras = extras; + } + + return ctx; } function extractDesignations(concept) {