|
7 | 7 |
|
8 | 8 | import { describe, it, expect } from 'vitest'; |
9 | 9 | import type { ServiceObject } from '@objectstack/spec/data'; |
| 10 | +import { resolveDisplayField } from '@objectstack/spec/data'; |
10 | 11 | import { |
11 | 12 | SEARCH_COMPANION_FIELD, |
12 | 13 | provisionSearchCompanion, |
13 | 14 | resolveSearchCompanionSources, |
14 | 15 | isCompanionSourceEligible, |
15 | 16 | isCompanionMatchableTerm, |
| 17 | + isPrimaryKeyField, |
16 | 18 | containsCJK, |
17 | 19 | } from './search-companion'; |
18 | 20 | import { expandSearchToFilter, resolveSearchFields } from './search-filter'; |
@@ -195,3 +197,141 @@ describe('containsCJK / isCompanionMatchableTerm', () => { |
195 | 197 | expect(isCompanionMatchableTerm('123')).toBe(false); |
196 | 198 | }); |
197 | 199 | }); |
| 200 | + |
| 201 | +// ───────────────────────────────────────────────────────────────────────────── |
| 202 | +// [#10290] The primary key is never a companion source. |
| 203 | +// |
| 204 | +// The two blocks below are a matched pair and are meant to be read together: |
| 205 | +// the first is the NEGATIVE control (the defect — it fails on the pre-fix |
| 206 | +// source), the second is the POSITIVE control (the capability survives — it |
| 207 | +// fails if the refusal over-reaches). |
| 208 | +// ───────────────────────────────────────────────────────────────────────────── |
| 209 | + |
| 210 | +/** |
| 211 | + * The measured shape: a platform table whose only title-eligible column IS its |
| 212 | + * primary key. `id` is declared first, so ADR-0079's derivation reaches tier 3 |
| 213 | + * ("first title-eligible field by declaration order") and lands on it — which |
| 214 | + * is how `sys_jwks`, `sys_secret`, `sys_oauth_access_token` and 17 more came to |
| 215 | + * carry a companion column their values can never fill. |
| 216 | + */ |
| 217 | +const pkOnly = (): any => ({ |
| 218 | + name: 'sys_jwks', |
| 219 | + fields: { |
| 220 | + id: { type: 'text', label: 'ID', readonly: true }, |
| 221 | + created_at: { type: 'datetime' }, |
| 222 | + expires_at: { type: 'datetime' }, |
| 223 | + }, |
| 224 | +}); |
| 225 | + |
| 226 | +/** |
| 227 | + * The shape the registry ACTUALLY hands this seam. `materializeBaseLayer` runs |
| 228 | + * `provisionPrimary(schema, { synthesize: false })` first — contractual order — |
| 229 | + * so the derived fallback has already been written down as an explicit |
| 230 | + * `nameField` pointer by the time provisioning asks. A refusal keyed on "the |
| 231 | + * display field resolved by fallback" could not see any difference here, which |
| 232 | + * is why the refusal is keyed on the field's role instead. |
| 233 | + */ |
| 234 | +const pkOnlyDesignated = (): any => ({ ...pkOnly(), nameField: 'id' }); |
| 235 | + |
| 236 | +describe('[#10290] negative control — the primary key is never a companion source', () => { |
| 237 | + it('refuses a tier-3 derived display field that is the primary key', () => { |
| 238 | + expect(resolveDisplayField(pkOnly())).toBe('id'); // ADR-0079 is unchanged… |
| 239 | + expect(resolveSearchCompanionSources(pkOnly())).toEqual([]); // …the companion declines it |
| 240 | + }); |
| 241 | + |
| 242 | + it('refuses it just the same once `provisionPrimary` has written it down as `nameField`', () => { |
| 243 | + expect(resolveSearchCompanionSources(pkOnlyDesignated())).toEqual([]); |
| 244 | + }); |
| 245 | + |
| 246 | + it('refuses the `_id` spelling of the same address', () => { |
| 247 | + expect(resolveSearchCompanionSources({ |
| 248 | + name: 'sys_legacy', |
| 249 | + nameField: '_id', |
| 250 | + fields: { _id: { type: 'text' }, created_at: { type: 'datetime' } }, |
| 251 | + })).toEqual([]); |
| 252 | + }); |
| 253 | + |
| 254 | + it('declares no `__search` column on such an object (returns it by reference)', () => { |
| 255 | + const before = pkOnly(); |
| 256 | + expect(provisionSearchCompanion(before)).toBe(before); |
| 257 | + expect(before.fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); |
| 258 | + |
| 259 | + const designated = pkOnlyDesignated(); |
| 260 | + expect(provisionSearchCompanion(designated)).toBe(designated); |
| 261 | + expect(designated.fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); |
| 262 | + }); |
| 263 | + |
| 264 | + it('SchemaRegistry: registering one provisions no companion, and ADR-0079 still designates the title', () => { |
| 265 | + const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: true }); |
| 266 | + registry.registerObject(pkOnly(), 'test-pkg', 'sys'); |
| 267 | + const schema = registry.getObject('sys_jwks')!; |
| 268 | + expect(schema.fields![SEARCH_COMPANION_FIELD]).toBeUndefined(); |
| 269 | + // The title contract is INTERPRETED, not amended: the pointer is still set. |
| 270 | + expect((schema as any).nameField).toBe('id'); |
| 271 | + }); |
| 272 | + |
| 273 | + it('isPrimaryKeyField answers for the address spellings only', () => { |
| 274 | + expect(isPrimaryKeyField('id')).toBe(true); |
| 275 | + expect(isPrimaryKeyField('_id')).toBe(true); |
| 276 | + expect(isPrimaryKeyField(undefined)).toBe(false); |
| 277 | + expect(isPrimaryKeyField('')).toBe(false); |
| 278 | + }); |
| 279 | +}); |
| 280 | + |
| 281 | +describe('[#10290] positive control — objects with a real title keep the companion', () => { |
| 282 | + /** A contact as the platform really registers it: `id` present, `name` too. */ |
| 283 | + const contactWithId = (): any => ({ |
| 284 | + name: 'crm_contact', |
| 285 | + fields: { |
| 286 | + id: { type: 'text', label: 'ID', readonly: true }, |
| 287 | + name: { type: 'text', label: 'Name' }, |
| 288 | + email: { type: 'email' }, |
| 289 | + }, |
| 290 | + }); |
| 291 | + |
| 292 | + it('an object carrying an `id` field still gets its companion from the name field', () => { |
| 293 | + expect(resolveSearchCompanionSources(contactWithId())).toEqual(['name']); |
| 294 | + const out = provisionSearchCompanion(contactWithId()); |
| 295 | + expect(out.fields[SEARCH_COMPANION_FIELD]).toBeDefined(); |
| 296 | + }); |
| 297 | + |
| 298 | + it('a plain text display field that is not named `name`/`title` is still accepted', () => { |
| 299 | + // Explicit pointer … |
| 300 | + expect(resolveSearchCompanionSources({ |
| 301 | + name: 'crm_ticket', |
| 302 | + nameField: 'subject', |
| 303 | + fields: { id: { type: 'text' }, subject: { type: 'text' } }, |
| 304 | + })).toEqual(['subject']); |
| 305 | + // … and a tier-3 DERIVED text column (the same derivation path the negative |
| 306 | + // control exercises), so the refusal cannot be "all `type: 'text'` sources". |
| 307 | + expect(resolveSearchCompanionSources({ |
| 308 | + name: 'crm_code', |
| 309 | + fields: { code: { type: 'text' }, note: { type: 'textarea' } }, |
| 310 | + })).toEqual(['code']); |
| 311 | + }); |
| 312 | + |
| 313 | + it('a field whose NAME merely contains "id" is not the address', () => { |
| 314 | + for (const fname of ['identifier', 'id_card', 'bid', 'valid_name', 'external_id_label']) { |
| 315 | + expect(isPrimaryKeyField(fname)).toBe(false); |
| 316 | + expect(resolveSearchCompanionSources({ |
| 317 | + name: 'crm_thing', |
| 318 | + nameField: fname, |
| 319 | + fields: { [fname]: { type: 'text' } }, |
| 320 | + })).toEqual([fname]); |
| 321 | + } |
| 322 | + }); |
| 323 | + |
| 324 | + it('SchemaRegistry: a titled object is still provisioned end-to-end', () => { |
| 325 | + const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: true }); |
| 326 | + registry.registerObject(contactWithId(), 'test-pkg', 'crm'); |
| 327 | + const schema = registry.getObject('crm_contact')!; |
| 328 | + expect(schema.fields![SEARCH_COMPANION_FIELD]).toBeDefined(); |
| 329 | + expect((schema.fields![SEARCH_COMPANION_FIELD] as any).hidden).toBe(true); |
| 330 | + }); |
| 331 | + |
| 332 | + it('the query-time OR clause still fires for a companion-bearing object', () => { |
| 333 | + const fields = provisionSearchCompanion(contactWithId()).fields as any; |
| 334 | + const filter = expandSearchToFilter('zhangwei', { fields }); |
| 335 | + expect(filter.$or).toContainEqual({ [SEARCH_COMPANION_FIELD]: { $contains: 'zhangwei' } }); |
| 336 | + }); |
| 337 | +}); |
0 commit comments