diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e9dab53 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build and test package + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout rayforce-wasm + uses: actions/checkout@v7 + + - name: Setup Emscripten + uses: mymindstorm/setup-emsdk@v16 + with: + version: '6.0.6' + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '24' + package-manager-cache: false + + - name: Build WASM package + run: npm run build + + - name: Test runtime, declarations, and documented API + run: npm test + + - name: Verify packaged SDK mirrors source + run: | + cmp src/index.js dist/index.js + cmp src/index.d.ts dist/index.d.ts + cmp src/rayforce.sdk.js dist/rayforce.sdk.js + cmp src/rayforce.sdk.d.ts dist/rayforce.sdk.d.ts + cmp src/rayforce.umd.js dist/rayforce.umd.js + + - name: Verify npm package payload + run: npm pack --dry-run --ignore-scripts diff --git a/CLAUDE.md b/CLAUDE.md index b71d573..387ef5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ console.log(table.toRows()); ```html * * @module rayforce - * @version 0.2.0 + * @version 0.2.1 */ import { createRayforceSDK, Types, Expr } from './rayforce.sdk.js'; // SDK version -export const version = '0.2.0'; +export const version = '0.2.1'; // Re-export types and utilities export { Types, Expr }; diff --git a/src/rayforce.sdk.js b/src/rayforce.sdk.js index 28008fb..fa250eb 100644 --- a/src/rayforce.sdk.js +++ b/src/rayforce.sdk.js @@ -5,7 +5,7 @@ * Provides TypedArray views over native Rayforce vectors for efficient data access. * * @module rayforce - * @version 0.2.0 + * @version 0.2.1 */ // ============================================================================ @@ -643,6 +643,15 @@ class RayforceSDK { typeName(typeCode) { return this._getTypeName(typeCode); } + + /** + * Create a column reference for the query builder. + * @param {string} name + * @returns {Expr} + */ + col(name) { + return Expr.col(this, name); + } } // ============================================================================ @@ -1458,6 +1467,17 @@ class Lambda extends RayObject { // Query Builder // ============================================================================ +const RAYFALL_NAME = /^[A-Za-z0-9_.-]+$/; + +function assertRayfallName(name, role = 'column') { + if (typeof name !== 'string' || name.length === 0 || !RAYFALL_NAME.test(name)) { + throw new TypeError( + `${role} name must contain only letters, numbers, underscores, dots, or hyphens`, + ); + } + return name; +} + /** * Expression builder for query conditions */ @@ -1473,12 +1493,14 @@ class Expr { * @returns {Expr} */ static col(sdk, name) { - return new Expr(sdk, [`\`${name}`]); + // Rayforce v2 uses a leading apostrophe for a quoted symbol. Inside a + // query, quoted symbols resolve to columns just like bare names. + return new Expr(sdk, [`'${assertRayfallName(name)}`]); } // Comparison operators - eq(value) { return this._binOp('=', value); } - ne(value) { return this._binOp('<>', value); } + eq(value) { return this._binOp('==', value); } + ne(value) { return this._binOp('!=', value); } lt(value) { return this._binOp('<', value); } le(value) { return this._binOp('<=', value); } gt(value) { return this._binOp('>', value); } @@ -1505,6 +1527,9 @@ class Expr { } _logicOp(op, other) { + if (!(other instanceof Expr)) { + throw new TypeError(`${op}() expects an Expr`); + } return new Expr(this._sdk, [`(${op}`, ...this._parts, ...other._parts, ')']); } @@ -1542,6 +1567,10 @@ class SelectQuery { * @returns {SelectQuery} */ select(...cols) { + for (const col of cols) { + if (typeof col === 'string') assertRayfallName(col); + else if (!(col instanceof Expr)) throw new TypeError('select() expects column names or Expr values'); + } const q = this._clone(); q._selectCols = cols; return q; @@ -1554,6 +1583,8 @@ class SelectQuery { * @returns {SelectQuery} */ withColumn(name, expr) { + assertRayfallName(name, 'output column'); + if (!(expr instanceof Expr)) throw new TypeError('withColumn() expects an Expr'); const q = this._clone(); q._computedCols[name] = expr; return q; @@ -1565,6 +1596,7 @@ class SelectQuery { * @returns {SelectQuery} */ where(condition) { + if (!(condition instanceof Expr)) throw new TypeError('where() expects an Expr'); const q = this._clone(); q._whereCond = q._whereCond ? q._whereCond.and(condition) : condition; return q; @@ -1576,6 +1608,7 @@ class SelectQuery { * @returns {SelectQuery} */ groupBy(...cols) { + for (const col of cols) assertRayfallName(col, 'group-by column'); const q = this._clone(); q._byCols = cols; return q; diff --git a/test-contract.mjs b/test-contract.mjs new file mode 100644 index 0000000..af657a6 --- /dev/null +++ b/test-contract.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import * as indexExports from './dist/index.js'; +import * as sdkExports from './dist/rayforce.sdk.js'; +import { init, Types } from './dist/index.js'; + +const declarations = readFileSync(new URL('./dist/rayforce.sdk.d.ts', import.meta.url), 'utf8'); +const indexDeclarations = readFileSync(new URL('./dist/index.d.ts', import.meta.url), 'utf8'); +const readme = readFileSync(new URL('./README.md', import.meta.url), 'utf8'); + +function declarationValueExports(source) { + const names = new Set(); + for (const match of source.matchAll(/^export declare (?:class|const|function) ([A-Za-z_$][\w$]*)/gm)) { + names.add(match[1]); + } + for (const match of source.matchAll(/^export \{[^\n}]*\bas\s+([A-Za-z_$][\w$]*)[^\n}]*\};/gm)) { + names.add(match[1]); + } + if (/^export default /m.test(source)) names.add('default'); + return names; +} + +const declaredSdkExports = declarationValueExports(declarations); +assert.deepEqual( + Object.keys(sdkExports).sort(), + [...declaredSdkExports].sort(), + 'rayforce.sdk.js exports do not match rayforce.sdk.d.ts', +); + +const declaredIndexExports = declarationValueExports(indexDeclarations); +for (const name of declaredSdkExports) { + if (name !== 'default') declaredIndexExports.add(name); +} +assert.deepEqual( + Object.keys(indexExports).sort(), + [...declaredIndexExports].sort(), + 'index.js exports do not match index.d.ts', +); + +function declarationClasses(source) { + const classes = new Map(); + const classPattern = /export declare class ([A-Za-z_$][\w$]*)(?: extends ([A-Za-z_$][\w$]*))? \{([\s\S]*?)^\}/gm; + + for (const match of source.matchAll(classPattern)) { + const [, name, extendsName, body] = match; + const instance = new Set(); + const statics = new Set(); + const memberPattern = /^\s+(static\s+)?(?:readonly\s+)?([A-Za-z_$][\w$]*|\[Symbol\.iterator\])(?:<[^\n>]+>)?\s*(?=\(|:)/gm; + + for (const member of body.matchAll(memberPattern)) { + const memberName = member[2]; + if (memberName === 'constructor') continue; + (member[1] ? statics : instance).add(memberName); + } + classes.set(name, { extendsName, instance, statics }); + } + + return classes; +} + +function runtimeKey(name) { + return name === '[Symbol.iterator]' ? globalThis.Symbol.iterator : name; +} + +const declaredClasses = declarationClasses(declarations); +assert.ok(declaredClasses.size > 0, 'no TypeScript class declarations were found'); + +function hasDeclaredInstanceMember(className, member) { + let current = declaredClasses.get(className); + while (current) { + if (current.instance.has(member)) return true; + current = current.extendsName ? declaredClasses.get(current.extendsName) : null; + } + return false; +} + +for (const [className, members] of declaredClasses) { + const RuntimeClass = sdkExports[className]; + assert.equal(typeof RuntimeClass, 'function', `${className} is declared but not exported at runtime`); + + for (const member of members.instance) { + assert.ok( + runtimeKey(member) in RuntimeClass.prototype, + `${className}.${member} is declared in TypeScript but missing at runtime`, + ); + } + for (const member of members.statics) { + assert.ok( + runtimeKey(member) in RuntimeClass, + `${className}.${member} is declared static in TypeScript but missing at runtime`, + ); + } + + for (const key of Reflect.ownKeys(RuntimeClass.prototype)) { + if (key === 'constructor' || (typeof key === 'string' && key.startsWith('_'))) continue; + const declaredName = key === globalThis.Symbol.iterator ? '[Symbol.iterator]' : key; + assert.ok( + hasDeclaredInstanceMember(className, declaredName), + `${className}.${String(declaredName)} exists at runtime but is missing from TypeScript`, + ); + } +} + +function documentedMembers(receiver) { + const names = new Set(); + const pattern = new RegExp(`\\b${receiver}\\.([A-Za-z_$][\\w$]*)\\b`, 'g'); + for (const match of readme.matchAll(pattern)) names.add(match[1]); + return names; +} + +const rf = await init({ singleton: false }); +const table = rf.table({ id: [1], name: ['Ada'], score: [95.5] }); +const vec = rf.vector(Types.F64, [1.5]); +const col = rf.col('score'); +const result = rf.eval('(+ 1 2)'); +const row = table.row(0); + +for (const [receiver, object] of Object.entries({ rf, table, vec, col, result, row })) { + const members = documentedMembers(receiver); + assert.ok(members.size > 0, `README contract receiver ${receiver} has no documented members`); + for (const member of members) { + assert.ok(member in object, `README documents ${receiver}.${member}, but it is missing at runtime`); + } +} + +console.log( + `${declaredClasses.size} TypeScript classes, module exports, and README API usages match the runtime`, +); diff --git a/test.mjs b/test.mjs index f3b3f0a..f7994a1 100644 --- a/test.mjs +++ b/test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { init, version, Types } from './dist/index.js'; -assert.equal(version, '0.2.0'); +assert.equal(version, '0.2.1'); const rf = await init({ singleton: false }); assert.equal(rf.version, '2.5.13'); @@ -26,6 +26,94 @@ assert.deepEqual(rf.readCSV(csv, 'smoke.csv').toRows(), [ { id: 2, name: 'Lin' }, ]); +// Keep the fluent query-builder examples in README executable. This guards +// the public rf.col() helper, generated Rayfall syntax, and comparison names. +assert.equal(typeof rf.col, 'function'); +const queryTable = rf.table({ + name: ['Alice', 'Bob', 'Carol'], + department: ['eng', 'ops', 'eng'], + score: [95.5, 87.3, 92.1], + active: [true, false, true], +}); + +assert.deepEqual( + queryTable + .select('name', 'score') + .where(rf.col('score').gt(90)) + .execute() + .toRows(), + [ + { name: 'Alice', score: 95.5 }, + { name: 'Carol', score: 92.1 }, + ], +); + +assert.deepEqual( + queryTable + .select('department') + .withColumn('avg_score', rf.col('score').avg()) + .withColumn('max_score', rf.col('score').max()) + .groupBy('department') + .execute() + .toRows(), + [ + { department: 'eng', avg_score: 93.8, max_score: 95.5 }, + { department: 'ops', avg_score: 87.3, max_score: 87.3 }, + ], +); + +assert.deepEqual( + queryTable + .where(rf.col('score').gt(80).and(rf.col('active').eq(true))) + .execute() + .toRows() + .map(({ name }) => name), + ['Alice', 'Carol'], +); + +assert.deepEqual( + queryTable.where(rf.col('department').ne('eng')).execute().toRows().map(({ name }) => name), + ['Bob'], +); + +const comparisonCases = [ + ['eq', 87.3, ['Bob']], + ['ne', 87.3, ['Alice', 'Carol']], + ['lt', 90, ['Bob']], + ['le', 87.3, ['Bob']], + ['gt', 90, ['Alice', 'Carol']], + ['ge', 95.5, ['Alice']], +]; +for (const [method, value, expectedNames] of comparisonCases) { + const names = queryTable + .select('name') + .where(rf.col('score')[method](value)) + .execute() + .toRows() + .map(({ name }) => name); + assert.deepEqual(names, expectedNames, `Expr.${method}() emitted an invalid query`); +} + +const aggregationCases = ['sum', 'avg', 'min', 'max', 'count', 'first', 'last', 'distinct']; +for (const method of aggregationCases) { + const aggregation = queryTable + .select() + .withColumn('value', rf.col('score')[method]()) + .execute(); + assert.equal(aggregation.isError, false, `Expr.${method}() emitted an invalid query`); +} + +assert.deepEqual( + queryTable + .select('name') + .where(rf.col('score').lt(90).or(rf.col('active').not())) + .execute() + .toRows() + .map(({ name }) => name), + ['Bob', 'Carol'], +); +assert.throws(() => rf.col('score) injected'), /column name/); + const error = rf.eval('(+ 1)'); assert.equal(error.isError, true); assert.equal(error.code, 'arity');