Files
dc/client/src/app/services/sas-store.service.spec.ts
T
dc 8f98ebe326
Build / Build-and-ng-test (pull_request) Successful in 5m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m51s
Build / Build-and-test-development (pull_request) Successful in 30m50s
fix(filters): map an IN entry to its unformatted value before the literal
Finding from the review at be79f3a.

createFilterQueryTable's IN / NOT IN branch read element.val directly, so it
produced a temporal literal only because whereClauseCreator had already mutated
query.value[i].val from the formatted label to the unformatted count in place
before submit.  Any path reaching the branch without the preview having run
first would have put the formatted string into RAW_VALUE.

The entry now goes through the same unformattedValue() lookup as the BETWEEN
ends.  An already-unformatted value passes through unchanged, since it no longer
matches a formatted label.

Two unit cases added: an IN entry carrying the formatted label, and one already
carrying the internal count.  578/578 unit tests pass.
2026-10-16 20:21:38 +00:00

314 lines
8.7 KiB
TypeScript

import { SasStoreService } from './sas-store.service'
import { HelperService } from './helper.service'
/**
* The filter picker keys its operator set, its variable highlight and its value
* quoting on Col.TYPE being exactly 'num' or 'char'. On the normal path
* mergeColsRules derives that from $dataFormats; on the public/getcols fallback
* (used when the EDIT/VIEW limits abort the load) the service speaks the SAS
* vocabulary (TYPE C/N, DDTYPE separating DATE/DATETIME/TIME), so the store has
* to map it. These tests pin both the mapping and the empty-payload guard.
*/
describe('SasStoreService', () => {
const buildStore = (cols: any[]) =>
new SasStoreService(
{
request: () => Promise.resolve({ adapterResponse: { cols } })
} as any,
{} as any,
{} as any
)
const collectColumns = (store: SasStoreService) => {
const seen: any[] = []
store.columns.subscribe((value: any) => seen.push(value))
return seen
}
describe('getCols', () => {
it('maps the SAS C/N TYPE onto the picker vocabulary', async () => {
const store = buildStore([
{ NAME: 'CHAR_COL', TYPE: 'C', DDTYPE: 'C' },
{ NAME: 'NUM_COL', TYPE: 'N', DDTYPE: 'N' },
{ NAME: 'DATE_COL', TYPE: 'N', DDTYPE: 'DATE' },
{ NAME: 'DATETIME_COL', TYPE: 'N', DDTYPE: 'DATETIME' },
{ NAME: 'TIME_COL', TYPE: 'N', DDTYPE: 'TIME' }
])
const cols = await store.getCols('DC.MY_TABLE')
expect(cols.map((col: any) => col.TYPE)).toEqual([
'char',
'num',
'num',
'num',
'num'
])
})
it('leaves the rest of the column payload untouched', async () => {
const store = buildStore([
{
NAME: 'CHAR_COL',
TYPE: 'C',
DDTYPE: 'C',
LENGTH: 8,
LABEL: 'A label'
}
])
const cols = await store.getCols('DC.MY_TABLE')
expect(cols[0].NAME).toBe('CHAR_COL')
expect(cols[0].DDTYPE).toBe('C')
expect(cols[0].LENGTH).toBe(8)
expect(cols[0].LABEL).toBe('A label')
})
it('returns an empty list rather than throwing when the payload is empty', async () => {
const store = buildStore([])
const cols = await store.getCols('DC.MY_TABLE')
expect(cols).toEqual([])
})
})
describe('setQueryVariablesFromTable', () => {
it('publishes the table columns without calling the service', async () => {
let called = false
const store = new SasStoreService(
{
request: () => {
called = true
return Promise.resolve({})
}
} as any,
{} as any,
{} as any
)
const seen = collectColumns(store)
await store.setQueryVariablesFromTable('DC.MY_TABLE', [{ NAME: 'A' }])
expect(called).toBe(false)
expect(seen.length).toBe(1)
expect(seen[0].data.cols.length).toBe(1)
})
it('falls back to getcols and publishes the mapped columns', async () => {
const store = buildStore([{ NAME: 'A', TYPE: 'C', DDTYPE: 'C' }])
const seen = collectColumns(store)
await store.setQueryVariablesFromTable('DC.MY_TABLE', [])
expect(seen.length).toBe(1)
expect(seen[0].libds).toBe('DC.MY_TABLE')
expect(seen[0].data.cols[0].TYPE).toBe('char')
})
it('does not publish an empty fallback, which the picker cannot render', async () => {
const store = buildStore([])
const seen = collectColumns(store)
await store.setQueryVariablesFromTable('DC.MY_TABLE', [])
expect(seen.length).toBe(0)
})
})
})
/**
* The clause shown in the filter panel is composed by whereClauseCreator, and
* its temporal rewrite turns the internal day / second count into a SAS
* literal. It may only do that when the composed value really is nothing but
* those numbers - a value typed by hand is not, and rewriting the digit runs
* inside it produced clauses like '13JAN1960'd/'03JAN1960'd/'14MAY1965'd for
* a date typed as 12/02/1960.
*/
describe('SasStoreService - temporal clause preview', () => {
const buildStore = () =>
new SasStoreService({} as any, new HelperService(null as any), {} as any)
const clauseFor = (
value: any,
operator: string,
ddtype: string,
values: any[] = []
) => ({
queryObj: [
{
clauseLogic: '',
elements: [
{
variable: 'SOME_DATE',
operator,
value,
type: 'num',
ddtype,
valueVariable: false,
values
}
]
}
],
groupLogic: ''
})
it('leaves a typed temporal value alone when it is not a number', () => {
const store = buildStore()
const result = store.whereClauseCreator(
clauseFor('12/02/1960', '=', 'DATE'),
'',
'lib.ds'
)
expect(result.whereClause).toContain('12/02/1960')
expect(result.whereClause).not.toContain("'d")
})
it('still shows an internal day count as a date literal', () => {
const store = buildStore()
const result = store.whereClauseCreator(
clauseFor('42', '=', 'DATE'),
'',
'lib.ds'
)
expect(result.whereClause).toContain("'12FEB1960'd")
})
/**
* A BETWEEN pair is composed as `<from> AND <to>`, so the guard that decides
* whether the rewrite may run has to see past the separator - its letters are
* not part of the numbers the value is built from.
*/
it('shows a BETWEEN pair picked from the value list as literals', () => {
const store = buildStore()
const result = store.whereClauseCreator(
clauseFor(['12FEB1960', '13FEB1960'], 'BETWEEN', 'DATE', [
{ formatted: '12FEB1960', unformatted: '42' },
{ formatted: '13FEB1960', unformatted: '43' }
]),
'',
'lib.ds'
)
expect(result.whereClause).toContain(
"BETWEEN '12FEB1960'd AND '13FEB1960'd"
)
})
})
/**
* createFilterQueryTable builds the RAW_VALUE the backend validates and stores.
* The range dropdown binds the formatted label, so a BETWEEN has to map each
* end back to its unformatted value before the literal is built - otherwise
* sasTemporalLiteral returns null and an unquoted date reaches the backend.
*/
describe('SasStoreService - temporal RAW_VALUE', () => {
const buildStore = () =>
new SasStoreService({} as any, new HelperService(null as any), {} as any)
const betweenQuery = (value: any[], values: any[]) => ({
groupLogic: 'AND',
filterGroups: [
{
clauseLogic: 'AND',
filterClauses: [
{
ddtype: 'DATE',
invalidClause: false,
logic: '',
operator: 'BETWEEN',
operators: [],
type: 'num',
value,
valueVariable: false,
values,
variable: 'SOME_DATE'
}
]
}
]
})
it('maps a BETWEEN picked from the value list back to the internal value', () => {
const store = buildStore()
const clauses = store.createFilterQueryTable(
betweenQuery(
['12FEB1960', '13FEB1960'],
[
{ formatted: '12FEB1960', unformatted: 42 },
{ formatted: '13FEB1960', unformatted: 43 }
]
) as any
)
expect(clauses[0].RAW_VALUE).toBe("'12FEB1960'd AND '13FEB1960'd")
})
it('still builds literals when the values are already internal counts', () => {
const store = buildStore()
const clauses = store.createFilterQueryTable(
betweenQuery(['42', '43'], []) as any
)
expect(clauses[0].RAW_VALUE).toBe("'12FEB1960'd AND '13FEB1960'd")
})
const inQuery = (value: any[], values: any[]) => ({
groupLogic: 'AND',
filterGroups: [
{
clauseLogic: 'AND',
filterClauses: [
{
ddtype: 'DATE',
invalidClause: false,
logic: '',
operator: 'IN',
operators: [],
type: 'num',
value,
valueVariable: false,
values,
variable: 'SOME_DATE'
}
]
}
]
})
it('maps an IN entry picked from the value list back to the internal value', () => {
const store = buildStore()
const clauses = store.createFilterQueryTable(
inQuery(
[{ checked: true, val: '12FEB1960' }],
[{ formatted: '12FEB1960', unformatted: 42 }]
) as any
)
expect(clauses[0].RAW_VALUE).toBe("('12FEB1960'd)")
})
it('passes an already-unformatted IN entry through unchanged', () => {
const store = buildStore()
const clauses = store.createFilterQueryTable(
inQuery(
[{ checked: true, val: 42 }],
[{ formatted: '12FEB1960', unformatted: 42 }]
) as any
)
expect(clauses[0].RAW_VALUE).toBe("('12FEB1960'd)")
})
})