refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,19 @@
{
"name": "@fluxer/cassandra",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"cassandra-driver": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
CassandraClient,
type CassandraClientOptions,
type CassandraConfig,
type ICassandraClient,
} from '@fluxer/cassandra/src/Client';
import {type Logger, NoopLogger} from '@fluxer/cassandra/src/Logger';
import {CassandraQueryExecutor, type ICassandraQueryExecutor} from '@fluxer/cassandra/src/Queries';
export interface CassandraRuntimeOptions {
logger?: Logger | undefined;
}
export interface ICassandraRuntime {
client: ICassandraClient;
queries: ICassandraQueryExecutor;
connect(): Promise<void>;
shutdown(): Promise<void>;
}
export class CassandraRuntime implements ICassandraRuntime {
public readonly client: CassandraClient;
public readonly queries: CassandraQueryExecutor;
public constructor(config: CassandraConfig, options: CassandraRuntimeOptions = {}) {
const logger = options.logger ?? NoopLogger;
const clientOptions: CassandraClientOptions = {logger};
this.client = new CassandraClient(config, clientOptions);
this.queries = new CassandraQueryExecutor(this.client, logger);
}
public async connect(): Promise<void> {
await this.client.connect();
}
public async shutdown(): Promise<void> {
await this.client.shutdown();
}
}
export function createCassandraRuntime(
config: CassandraConfig,
options: CassandraRuntimeOptions = {},
): CassandraRuntime {
return new CassandraRuntime(config, options);
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {types} from 'cassandra-driver';
export type CassandraValue =
| string
| number
| bigint
| boolean
| Buffer
| Date
| types.LocalDate
| Set<unknown>
| Map<unknown, unknown>
| Array<unknown>
| Record<string, unknown>
| null;
export type CassandraParams = Record<string, CassandraValue>;
export interface PreparedQuery<P extends CassandraParams = CassandraParams> {
cql: string;
params: P;
}
export interface QueryTemplate<P extends CassandraParams = CassandraParams> {
cql: string;
bind(params: P): PreparedQuery<P>;
}
export interface ConditionalQueryResult {
applied: boolean;
}

View File

@@ -0,0 +1,211 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {CassandraParams, PreparedQuery} from '@fluxer/cassandra/src/CassandraTypes';
import {type Logger, NoopLogger} from '@fluxer/cassandra/src/Logger';
import cassandra from 'cassandra-driver';
export interface CassandraConfig {
hosts: Array<string>;
keyspace: string;
localDc: string;
username?: string | undefined;
password?: string | undefined;
}
export interface CassandraClientOptions {
logger?: Logger | undefined;
}
export interface CassandraExecuteOptions {
prepare?: boolean | undefined;
}
export interface CassandraBatchOptions {
prepare?: boolean | undefined;
}
export interface ICassandraClient {
connect(): Promise<void>;
shutdown(): Promise<void>;
isConnected(): boolean;
execute<P extends CassandraParams>(
query: PreparedQuery<P>,
options?: CassandraExecuteOptions,
): Promise<cassandra.types.ResultSet>;
batch(queries: Array<PreparedQuery>, options?: CassandraBatchOptions): Promise<void>;
getNativeClient(): cassandra.Client;
setLogger(logger: Logger): void;
}
interface DefaultClientState {
client: CassandraClient | null;
logger: Logger;
}
const defaultClientState: DefaultClientState = {
client: null,
logger: NoopLogger,
};
export class CassandraClient implements ICassandraClient {
private readonly config: CassandraConfig;
private logger: Logger;
private client: cassandra.Client | null;
public constructor(config: CassandraConfig, options: CassandraClientOptions = {}) {
this.config = {
hosts: [...config.hosts],
keyspace: config.keyspace,
localDc: config.localDc,
username: config.username,
password: config.password,
};
this.logger = options.logger ?? NoopLogger;
this.client = null;
}
public async connect(): Promise<void> {
if (this.client !== null) {
return;
}
const authProvider = this.config.username
? new cassandra.auth.PlainTextAuthProvider(this.config.username, this.config.password ?? '')
: undefined;
const client = new cassandra.Client({
contactPoints: this.config.hosts,
keyspace: this.config.keyspace,
localDataCenter: this.config.localDc,
encoding: {
map: Map,
set: Set,
useBigIntAsLong: true,
useBigIntAsVarint: true,
},
...(authProvider ? {authProvider} : {}),
});
await client.connect();
this.client = client;
this.logger.info(
{
hosts: this.config.hosts,
keyspace: this.config.keyspace,
local_dc: this.config.localDc,
},
'Connected to Cassandra',
);
}
public async shutdown(): Promise<void> {
if (this.client === null) {
return;
}
const activeClient = this.client;
this.client = null;
await activeClient.shutdown();
this.logger.info({}, 'Cassandra connection closed');
}
public isConnected(): boolean {
return this.client !== null;
}
public async execute<P extends CassandraParams>(
query: PreparedQuery<P>,
options: CassandraExecuteOptions = {},
): Promise<cassandra.types.ResultSet> {
return this.getNativeClient().execute(query.cql, query.params, {
prepare: options.prepare ?? true,
});
}
public async batch(queries: Array<PreparedQuery>, options: CassandraBatchOptions = {}): Promise<void> {
if (queries.length === 0) {
return;
}
const batch = queries.map((query) => ({query: query.cql, params: query.params}));
await this.getNativeClient().batch(batch, {
prepare: options.prepare ?? true,
});
}
public getNativeClient(): cassandra.Client {
if (this.client === null) {
throw new Error('Cassandra client is not connected. Call connect() first.');
}
return this.client;
}
public setLogger(logger: Logger): void {
this.logger = logger;
}
}
export function createCassandraClient(config: CassandraConfig, options: CassandraClientOptions = {}): CassandraClient {
const logger = options.logger ?? defaultClientState.logger;
return new CassandraClient(config, {logger});
}
export function setLogger(loggerInstance: Logger): void {
defaultClientState.logger = loggerInstance;
if (defaultClientState.client !== null) {
defaultClientState.client.setLogger(loggerInstance);
}
}
export function getLogger(): Logger {
return defaultClientState.logger;
}
export async function initCassandra(config: CassandraConfig): Promise<void> {
if (defaultClientState.client !== null) {
await defaultClientState.client.shutdown();
}
const client = new CassandraClient(config, {logger: defaultClientState.logger});
await client.connect();
defaultClientState.client = client;
}
export async function shutdownCassandra(): Promise<void> {
if (defaultClientState.client === null) {
return;
}
await defaultClientState.client.shutdown();
defaultClientState.client = null;
}
export function getDefaultCassandraClient(): ICassandraClient {
if (defaultClientState.client === null) {
throw new Error('Cassandra client is not initialized. Call initCassandra() first.');
}
return defaultClientState.client;
}
export function getClient(): cassandra.Client {
return getDefaultCassandraClient().getNativeClient();
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface Logger {
info(context: Record<string, unknown>, message: string): void;
debug(context: Record<string, unknown>, message: string): void;
error(context: Record<string, unknown>, message: string): void;
}
export const NoopLogger: Logger = {
info(): void {},
debug(): void {},
error(): void {},
};

View File

@@ -0,0 +1,189 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ConditionalQueryResult, PreparedQuery} from '@fluxer/cassandra/src/CassandraTypes';
import type {ICassandraClient} from '@fluxer/cassandra/src/Client';
import {getDefaultCassandraClient, getLogger} from '@fluxer/cassandra/src/Client';
import {type Logger, NoopLogger} from '@fluxer/cassandra/src/Logger';
export interface ICassandraQueryExecutor {
fetchMany<Row>(query: PreparedQuery): Promise<Array<Row>>;
fetchOne<Row>(query: PreparedQuery): Promise<Row | null>;
execute(query: PreparedQuery): Promise<void>;
executeBatch(queries: Array<PreparedQuery>): Promise<void>;
executeConditional(query: PreparedQuery): Promise<ConditionalQueryResult>;
}
export class CassandraQueryExecutor implements ICassandraQueryExecutor {
private readonly client: ICassandraClient;
private readonly logger: Logger;
public constructor(client: ICassandraClient, logger: Logger = NoopLogger) {
this.client = client;
this.logger = logger;
}
public async fetchMany<Row>(query: PreparedQuery): Promise<Array<Row>> {
const result = await this.runQuery(
'query',
query,
async () => {
return this.client.execute(query);
},
{},
);
return CassandraQueryExecutor.validateRows<Row>(result.rows);
}
public async fetchOne<Row>(query: PreparedQuery): Promise<Row | null> {
const rows = await this.fetchMany<Row>(query);
return rows[0] ?? null;
}
public async execute(query: PreparedQuery): Promise<void> {
await this.runQuery(
'execute',
query,
async () => {
await this.client.execute(query);
},
{},
);
}
public async executeBatch(queries: Array<PreparedQuery>): Promise<void> {
if (queries.length === 0) {
return;
}
const start = Date.now();
try {
await this.client.batch(queries);
const durationMs = Date.now() - start;
this.logger.debug(
{
query_count: queries.length,
duration_ms: durationMs,
},
'batch execute',
);
} catch (error) {
const durationMs = Date.now() - start;
this.logger.error(
{
query_count: queries.length,
duration_ms: durationMs,
error,
},
'batch execute error',
);
throw error;
}
}
public async executeConditional(query: PreparedQuery): Promise<ConditionalQueryResult> {
const result = await this.runQuery(
'conditional',
query,
async () => {
return this.client.execute(query);
},
{},
);
const applied = result.rows[0]?.['[applied]'] !== false;
return {applied};
}
private async runQuery<T>(
action: string,
query: PreparedQuery,
runner: () => Promise<T>,
context: Record<string, unknown>,
): Promise<T> {
const start = Date.now();
try {
const result = await runner();
const durationMs = Date.now() - start;
this.logger.debug(
{
action,
cql: query.cql,
params: query.params,
duration_ms: durationMs,
...context,
},
'action success',
);
return result;
} catch (error) {
const durationMs = Date.now() - start;
this.logger.error(
{
action,
cql: query.cql,
params: query.params,
duration_ms: durationMs,
error,
},
'action error',
);
throw error;
}
}
private static validateRows<Row>(rows: unknown): Array<Row> {
if (!Array.isArray(rows)) {
throw new Error('Expected Cassandra row array result');
}
for (const row of rows) {
if (row === null || typeof row !== 'object') {
throw new Error('Expected Cassandra rows to contain objects');
}
}
return rows as Array<Row>;
}
}
function createDefaultQueryExecutor(): CassandraQueryExecutor {
return new CassandraQueryExecutor(getDefaultCassandraClient(), getLogger());
}
export async function fetchMany<Row>(query: PreparedQuery): Promise<Array<Row>> {
return createDefaultQueryExecutor().fetchMany<Row>(query);
}
export async function fetchOne<Row>(query: PreparedQuery): Promise<Row | null> {
return createDefaultQueryExecutor().fetchOne<Row>(query);
}
export async function execute(query: PreparedQuery): Promise<void> {
await createDefaultQueryExecutor().execute(query);
}
export async function executeBatch(queries: Array<PreparedQuery>): Promise<void> {
await createDefaultQueryExecutor().executeBatch(queries);
}
export async function executeConditional(query: PreparedQuery): Promise<ConditionalQueryResult> {
return createDefaultQueryExecutor().executeConditional(query);
}

View File

@@ -0,0 +1,667 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {CassandraParams, CassandraValue, PreparedQuery} from '@fluxer/cassandra/src/CassandraTypes';
export type DbOp<T> = {kind: 'set'; value: T} | {kind: 'clear'};
export const Db = {
set<T>(value: T): DbOp<T> {
return {kind: 'set', value};
},
clear<T = never>(): DbOp<T> {
return {kind: 'clear'};
},
} as const;
export type ColumnName<Row> = Extract<keyof Row, string>;
type RowValue<Row, K extends ColumnName<Row>> = Row[K & keyof Row];
export type WhereExpr<Row extends object> =
| {kind: 'eq'; col: ColumnName<Row>; param: string}
| {kind: 'in'; col: ColumnName<Row>; param: string}
| {kind: 'lt'; col: ColumnName<Row>; param: string}
| {kind: 'gt'; col: ColumnName<Row>; param: string}
| {kind: 'lte'; col: ColumnName<Row>; param: string}
| {kind: 'gte'; col: ColumnName<Row>; param: string}
| {kind: 'tokenGt'; col: ColumnName<Row>; param: string}
| {kind: 'tupleGt'; cols: ReadonlyArray<ColumnName<Row>>; params: ReadonlyArray<string>};
export type OrderBy<Row extends object> = {col: ColumnName<Row>; direction?: 'ASC' | 'DESC' | undefined};
export interface QueryTemplate<P extends CassandraParams = CassandraParams> {
cql: string;
bind(params: P): PreparedQuery<P>;
}
export interface TableSelectOptions<Row extends object> {
columns?: ReadonlyArray<ColumnName<Row>> | undefined;
where?: WhereExpr<Row> | ReadonlyArray<WhereExpr<Row>> | undefined;
orderBy?: OrderBy<Row> | undefined;
limit?: number | undefined;
}
export interface TableDeleteOptions<Row extends object> {
where?: WhereExpr<Row> | ReadonlyArray<WhereExpr<Row>> | undefined;
}
export interface TableCountOptions<Row extends object> {
where?: WhereExpr<Row> | ReadonlyArray<WhereExpr<Row>> | undefined;
}
export interface TableDefinition<Row extends object, PK extends ColumnName<Row>, PartKey extends ColumnName<Row> = PK> {
name: string;
columns: ReadonlyArray<ColumnName<Row>>;
primaryKey: ReadonlyArray<PK>;
partitionKey?: ReadonlyArray<PartKey> | undefined;
}
export type TablePatch<Row extends object, PK extends ColumnName<Row>> = Partial<{
[K in Exclude<ColumnName<Row>, PK>]: DbOp<RowValue<Row, K>>;
}>;
export interface Table<Row extends object, PK extends ColumnName<Row>, PartKey extends ColumnName<Row> = PK> {
name: string;
columns: ReadonlyArray<ColumnName<Row>>;
primaryKey: ReadonlyArray<PK>;
partitionKey: ReadonlyArray<PartKey>;
selectCql(opts?: TableSelectOptions<Row>): string;
select(opts?: TableSelectOptions<Row>): QueryTemplate;
updateAllCql(): string;
paramsFromRow(row: Row): CassandraParams;
upsertAll(row: Row): PreparedQuery;
patchByPk(pk: Pick<Row, PK>, patch: TablePatch<Row, PK>): PreparedQuery;
deleteCql(opts?: TableDeleteOptions<Row>): string;
delete(opts?: TableDeleteOptions<Row>): QueryTemplate;
deleteByPk(pk: Pick<Row, PK>): PreparedQuery;
deletePartition(pk: Pick<Row, PartKey>): PreparedQuery;
insertCql(opts?: {ttlParam?: string | undefined}): string;
insert(row: Row): PreparedQuery;
insertWithTtl(row: Row, ttlSeconds: number): PreparedQuery;
insertWithTtlParam(row: Row, ttlParamName: string): PreparedQuery;
insertIfNotExists(row: Row): PreparedQuery;
selectCountCql(opts?: TableCountOptions<Row>): string;
selectCount(opts?: TableCountOptions<Row>): QueryTemplate;
insertWithNow<NowCol extends ColumnName<Row>>(row: Omit<Row, NowCol>, nowColumn: NowCol): PreparedQuery;
patchByPkWithTtl(pk: Pick<Row, PK>, patch: TablePatch<Row, PK>, ttlSeconds: number): PreparedQuery;
patchByPkWithTtlParam(
pk: Pick<Row, PK>,
patch: TablePatch<Row, PK>,
ttlParamName: string,
ttlValue: number,
): PreparedQuery;
upsertAllWithTtl(row: Row, ttlSeconds: number): PreparedQuery;
upsertAllWithTtlParam(row: Row, ttlParamName: string, ttlValue: number): PreparedQuery;
patchByPkIf<CondCol extends Exclude<ColumnName<Row>, PK>>(
pk: Pick<Row, PK>,
patch: TablePatch<Row, PK>,
condition: {col: CondCol; expectedParam: string; expectedValue: RowValue<Row, CondCol>},
): PreparedQuery;
where: {
eq: <K extends ColumnName<Row>>(col: K, param?: string) => WhereExpr<Row>;
in: <K extends ColumnName<Row>>(col: K, param: string) => WhereExpr<Row>;
lt: <K extends ColumnName<Row>>(col: K, param?: string) => WhereExpr<Row>;
gt: <K extends ColumnName<Row>>(col: K, param?: string) => WhereExpr<Row>;
lte: <K extends ColumnName<Row>>(col: K, param?: string) => WhereExpr<Row>;
gte: <K extends ColumnName<Row>>(col: K, param?: string) => WhereExpr<Row>;
tokenGt: <K extends ColumnName<Row>>(col: K, param: string) => WhereExpr<Row>;
tupleGt: <K extends ColumnName<Row>>(cols: ReadonlyArray<K>, params: ReadonlyArray<string>) => WhereExpr<Row>;
};
}
function prepared<P extends CassandraParams>(cql: string, params: P): PreparedQuery<P> {
return {cql, params};
}
function asWhereArray<Row extends object>(
where: WhereExpr<Row> | ReadonlyArray<WhereExpr<Row>> | undefined,
): Array<WhereExpr<Row>> {
if (where === undefined) {
return [];
}
if (Array.isArray(where)) {
return [...where];
}
return [where as WhereExpr<Row>];
}
function compileWhere<Row extends object>(where: WhereExpr<Row>): string {
switch (where.kind) {
case 'eq':
return `${where.col} = :${where.param}`;
case 'in':
return `${where.col} IN :${where.param}`;
case 'lt':
return `${where.col} < :${where.param}`;
case 'gt':
return `${where.col} > :${where.param}`;
case 'lte':
return `${where.col} <= :${where.param}`;
case 'gte':
return `${where.col} >= :${where.param}`;
case 'tokenGt':
return `TOKEN(${where.col}) > TOKEN(:${where.param})`;
case 'tupleGt': {
if (where.cols.length === 0 || where.cols.length !== where.params.length) {
throw new Error('tupleGt requires equal-length, non-empty cols and params.');
}
const cols = `(${where.cols.join(', ')})`;
const params = `(${where.params.map((paramName) => `:${paramName}`).join(', ')})`;
return `${cols} > ${params}`;
}
default: {
const exhaustive: never = where;
return exhaustive;
}
}
}
function compileWhereClause<Row extends object>(
where: WhereExpr<Row> | ReadonlyArray<WhereExpr<Row>> | undefined,
): string {
const clauses = asWhereArray(where);
if (clauses.length === 0) {
return '';
}
return ` WHERE ${clauses.map((clause) => compileWhere(clause)).join(' AND ')}`;
}
function toCassandraValue(op: DbOp<unknown>, tableName: string, columnName: string): CassandraValue {
if (op.kind === 'clear') {
return null;
}
if (op.value === undefined) {
throw new Error(`Patch value for "${tableName}.${columnName}" is undefined. Use Db.clear() to write null.`);
}
return op.value as CassandraValue;
}
function ensureUnique(values: ReadonlyArray<string>, fieldName: string, tableName: string): void {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
throw new Error(`Table "${tableName}" contains duplicate ${fieldName} value "${value}".`);
}
seen.add(value);
}
}
function assertValidTableDefinition<Row extends object, PK extends ColumnName<Row>, PartKey extends ColumnName<Row>>(
definition: TableDefinition<Row, PK, PartKey>,
): void {
const tableName = definition.name;
if (definition.columns.length === 0) {
throw new Error(`Table "${tableName}" must define at least one column.`);
}
if (definition.primaryKey.length === 0) {
throw new Error(`Table "${tableName}" must define at least one primary key column.`);
}
const columns = [...definition.columns] as Array<string>;
const primaryKey = [...definition.primaryKey] as Array<string>;
const partitionKey = [...(definition.partitionKey ?? definition.primaryKey)] as Array<string>;
ensureUnique(columns, 'column', tableName);
ensureUnique(primaryKey, 'primary key', tableName);
ensureUnique(partitionKey, 'partition key', tableName);
const columnSet = new Set<string>(columns);
for (const keyColumn of primaryKey) {
if (!columnSet.has(keyColumn)) {
throw new Error(`Primary key column "${tableName}.${keyColumn}" must exist in table columns.`);
}
}
const primaryKeySet = new Set<string>(primaryKey);
for (const partitionColumn of partitionKey) {
if (!columnSet.has(partitionColumn)) {
throw new Error(`Partition key column "${tableName}.${partitionColumn}" must exist in table columns.`);
}
if (!primaryKeySet.has(partitionColumn)) {
throw new Error(`Partition key column "${tableName}.${partitionColumn}" must also be part of the primary key.`);
}
}
}
interface PatchCqlOptions<Row extends object> {
ttlSeconds?: number | undefined;
ttlParamName?: string | undefined;
condition?: {col: ColumnName<Row>; expectedParam: string} | undefined;
}
class CassandraTable<Row extends object, PK extends ColumnName<Row>, PartKey extends ColumnName<Row> = PK>
implements Table<Row, PK, PartKey>
{
public readonly name: string;
public readonly columns: ReadonlyArray<ColumnName<Row>>;
public readonly primaryKey: ReadonlyArray<PK>;
public readonly partitionKey: ReadonlyArray<PartKey>;
public readonly where: Table<Row, PK, PartKey>['where'];
private readonly nonPrimaryKeyColumns: Array<Exclude<ColumnName<Row>, PK>>;
private readonly updateAllStatement: string;
private readonly deleteByPrimaryKeyStatement: string;
private readonly insertBaseStatement: string;
public constructor(definition: TableDefinition<Row, PK, PartKey>) {
assertValidTableDefinition(definition);
this.name = definition.name;
this.columns = [...definition.columns];
this.primaryKey = [...definition.primaryKey];
this.partitionKey = definition.partitionKey
? [...definition.partitionKey]
: ([...definition.primaryKey] as unknown as Array<PartKey>);
this.nonPrimaryKeyColumns = this.columns.filter((column) => {
return !this.primaryKey.includes(column as PK);
}) as Array<Exclude<ColumnName<Row>, PK>>;
this.updateAllStatement = this.buildUpdateAllStatement();
this.deleteByPrimaryKeyStatement = `DELETE FROM ${this.name} WHERE ${this.primaryKeyWhereClause()}`;
this.insertBaseStatement = `INSERT INTO ${this.name} (${this.columns.join(', ')}) VALUES (${this.columns.map((column) => `:${column}`).join(', ')})`;
this.where = {
eq<K extends ColumnName<Row>>(col: K, param?: string): WhereExpr<Row> {
return {kind: 'eq', col, param: param ?? col};
},
in<K extends ColumnName<Row>>(col: K, param: string): WhereExpr<Row> {
return {kind: 'in', col, param};
},
lt<K extends ColumnName<Row>>(col: K, param?: string): WhereExpr<Row> {
return {kind: 'lt', col, param: param ?? col};
},
gt<K extends ColumnName<Row>>(col: K, param?: string): WhereExpr<Row> {
return {kind: 'gt', col, param: param ?? col};
},
lte<K extends ColumnName<Row>>(col: K, param?: string): WhereExpr<Row> {
return {kind: 'lte', col, param: param ?? col};
},
gte<K extends ColumnName<Row>>(col: K, param?: string): WhereExpr<Row> {
return {kind: 'gte', col, param: param ?? col};
},
tokenGt<K extends ColumnName<Row>>(col: K, param: string): WhereExpr<Row> {
return {kind: 'tokenGt', col, param};
},
tupleGt<K extends ColumnName<Row>>(cols: ReadonlyArray<K>, params: ReadonlyArray<string>): WhereExpr<Row> {
return {kind: 'tupleGt', cols, params};
},
};
}
public selectCql(options: TableSelectOptions<Row> = {}): string {
const selectedColumns = (options.columns ?? this.columns).join(', ');
const whereClause = compileWhereClause(options.where);
const orderByClause = options.orderBy
? ` ORDER BY ${options.orderBy.col} ${options.orderBy.direction ?? 'ASC'}`
: '';
const limitClause = this.limitClause(options.limit);
return `SELECT ${selectedColumns} FROM ${this.name}${whereClause}${orderByClause}${limitClause}`;
}
public select(options: TableSelectOptions<Row> = {}): QueryTemplate {
const cql = this.selectCql(options);
return this.toTemplate(cql);
}
public updateAllCql(): string {
return this.updateAllStatement;
}
public paramsFromRow(row: Row): CassandraParams {
return this.collectRowParams(row, true);
}
public upsertAll(row: Row): PreparedQuery {
if (this.hasAllColumns(row)) {
return prepared(this.updateAllStatement, this.collectRowParams(row, true));
}
return this.buildDynamicUpsert(row);
}
public patchByPk(pk: Pick<Row, PK>, patch: TablePatch<Row, PK>): PreparedQuery {
const patchColumns = this.patchColumns(patch, 'PATCH update');
const cql = this.buildPatchStatement(patchColumns);
const params = this.primaryKeyParams(pk);
this.appendPatchParams(params, patch, patchColumns);
return prepared(cql, params);
}
public deleteCql(options: TableDeleteOptions<Row> = {}): string {
const whereClause = options.where ? compileWhereClause(options.where) : ` WHERE ${this.primaryKeyWhereClause()}`;
return `DELETE FROM ${this.name}${whereClause}`;
}
public delete(options: TableDeleteOptions<Row> = {}): QueryTemplate {
const cql = this.deleteCql(options);
return this.toTemplate(cql);
}
public deleteByPk(pk: Pick<Row, PK>): PreparedQuery {
return prepared(this.deleteByPrimaryKeyStatement, this.primaryKeyParams(pk));
}
public deletePartition(pk: Pick<Row, PartKey>): PreparedQuery {
if (this.partitionKey.length === 0) {
throw new Error(`Table "${this.name}" has no partition key columns.`);
}
const cql = `DELETE FROM ${this.name} WHERE ${this.partitionKeyWhereClause()}`;
return prepared(cql, this.partitionKeyParams(pk));
}
public insertCql(options: {ttlParam?: string | undefined} = {}): string {
if (!options.ttlParam) {
return this.insertBaseStatement;
}
return `${this.insertBaseStatement} USING TTL :${options.ttlParam}`;
}
public insert(row: Row): PreparedQuery {
return prepared(this.insertBaseStatement, this.collectRowParams(row, true));
}
public insertWithTtl(row: Row, ttlSeconds: number): PreparedQuery {
const cql = `${this.insertBaseStatement} USING TTL ${ttlSeconds}`;
return prepared(cql, this.collectRowParams(row, true));
}
public insertWithTtlParam(row: Row, ttlParamName: string): PreparedQuery {
const cql = `${this.insertBaseStatement} USING TTL :${ttlParamName}`;
const params = this.collectRowParams(row, true);
const rowRecord = row as Record<string, unknown>;
const ttlValue = rowRecord[ttlParamName];
if (ttlValue === undefined) {
throw new Error(
`Row is missing TTL param value "${this.name}.${ttlParamName}". Include this field or use insertWithTtl().`,
);
}
params[ttlParamName] = ttlValue as CassandraValue;
return prepared(cql, params);
}
public insertIfNotExists(row: Row): PreparedQuery {
const cql = `${this.insertBaseStatement} IF NOT EXISTS`;
return prepared(cql, this.collectRowParams(row, true));
}
public selectCountCql(options: TableCountOptions<Row> = {}): string {
const whereClause = compileWhereClause(options.where);
return `SELECT COUNT(*) as count FROM ${this.name}${whereClause}`;
}
public selectCount(options: TableCountOptions<Row> = {}): QueryTemplate {
const cql = this.selectCountCql(options);
return this.toTemplate(cql);
}
public insertWithNow<NowCol extends ColumnName<Row>>(row: Omit<Row, NowCol>, nowColumn: NowCol): PreparedQuery {
if (!this.columns.includes(nowColumn)) {
throw new Error(`Column "${this.name}.${nowColumn}" does not exist.`);
}
const columns = this.columns.filter((column) => {
return column !== nowColumn;
});
const cql = `INSERT INTO ${this.name} (${[...columns, nowColumn].join(', ')}) VALUES (${columns.map((column) => `:${column}`).join(', ')}, now())`;
const rowRecord = row as Record<string, unknown>;
const params: CassandraParams = {};
for (const column of columns) {
const value = rowRecord[column];
if (value === undefined) {
throw new Error(`Row is missing value for "${this.name}.${column}". INSERT requires all non-now columns.`);
}
params[column] = value as CassandraValue;
}
return prepared(cql, params);
}
public patchByPkWithTtl(pk: Pick<Row, PK>, patch: TablePatch<Row, PK>, ttlSeconds: number): PreparedQuery {
const patchColumns = this.patchColumns(patch, 'PATCH update');
const cql = this.buildPatchStatement(patchColumns, {ttlSeconds});
const params = this.primaryKeyParams(pk);
this.appendPatchParams(params, patch, patchColumns);
return prepared(cql, params);
}
public patchByPkWithTtlParam(
pk: Pick<Row, PK>,
patch: TablePatch<Row, PK>,
ttlParamName: string,
ttlValue: number,
): PreparedQuery {
const patchColumns = this.patchColumns(patch, 'PATCH update');
const cql = this.buildPatchStatement(patchColumns, {ttlParamName});
const params = this.primaryKeyParams(pk);
this.appendPatchParams(params, patch, patchColumns);
params[ttlParamName] = ttlValue;
return prepared(cql, params);
}
public upsertAllWithTtl(row: Row, ttlSeconds: number): PreparedQuery {
const cql = this.nonPrimaryKeyColumns.length
? `UPDATE ${this.name} USING TTL ${ttlSeconds} SET ${this.nonPrimaryKeyColumns.map((column) => `${column} = :${column}`).join(', ')} WHERE ${this.primaryKeyWhereClause()}`
: `${this.insertBaseStatement} USING TTL ${ttlSeconds}`;
return prepared(cql, this.collectRowParams(row, true));
}
public upsertAllWithTtlParam(row: Row, ttlParamName: string, ttlValue: number): PreparedQuery {
const cql = this.nonPrimaryKeyColumns.length
? `UPDATE ${this.name} USING TTL :${ttlParamName} SET ${this.nonPrimaryKeyColumns.map((column) => `${column} = :${column}`).join(', ')} WHERE ${this.primaryKeyWhereClause()}`
: `${this.insertBaseStatement} USING TTL :${ttlParamName}`;
const params = this.collectRowParams(row, true);
params[ttlParamName] = ttlValue;
return prepared(cql, params);
}
public patchByPkIf<CondCol extends Exclude<ColumnName<Row>, PK>>(
pk: Pick<Row, PK>,
patch: TablePatch<Row, PK>,
condition: {col: CondCol; expectedParam: string; expectedValue: RowValue<Row, CondCol>},
): PreparedQuery {
const patchColumns = this.patchColumns(patch, 'conditional PATCH update');
const cql = this.buildPatchStatement(patchColumns, {
condition: {
col: condition.col,
expectedParam: condition.expectedParam,
},
});
const params = this.primaryKeyParams(pk);
this.appendPatchParams(params, patch, patchColumns);
params[condition.expectedParam] = condition.expectedValue as CassandraValue;
return prepared(cql, params);
}
private toTemplate(cql: string): QueryTemplate {
return {
cql,
bind<P extends CassandraParams>(params: P): PreparedQuery<P> {
return prepared(cql, params);
},
};
}
private buildUpdateAllStatement(): string {
if (this.nonPrimaryKeyColumns.length === 0) {
return this.insertBaseStatement;
}
const setClause = this.nonPrimaryKeyColumns.map((column) => `${column} = :${column}`).join(', ');
return `UPDATE ${this.name} SET ${setClause} WHERE ${this.primaryKeyWhereClause()}`;
}
private collectRowParams(row: Row, requireAllColumns: boolean): CassandraParams {
const rowRecord = row as Record<string, unknown>;
const params: CassandraParams = {};
for (const column of this.columns) {
const value = rowRecord[column];
if (value === undefined) {
if (requireAllColumns) {
throw new Error(
`Row is missing value for "${this.name}.${column}". Full-row operations require every column to be present.`,
);
}
continue;
}
params[column] = value as CassandraValue;
}
return params;
}
private hasAllColumns(row: Row): boolean {
const rowRecord = row as Record<string, unknown>;
for (const column of this.columns) {
if (rowRecord[column] === undefined) {
return false;
}
}
return true;
}
private buildDynamicUpsert(row: Row): PreparedQuery {
const rowRecord = row as Record<string, unknown>;
const params: CassandraParams = {};
const presentColumns: Array<ColumnName<Row>> = [];
for (const column of this.columns) {
const value = rowRecord[column];
if (value === undefined) {
continue;
}
presentColumns.push(column);
params[column] = value as CassandraValue;
}
for (const keyColumn of this.primaryKey) {
if (params[keyColumn] === undefined) {
throw new Error(
`Row is missing value for "${this.name}.${keyColumn}". Dynamic upserts require all primary key columns to be present.`,
);
}
}
const mutableColumns = presentColumns.filter((column) => {
return !this.primaryKey.includes(column as PK);
});
const cql = mutableColumns.length
? `UPDATE ${this.name} SET ${mutableColumns.map((column) => `${column} = :${column}`).join(', ')} WHERE ${this.primaryKeyWhereClause()}`
: `INSERT INTO ${this.name} (${this.primaryKey.join(', ')}) VALUES (${this.primaryKey.map((column) => `:${column}`).join(', ')})`;
return prepared(cql, params);
}
private patchColumns(patch: TablePatch<Row, PK>, actionName: string): Array<Exclude<ColumnName<Row>, PK>> {
const columns = Object.keys(patch) as Array<Exclude<ColumnName<Row>, PK>>;
if (columns.length === 0) {
throw new Error(`Refusing to execute empty ${actionName} on table "${this.name}".`);
}
columns.sort((left, right) => {
return this.columns.indexOf(left) - this.columns.indexOf(right);
});
return columns;
}
private buildPatchStatement(
patchColumns: Array<Exclude<ColumnName<Row>, PK>>,
options: PatchCqlOptions<Row> = {},
): string {
const setClause = patchColumns.map((column) => `${column} = :${column}`).join(', ');
const ttlClause =
options.ttlSeconds !== undefined
? ` USING TTL ${options.ttlSeconds}`
: options.ttlParamName
? ` USING TTL :${options.ttlParamName}`
: '';
const conditionClause = options.condition
? ` IF ${options.condition.col} = :${options.condition.expectedParam}`
: '';
return `UPDATE ${this.name}${ttlClause} SET ${setClause} WHERE ${this.primaryKeyWhereClause()}${conditionClause}`;
}
private appendPatchParams(
params: CassandraParams,
patch: TablePatch<Row, PK>,
patchColumns: Array<Exclude<ColumnName<Row>, PK>>,
): void {
for (const column of patchColumns) {
const op = patch[column];
if (!op) {
throw new Error(`Patch operation for "${this.name}.${column}" is missing.`);
}
params[column] = toCassandraValue(op, this.name, column);
}
}
private primaryKeyParams(pk: Pick<Row, PK>): CassandraParams {
const record = pk as Record<string, unknown>;
const params: CassandraParams = {};
for (const keyColumn of this.primaryKey) {
const value = record[keyColumn];
if (value === undefined) {
throw new Error(`Primary key value is missing for "${this.name}.${keyColumn}".`);
}
params[keyColumn] = value as CassandraValue;
}
return params;
}
private partitionKeyParams(pk: Pick<Row, PartKey>): CassandraParams {
const record = pk as Record<string, unknown>;
const params: CassandraParams = {};
for (const keyColumn of this.partitionKey) {
const value = record[keyColumn];
if (value === undefined) {
throw new Error(`Partition key value is missing for "${this.name}.${keyColumn}".`);
}
params[keyColumn] = value as CassandraValue;
}
return params;
}
private primaryKeyWhereClause(): string {
return this.primaryKey.map((column) => `${column} = :${column}`).join(' AND ');
}
private partitionKeyWhereClause(): string {
return this.partitionKey.map((column) => `${column} = :${column}`).join(' AND ');
}
private limitClause(limit: number | undefined): string {
if (limit === undefined) {
return '';
}
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error(`SELECT limit for "${this.name}" must be a positive integer.`);
}
return ` LIMIT ${limit}`;
}
}
export function defineTable<Row extends object, PK extends ColumnName<Row>, PartKey extends ColumnName<Row> = PK>(
definition: TableDefinition<Row, PK, PartKey>,
): Table<Row, PK, PartKey> {
return new CassandraTable(definition);
}

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfigs/package.json",
"compilerOptions": {},
"include": ["src/**/*"]
}