diff --git a/package.json b/package.json index 70fa104..bf7f334 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "lint": "eslint . --fix --report-unused-disable-directives", "format": "prettier --write .", "test": "yarn workspaces foreach -vvpA run test --run --clearScreen false", - "build": "yarn workspaces foreach -vvpA --topological-dev run build", + "build": "yarn workspaces foreach -vvpA --topological run build", "add-package": "tsx ./scripts/add-package.ts", "postinstall": "husky" } diff --git a/packages/fabric/domain/package.json b/packages/fabric/domain/package.json index a9cd059..c44c9e6 100644 --- a/packages/fabric/domain/package.json +++ b/packages/fabric/domain/package.json @@ -14,7 +14,8 @@ "vitest": "^2.1.1" }, "dependencies": { - "@fabric/core": "workspace:^" + "@fabric/core": "workspace:^", + "decimal.js": "^10.4.3" }, "scripts": { "test": "vitest", diff --git a/packages/fabric/domain/src/models/fields/decimal.ts b/packages/fabric/domain/src/models/fields/decimal.ts new file mode 100644 index 0000000..568feff --- /dev/null +++ b/packages/fabric/domain/src/models/fields/decimal.ts @@ -0,0 +1,21 @@ +import { TaggedVariant, VariantTag } from "@fabric/core"; +import { BaseField } from "./base-field.js"; + +export interface DecimalFieldOptions extends BaseField { + isUnsigned?: boolean; + precision?: number; + scale?: number; +} + +export interface DecimalField + extends TaggedVariant<"DecimalField">, + DecimalFieldOptions {} + +export function createDecimalField( + opts: T = {} as T, +): DecimalField & T { + return { + [VariantTag]: "DecimalField", + ...opts, + } as const; +} diff --git a/packages/fabric/domain/src/models/fields/field-to-type.ts b/packages/fabric/domain/src/models/fields/field-to-type.ts index 16d1bff..b7f67b9 100644 --- a/packages/fabric/domain/src/models/fields/field-to-type.ts +++ b/packages/fabric/domain/src/models/fields/field-to-type.ts @@ -1,4 +1,7 @@ +import { Decimal } from "decimal.js"; import { UUID } from "../../types/uuid.js"; +import { DecimalField } from "./decimal.js"; +import { FloatField } from "./float.js"; import { IntegerField } from "./integer.js"; import { ReferenceField } from "./reference-field.js"; import { StringField } from "./string-field.js"; @@ -7,20 +10,23 @@ import { UUIDField } from "./uuid-field.js"; /** * Converts a field definition to its corresponding TypeScript type. */ -export type FieldToType = TField extends StringField - ? ToOptional - : TField extends UUIDField - ? ToOptional - : TField extends IntegerField - ? TField["hasArbitraryPrecision"] extends true - ? ToOptional - : TField["hasArbitraryPrecision"] extends false - ? ToOptional - : ToOptional - : TField extends ReferenceField - ? ToOptional - : never; +//prettier-ignore +export type FieldToType = + TField extends StringField ? MaybeOptional + : TField extends UUIDField ? MaybeOptional + : TField extends IntegerField ? IntegerFieldToType + : TField extends ReferenceField ? MaybeOptional + : TField extends DecimalField ? MaybeOptional + : TField extends FloatField ? MaybeOptional + : never; -type ToOptional = TField extends { isOptional: true } +//prettier-ignore +type IntegerFieldToType = TField["hasArbitraryPrecision"] extends true + ? MaybeOptional + : TField["hasArbitraryPrecision"] extends false + ? MaybeOptional + : MaybeOptional; + +type MaybeOptional = TField extends { isOptional: true } ? TType | null : TType; diff --git a/packages/fabric/domain/src/models/fields/float.ts b/packages/fabric/domain/src/models/fields/float.ts new file mode 100644 index 0000000..fd67bcc --- /dev/null +++ b/packages/fabric/domain/src/models/fields/float.ts @@ -0,0 +1,18 @@ +import { TaggedVariant, VariantTag } from "@fabric/core"; +import { BaseField } from "./base-field.js"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface FloatFieldOptions extends BaseField {} + +export interface FloatField + extends TaggedVariant<"FloatField">, + FloatFieldOptions {} + +export function createFloatField( + opts: T = {} as T, +): FloatField & T { + return { + [VariantTag]: "FloatField", + ...opts, + } as const; +} diff --git a/packages/fabric/domain/src/models/fields/index.ts b/packages/fabric/domain/src/models/fields/index.ts index b29c267..0f1b30a 100644 --- a/packages/fabric/domain/src/models/fields/index.ts +++ b/packages/fabric/domain/src/models/fields/index.ts @@ -1,3 +1,5 @@ +import { createDecimalField, DecimalField } from "./decimal.js"; +import { createFloatField, FloatField } from "./float.js"; import { createIntegerField, IntegerField } from "./integer.js"; import { createReferenceField, ReferenceField } from "./reference-field.js"; import { createStringField, StringField } from "./string-field.js"; @@ -10,6 +12,8 @@ export type FieldDefinition = | StringField | UUIDField | IntegerField + | FloatField + | DecimalField | ReferenceField; export namespace Field { @@ -17,4 +21,6 @@ export namespace Field { export const uuid = createUUIDField; export const integer = createIntegerField; export const reference = createReferenceField; + export const decimal = createDecimalField; + export const float = createFloatField; } diff --git a/packages/fabric/domain/src/models/model.ts b/packages/fabric/domain/src/models/model.ts index 34520bb..a79321b 100644 --- a/packages/fabric/domain/src/models/model.ts +++ b/packages/fabric/domain/src/models/model.ts @@ -4,6 +4,14 @@ import { Field, FieldDefinition } from "./fields/index.js"; export type CustomModelFields = Record; +export interface Collection< + TName extends string = string, + TFields extends CustomModelFields = CustomModelFields, +> { + name: TName; + fields: TFields; +} + export const DefaultModelFields = { id: Field.uuid({ isPrimaryKey: true }), streamId: Field.uuid({ isIndexed: true }), @@ -12,11 +20,11 @@ export const DefaultModelFields = { hasArbitraryPrecision: true, }), }; + export interface Model< TName extends string = string, TFields extends CustomModelFields = CustomModelFields, -> { - name: TName; +> extends Collection { fields: typeof DefaultModelFields & TFields; } @@ -30,6 +38,16 @@ export function defineModel< } as const; } +export function defineCollection< + TName extends string, + TFields extends CustomModelFields, +>(name: TName, fields: TFields): Collection { + return { + name, + fields, + } as const; +} + export type ModelToType = { [K in Keyof]: FieldToType; }; diff --git a/packages/fabric/domain/src/models/query/filter-options.ts b/packages/fabric/domain/src/models/query/filter-options.ts index 4a18f7c..9a86305 100644 --- a/packages/fabric/domain/src/models/query/filter-options.ts +++ b/packages/fabric/domain/src/models/query/filter-options.ts @@ -4,12 +4,14 @@ export type FilterOptions = | SingleFilterOption | MultiFilterOption; +export type FilterValue = + | T[K] + | LikeFilterOption + | ComparisonFilterOption + | InFilterOption; + export type SingleFilterOption = { - [K in keyof T]?: - | T[K] - | LikeFilterOption - | ComparisonFilterOption - | InFilterOption; + [K in keyof T]?: FilterValue; }; export type MultiFilterOption = SingleFilterOption[]; diff --git a/packages/fabric/domain/src/models/state-store.spec.ts b/packages/fabric/domain/src/models/state-store.spec.ts index 1f63dd1..7e30408 100644 --- a/packages/fabric/domain/src/models/state-store.spec.ts +++ b/packages/fabric/domain/src/models/state-store.spec.ts @@ -1,37 +1,54 @@ import { isError } from "@fabric/core"; import { SQLiteStorageDriver } from "@fabric/store-sqlite"; -import { describe, it } from "vitest"; -import { generateUUID } from "../types/uuid.js"; +import { + afterEach, + beforeEach, + describe, + expect, + expectTypeOf, + it, +} from "vitest"; +import { UUIDGeneratorMock } from "../services/uuid-generator.mock.js"; +import { UUID } from "../types/uuid.js"; import { Field } from "./fields/index.js"; import { defineModel } from "./model.js"; +import { isLike } from "./query/filter-options.js"; import { StateStore } from "./state-store.js"; describe("State Store", () => { - const driver = new SQLiteStorageDriver(":memory:"); - const models = [ defineModel("users", { name: Field.string(), }), ]; - it("should be able to create a new state store and migrate", async () => { - const store = new StateStore(driver, models); + let driver: SQLiteStorageDriver; + let store: StateStore<(typeof models)[number]>; + beforeEach(async () => { + driver = new SQLiteStorageDriver(":memory:"); + store = new StateStore(driver, models); const migrationResult = await store.migrate(); - if (isError(migrationResult)) throw migrationResult; }); - it("should be able to insert a record", async () => { - const store = new StateStore(driver, models); + afterEach(async () => { + await driver.close(); + }); - const migrationResult = await store.migrate(); - - if (isError(migrationResult)) throw migrationResult; - - const newUUID = generateUUID(); + it("should insert a record", async () => { + const newUUID = UUIDGeneratorMock.generate(); + const insertResult = await store.insertInto("users", { + name: "test", + id: newUUID, + streamId: newUUID, + streamVersion: 1n, + }); + if (isError(insertResult)) throw insertResult; + }); + it("should query with a basic select", async () => { + const newUUID = UUIDGeneratorMock.generate(); const insertResult = await store.insertInto("users", { name: "test", id: newUUID, @@ -40,5 +57,77 @@ describe("State Store", () => { }); if (isError(insertResult)) throw insertResult; + + const result = await store.from("users").select(); + + if (isError(result)) throw result; + + expectTypeOf(result).toEqualTypeOf< + { + id: UUID; + streamId: UUID; + streamVersion: bigint; + name: string; + }[] + >(); + + expect(result).toEqual([ + { + id: newUUID, + streamId: newUUID, + streamVersion: 1n, + name: "test", + }, + ]); + }); + + it("should query with a where clause", async () => { + const newUUID = UUIDGeneratorMock.generate(); + await store.insertInto("users", { + name: "test", + id: newUUID, + streamId: newUUID, + streamVersion: 1n, + }); + + await store.insertInto("users", { + name: "anotherName", + id: UUIDGeneratorMock.generate(), + streamId: UUIDGeneratorMock.generate(), + streamVersion: 1n, + }); + await store.insertInto("users", { + name: "anotherName2", + id: UUIDGeneratorMock.generate(), + streamId: UUIDGeneratorMock.generate(), + streamVersion: 1n, + }); + + const result = await store + .from("users") + .where({ + name: isLike("te*"), + }) + .select(); + + if (isError(result)) throw result; + + expectTypeOf(result).toEqualTypeOf< + { + id: UUID; + streamId: UUID; + streamVersion: bigint; + name: string; + }[] + >(); + + expect(result).toEqual([ + { + id: newUUID, + streamId: newUUID, + streamVersion: 1n, + name: "test", + }, + ]); }); }); diff --git a/packages/fabric/domain/src/models/state-store.ts b/packages/fabric/domain/src/models/state-store.ts index 1dcfa25..292bc7e 100644 --- a/packages/fabric/domain/src/models/state-store.ts +++ b/packages/fabric/domain/src/models/state-store.ts @@ -3,6 +3,8 @@ import { StoreQueryError } from "../errors/query-error.js"; import { StorageDriver } from "../storage/storage-driver.js"; import { ModelSchemaFromModels } from "./model-schema.js"; import { Model, ModelToType } from "./model.js"; +import { QueryBuilder } from "./query/query-builder.js"; +import { StoreQuery } from "./query/query.js"; export class StateStore { private schema: ModelSchemaFromModels; @@ -28,4 +30,12 @@ export class StateStore { ): AsyncResult { return this.driver.insert(this.schema[collection], record); } + + from>( + collection: T, + ): StoreQuery[T]>> { + return new QueryBuilder(this.driver, this.schema, { + from: collection, + }) as StoreQuery[T]>>; + } } diff --git a/packages/fabric/domain/src/services/uuid-generator.mock.ts b/packages/fabric/domain/src/services/uuid-generator.mock.ts new file mode 100644 index 0000000..3b9674d --- /dev/null +++ b/packages/fabric/domain/src/services/uuid-generator.mock.ts @@ -0,0 +1,8 @@ +import { UUID } from "../types/uuid.js"; +import { UUIDGenerator } from "./uuid-generator.js"; + +export const UUIDGeneratorMock: UUIDGenerator = { + generate(): UUID { + return crypto.randomUUID() as UUID; + }, +}; diff --git a/packages/fabric/domain/src/services/uuid-generator.ts b/packages/fabric/domain/src/services/uuid-generator.ts new file mode 100644 index 0000000..34c145e --- /dev/null +++ b/packages/fabric/domain/src/services/uuid-generator.ts @@ -0,0 +1,5 @@ +import { UUID } from "../types/uuid.js"; + +export interface UUIDGenerator { + generate(): UUID; +} diff --git a/packages/fabric/domain/src/storage/storage-driver.ts b/packages/fabric/domain/src/storage/storage-driver.ts index 730463d..6fc6c03 100644 --- a/packages/fabric/domain/src/storage/storage-driver.ts +++ b/packages/fabric/domain/src/storage/storage-driver.ts @@ -4,7 +4,7 @@ import { AsyncResult, UnexpectedError } from "@fabric/core"; import { CircularDependencyError } from "../errors/circular-dependency-error.js"; import { StoreQueryError } from "../errors/query-error.js"; import { ModelSchema } from "../models/model-schema.js"; -import { Model } from "../models/model.js"; +import { Collection } from "../models/model.js"; import { QueryDefinition } from "../models/query/query.js"; export interface StorageDriver { @@ -12,7 +12,7 @@ export interface StorageDriver { * Insert data into the store */ insert( - model: Model, + model: Collection, record: Record, ): AsyncResult; @@ -20,7 +20,7 @@ export interface StorageDriver { * Run a select query against the store. */ select( - model: Model, + model: Collection, query: QueryDefinition, ): AsyncResult; @@ -28,7 +28,7 @@ export interface StorageDriver { * Run a select query against the store. */ selectOne( - model: Model, + model: Collection, query: QueryDefinition, ): AsyncResult; @@ -53,7 +53,7 @@ export interface StorageDriver { * Update a record in the store. */ update( - model: Model, + model: Collection, id: string, record: Record, ): AsyncResult; @@ -61,5 +61,5 @@ export interface StorageDriver { /** * Delete a record from the store. */ - delete(model: Model, id: string): AsyncResult; + delete(model: Collection, id: string): AsyncResult; } diff --git a/packages/fabric/domain/src/types/decimal.ts b/packages/fabric/domain/src/types/decimal.ts new file mode 100644 index 0000000..a760328 --- /dev/null +++ b/packages/fabric/domain/src/types/decimal.ts @@ -0,0 +1 @@ +export { Decimal } from "decimal.js"; diff --git a/packages/fabric/domain/src/types/uuid.ts b/packages/fabric/domain/src/types/uuid.ts index 7c495b0..07dd212 100644 --- a/packages/fabric/domain/src/types/uuid.ts +++ b/packages/fabric/domain/src/types/uuid.ts @@ -1,5 +1 @@ export type UUID = `${string}-${string}-${string}-${string}-${string}`; - -export function generateUUID(): UUID { - return crypto.randomUUID() as UUID; -} diff --git a/packages/fabric/store-sqlite/src/filter-to-sql.spec.ts b/packages/fabric/store-sqlite/src/filter-to-sql.spec.ts new file mode 100644 index 0000000..ccef5a3 --- /dev/null +++ b/packages/fabric/store-sqlite/src/filter-to-sql.spec.ts @@ -0,0 +1,150 @@ +import { + defineCollection, + Field, + isGreaterOrEqualTo, + isGreaterThan, + isIn, + isLessOrEqualTo, + isLessThan, + isLike, + isNotEqualTo, +} from "@fabric/domain"; +import { describe, expect, it } from "vitest"; +import { filterToParams, filterToSQL } from "./filter-to-sql.js"; + +describe("SQL where clause from filter options", () => { + const col = defineCollection("users", { + name: Field.string(), + age: Field.integer(), + status: Field.string(), + salary: Field.decimal(), + rating: Field.float(), + quantity: Field.integer({ + isUnsigned: true, + }), + price: Field.decimal(), + }); + + it("should create a where clause from options with IN option", () => { + const opts = { + name: isIn(["John", "Jane"]), + }; + const result = filterToSQL(opts); + + const params = filterToParams(col, opts); + + expect(result).toEqual("WHERE name IN ($where_name_0,$where_name_1)"); + expect(params).toEqual({ $where_name_0: "John", $where_name_1: "Jane" }); + }); + + it("should create a where clause from options with LIKE option", () => { + const opts = { + name: isLike("%John%"), + }; + const result = filterToSQL(opts); + const params = filterToParams(col, opts); + expect(result).toEqual("WHERE name LIKE $where_name"); + expect(params).toEqual({ $where_name: "%John%" }); + }); + + it("should create a where clause from options with EQUALS option", () => { + const opts = { + age: 25, + }; + const result = filterToSQL(opts); + const params = filterToParams(col, opts); + expect(result).toEqual("WHERE age = $where_age"); + expect(params).toEqual({ $where_age: 25 }); + }); + + it("should create a where clause from options with NOT EQUALS option", () => { + const opts = { + status: isNotEqualTo("inactive"), + }; + const result = filterToSQL(opts); + const params = filterToParams(col, opts); + expect(result).toEqual("WHERE status <> $where_status"); + expect(params).toEqual({ $where_status: "inactive" }); + }); + + it("should create a where clause from options with GREATER THAN option", () => { + const opts = { + salary: isGreaterThan(50000), + }; + const result = filterToSQL(opts); + const params = filterToParams(col, opts); + expect(result).toEqual("WHERE salary > $where_salary"); + expect(params).toEqual({ $where_salary: 50000 }); + }); + + it("should create a where clause from options with LESS THAN option", () => { + const opts = { + rating: isLessThan(4.5), + }; + const result = filterToSQL(opts); + const params = filterToParams(col, opts); + expect(result).toEqual("WHERE rating < $where_rating"); + expect(params).toEqual({ $where_rating: 4.5 }); + }); + + it("should create a where clause from options with GREATER THAN OR EQUALS option", () => { + const opts = { + quantity: isGreaterOrEqualTo(10), + }; + const result = filterToSQL(opts); + const params = filterToParams(col, opts); + expect(result).toEqual("WHERE quantity >= $where_quantity"); + expect(params).toEqual({ $where_quantity: 10 }); + }); + + it("should create a where clause from options with LESS THAN OR EQUALS option", () => { + const opts = { + price: isLessOrEqualTo(100), + }; + const result = filterToSQL(opts); + const params = filterToParams(col, opts); + expect(result).toEqual("WHERE price <= $where_price"); + expect(params).toEqual({ $where_price: 100 }); + }); + + it("should create a where clause from options with IS NULL option", () => { + const opts = { + price: undefined, + }; + const result = filterToSQL(opts); + const params = filterToParams(col, opts); + expect(result).toEqual("WHERE price IS NULL"); + expect(params).toEqual({}); + }); + + it("should create a where clause from options with OR combination", () => { + const opts = [ + { + name: isIn(["John", "Jane"]), + age: isGreaterThan(30), + }, + { + status: isNotEqualTo("inactive"), + salary: isGreaterThan(50000), + }, + { + rating: isLessThan(4.5), + quantity: isGreaterOrEqualTo(10), + }, + ]; + const result = filterToSQL(opts); + const params = filterToParams(col, opts); + expect(result).toEqual( + "WHERE (name IN ($where_name_0_0,$where_name_0_1) AND age > $where_age_0) OR (status <> $where_status_1 AND salary > $where_salary_1) OR (rating < $where_rating_2 AND quantity >= $where_quantity_2)", + ); + expect(params).toEqual({ + $where_name_0_0: "John", + $where_name_0_1: "Jane", + $where_age_0: 30, + $where_status_1: "inactive", + $where_salary_1: 50000, + $where_rating_2: 4.5, + $where_quantity_2: 10, + }); + }); +}); diff --git a/packages/fabric/store-sqlite/src/filter-to-sql.ts b/packages/fabric/store-sqlite/src/filter-to-sql.ts new file mode 100644 index 0000000..da4f3b4 --- /dev/null +++ b/packages/fabric/store-sqlite/src/filter-to-sql.ts @@ -0,0 +1,166 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + Collection, + FieldDefinition, + FILTER_OPTION_OPERATOR_SYMBOL, + FILTER_OPTION_TYPE_SYMBOL, + FILTER_OPTION_VALUE_SYMBOL, + FilterOptions, + FilterValue, + MultiFilterOption, + SingleFilterOption, +} from "@fabric/domain"; +import { keyToParam } from "./record-utils.js"; +import { fieldValueToSQL } from "./value-to-sql.js"; + +export function filterToSQL(filterOptions?: FilterOptions) { + if (!filterOptions) return ""; + + if (Array.isArray(filterOptions)) + return `WHERE ${getWhereFromMultiOption(filterOptions)}`; + + return `WHERE ${getWhereFromSingleOption(filterOptions)}`; +} + +export function filterToParams( + collection: Collection, + filterOptions?: FilterOptions, +) { + if (!filterOptions) return {}; + + if (Array.isArray(filterOptions)) + return getParamsFromMultiFilterOption(collection, filterOptions); + + return getParamsFromSingleFilterOption(collection, filterOptions); +} + +function getWhereFromMultiOption(filterOptions: MultiFilterOption) { + return filterOptions + .map( + (option, i) => + `(${getWhereFromSingleOption(option, { postfix: `_${i}` })})`, + ) + .join(" OR "); +} + +function getWhereFromSingleOption( + filterOptions: SingleFilterOption, + opts: { postfix?: string } = {}, +) { + return Object.entries(filterOptions) + .map(([key, value]) => getWhereFromKeyValue(key, value, opts)) + .join(" AND "); +} + +const WHERE_KEY_PREFIX = "where_"; + +function getWhereParamKey(key: string, opts: { postfix?: string } = {}) { + return keyToParam(`${WHERE_KEY_PREFIX}${key}${opts.postfix ?? ""}`); +} + +function getWhereFromKeyValue( + key: string, + value: FilterValue, + opts: { postfix?: string } = {}, +) { + if (value == undefined) { + return `${key} IS NULL`; + } + + if (typeof value === "object") { + if (value[FILTER_OPTION_TYPE_SYMBOL] === "like") { + return `${key} LIKE ${getWhereParamKey(key, opts)}`; + } + + if (value[FILTER_OPTION_TYPE_SYMBOL] === "in") { + return `${key} IN (${value[FILTER_OPTION_VALUE_SYMBOL].map( + (v: any, i: number) => + `${getWhereParamKey(key, { + postfix: opts.postfix ? `${opts.postfix}_${i}` : `_${i}`, + })}`, + ).join(",")})`; + } + + if (value[FILTER_OPTION_TYPE_SYMBOL] === "comparison") { + return `${key} ${value[FILTER_OPTION_OPERATOR_SYMBOL]} ${getWhereParamKey( + key, + opts, + )}`; + } + } + return `${key} = ${getWhereParamKey(key, opts)}`; +} + +function getParamsFromMultiFilterOption( + collection: Collection, + filterOptions: MultiFilterOption, +) { + return filterOptions.reduce( + (acc, filterOption, i) => ({ + ...acc, + ...getParamsFromSingleFilterOption(collection, filterOption, { + postfix: `_${i}`, + }), + }), + {}, + ); +} + +function getParamsFromSingleFilterOption( + collection: Collection, + filterOptions: SingleFilterOption, + opts: { postfix?: string } = {}, +) { + return Object.entries(filterOptions) + .filter(([, value]) => { + return value !== undefined; + }) + .reduce( + (acc, [key, value]) => ({ + ...acc, + ...getParamsForFilterKeyValue(collection.fields[key], key, value, opts), + }), + {}, + ); +} + +function getParamValueFromOptionValue(field: FieldDefinition, value: any) { + if (typeof value === "object") { + if (value[FILTER_OPTION_TYPE_SYMBOL] === "like") { + return value[FILTER_OPTION_VALUE_SYMBOL]; + } + + if (value[FILTER_OPTION_TYPE_SYMBOL] === "comparison") { + return fieldValueToSQL(field, value[FILTER_OPTION_VALUE_SYMBOL]); + } + } + + return fieldValueToSQL(field, value); +} + +function getParamsForFilterKeyValue( + field: FieldDefinition, + key: string, + value: FilterValue, + opts: { postfix?: string } = {}, +) { + if (typeof value === "object") { + if (value[FILTER_OPTION_TYPE_SYMBOL] === "in") { + return value[FILTER_OPTION_VALUE_SYMBOL].reduce( + (acc: Record, v: any, i: number) => { + return { + ...acc, + [getWhereParamKey(key, { + postfix: opts.postfix ? `${opts.postfix}_${i}` : `_${i}`, + })]: value[FILTER_OPTION_VALUE_SYMBOL][i], + }; + }, + {}, + ); + } + } + + return { + [getWhereParamKey(key, opts)]: getParamValueFromOptionValue(field, value), + }; +} diff --git a/packages/fabric/store-sqlite/src/model-to-sql.ts b/packages/fabric/store-sqlite/src/model-to-sql.ts index 270ed76..ede39f4 100644 --- a/packages/fabric/store-sqlite/src/model-to-sql.ts +++ b/packages/fabric/store-sqlite/src/model-to-sql.ts @@ -21,10 +21,10 @@ const FieldSQLDefinitionMap: FieldSQLDefinitionMap = { modifiersFromOpts(f), ].join(" "); }, - IntegerField: function (n, f): string { + IntegerField: (n, f): string => { return [n, "INTEGER", modifiersFromOpts(f)].join(" "); }, - ReferenceField: function (n, f): string { + ReferenceField: (n, f): string => { return [ n, "TEXT", @@ -33,6 +33,12 @@ const FieldSQLDefinitionMap: FieldSQLDefinitionMap = { `FOREIGN KEY (${n}) REFERENCES ${f.targetModel}(${getTargetKey(f)})`, ].join(" "); }, + FloatField: (n, f): string => { + return [n, "REAL", modifiersFromOpts(f)].join(" "); + }, + DecimalField: (n, f): string => { + return [n, "REAL", modifiersFromOpts(f)].join(" "); + }, }; function fieldDefinitionToSQL(name: string, field: FieldDefinition) { return FieldSQLDefinitionMap[field[VariantTag]](name, field as any); diff --git a/packages/fabric/store-sqlite/src/record-utils.ts b/packages/fabric/store-sqlite/src/record-utils.ts index 7d26eeb..50dfd7c 100644 --- a/packages/fabric/store-sqlite/src/record-utils.ts +++ b/packages/fabric/store-sqlite/src/record-utils.ts @@ -6,20 +6,28 @@ import { fieldValueToSQL } from "./value-to-sql.js"; /** * Unfold a record into a string of it's keys separated by commas. */ -export function recordToKeys(record: Record, prefix = "") { +export function recordToSQLKeys(record: Record) { return Object.keys(record) - .map((key) => `${prefix}${key}`) + .map((key) => key) + .join(", "); +} +/** + * Unfold a record into a string of it's keys separated by commas. + */ +export function recordToSQLKeyParams(record: Record) { + return Object.keys(record) + .map((key) => keyToParam(key)) .join(", "); } /** * Unfold a record into a string of it's keys separated by commas. */ -export function recordToParams(model: Model, record: Record) { +export function recordToSQLParams(model: Model, record: Record) { return Object.keys(record).reduce( (acc, key) => ({ ...acc, - [`:${key}`]: fieldValueToSQL(model.fields[key], record[key]), + [keyToParam(key)]: fieldValueToSQL(model.fields[key], record[key]), }), {}, ); @@ -27,6 +35,10 @@ export function recordToParams(model: Model, record: Record) { export function recordToSQLSet(record: Record) { return Object.keys(record) - .map((key) => `${key} = :${key}`) + .map((key) => `${key} = ${keyToParam(key)}`) .join(", "); } + +export function keyToParam(key: string) { + return `$${key}`; +} diff --git a/packages/fabric/store-sqlite/src/sql-to-value.ts b/packages/fabric/store-sqlite/src/sql-to-value.ts index 584999d..152748c 100644 --- a/packages/fabric/store-sqlite/src/sql-to-value.ts +++ b/packages/fabric/store-sqlite/src/sql-to-value.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { VariantTag } from "@fabric/core"; -import { FieldDefinition, FieldToType, Model } from "@fabric/domain"; +import { Collection, FieldDefinition, FieldToType } from "@fabric/domain"; -export function transformRow(model: Model) { +export function transformRow(model: Collection) { return (row: Record) => { const result: Record = {}; for (const key in row) { @@ -34,4 +34,6 @@ const FieldSQLInsertMap: FieldSQLInsertMap = { return v; }, ReferenceField: (f, v) => v, + FloatField: (f, v) => v, + DecimalField: (f, v) => v, }; diff --git a/packages/fabric/store-sqlite/src/sqlite-driver.spec.ts b/packages/fabric/store-sqlite/src/sqlite-driver.spec.ts index 36e4ee6..514c9a7 100644 --- a/packages/fabric/store-sqlite/src/sqlite-driver.spec.ts +++ b/packages/fabric/store-sqlite/src/sqlite-driver.spec.ts @@ -1,6 +1,6 @@ import { isError } from "@fabric/core"; -import { defineModel, Field } from "@fabric/domain"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { defineModel, Field, isLike } from "@fabric/domain"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { SQLiteStorageDriver } from "./sqlite-driver.js"; describe("SQLite Store Driver", () => { @@ -21,7 +21,7 @@ describe("SQLite Store Driver", () => { if (isError(result)) throw result; }); - test("should be able to synchronize the store and insert a record", async () => { + it("should synchronize the store and insert a record", async () => { const result = await store.sync(schema); if (isError(result)) throw result; @@ -42,19 +42,18 @@ describe("SQLite Store Driver", () => { ]); }); - test("should be able to update a record", async () => { - const result = await store.sync(schema); - - if (isError(result)) throw result; + it("should be update a record", async () => { + await store.sync(schema); await store.insert(schema.users, { id: "1", name: "test", streamId: "1", - streamVersion: 1, + streamVersion: 1n, }); - await store.update(schema.users, "1", { name: "updated" }); + const err = await store.update(schema.users, "1", { name: "updated" }); + if (isError(err)) throw err; const records = await store.select(schema.users, { from: "users" }); @@ -63,16 +62,14 @@ describe("SQLite Store Driver", () => { ]); }); - test("should be able to delete a record", async () => { - const result = await store.sync(schema); - - if (isError(result)) throw result; + it("should be able to delete a record", async () => { + await store.sync(schema); await store.insert(schema.users, { id: "1", name: "test", streamId: "1", - streamVersion: 1, + streamVersion: 1n, }); await store.delete(schema.users, "1"); @@ -82,22 +79,20 @@ describe("SQLite Store Driver", () => { expect(records).toEqual([]); }); - test("should be able to select records", async () => { - const result = await store.sync(schema); - - if (isError(result)) throw result; + it("should be able to select records", async () => { + await store.sync(schema); await store.insert(schema.users, { id: "1", name: "test", streamId: "1", - streamVersion: 1, + streamVersion: 1n, }); await store.insert(schema.users, { id: "2", name: "test", streamId: "2", - streamVersion: 1, + streamVersion: 1n, }); const records = await store.select(schema.users, { from: "users" }); @@ -108,22 +103,20 @@ describe("SQLite Store Driver", () => { ]); }); - test("should be able to select one record", async () => { - const result = await store.sync(schema); - - if (isError(result)) throw result; + it("should be able to select one record", async () => { + await store.sync(schema); await store.insert(schema.users, { id: "1", name: "test", streamId: "1", - streamVersion: 1, + streamVersion: 1n, }); await store.insert(schema.users, { id: "2", name: "test", streamId: "2", - streamVersion: 1, + streamVersion: 1n, }); const record = await store.selectOne(schema.users, { from: "users" }); @@ -135,4 +128,104 @@ describe("SQLite Store Driver", () => { streamVersion: 1n, }); }); + + it("should select a record with a where clause", async () => { + await store.sync(schema); + + await store.insert(schema.users, { + id: "1", + name: "test", + streamId: "1", + streamVersion: 1n, + }); + await store.insert(schema.users, { + id: "2", + name: "jamón", + streamId: "2", + streamVersion: 1n, + }); + + const result = await store.select(schema.users, { + from: "users", + where: { name: isLike("te%") }, + }); + + expect(result).toEqual([ + { + id: "1", + name: "test", + streamId: "1", + streamVersion: 1n, + }, + ]); + }); + + it("should select a record with a where clause of a specific type", async () => { + await store.sync(schema); + + await store.insert(schema.users, { + id: "1", + name: "test", + streamId: "1", + streamVersion: 1n, + }); + await store.insert(schema.users, { + id: "2", + name: "jamón", + streamId: "2", + streamVersion: 1n, + }); + + const result = await store.select(schema.users, { + from: "users", + where: { streamVersion: 1n }, + }); + + expect(result).toEqual([ + { + id: "1", + name: "test", + streamId: "1", + streamVersion: 1n, + }, + { + id: "2", + name: "jamón", + streamId: "2", + streamVersion: 1n, + }, + ]); + }); + + it("should select with a limit and offset", async () => { + await store.sync(schema); + + await store.insert(schema.users, { + id: "1", + name: "test", + streamId: "1", + streamVersion: 1n, + }); + await store.insert(schema.users, { + id: "2", + name: "jamón", + streamId: "2", + streamVersion: 1n, + }); + + const result = await store.select(schema.users, { + from: "users", + limit: 1, + offset: 1, + }); + + expect(result).toEqual([ + { + id: "2", + name: "jamón", + streamId: "2", + streamVersion: 1n, + }, + ]); + }); }); diff --git a/packages/fabric/store-sqlite/src/sqlite-driver.ts b/packages/fabric/store-sqlite/src/sqlite-driver.ts index 30c95d0..2071776 100644 --- a/packages/fabric/store-sqlite/src/sqlite-driver.ts +++ b/packages/fabric/store-sqlite/src/sqlite-driver.ts @@ -4,6 +4,7 @@ import { unlink } from "fs/promises"; import { CircularDependencyError, + Collection, Model, ModelSchema, QueryDefinition, @@ -11,10 +12,13 @@ import { StoreQueryError, } from "@fabric/domain"; import { Database, Statement } from "sqlite3"; +import { filterToParams, filterToSQL } from "./filter-to-sql.js"; import { modelToSql } from "./model-to-sql.js"; import { - recordToKeys, - recordToParams, + keyToParam, + recordToSQLKeyParams, + recordToSQLKeys, + recordToSQLParams, recordToSQLSet, } from "./record-utils.js"; import { transformRow } from "./sql-to-value.js"; @@ -56,6 +60,32 @@ export class SQLiteStorageDriver implements StorageDriver { return stmt; } + private async getSelectStatement( + collection: Collection, + query: QueryDefinition, + ): Promise<[Statement, Record]> { + const selectFields = query.keys ? query.keys.join(", ") : "*"; + + const queryFilter = filterToSQL(query.where); + const limit = query.limit ? `LIMIT ${query.limit}` : ""; + const offset = query.offset ? `OFFSET ${query.offset}` : ""; + + const sql = [ + `SELECT ${selectFields}`, + `FROM ${query.from}`, + queryFilter, + limit, + offset, + ].join(" "); + + return [ + await this.getOrCreatePreparedStatement(sql), + { + ...filterToParams(collection, query.where), + }, + ]; + } + /** * Insert data into the store */ @@ -64,9 +94,9 @@ export class SQLiteStorageDriver implements StorageDriver { record: Record, ): AsyncResult { try { - const sql = `INSERT INTO ${model.name} (${recordToKeys(record)}) VALUES (${recordToKeys(record, ":")})`; + const sql = `INSERT INTO ${model.name} (${recordToSQLKeys(record)}) VALUES (${recordToSQLKeyParams(record)})`; const stmt = await this.getOrCreatePreparedStatement(sql); - return await run(stmt, recordToParams(model, record)); + return await run(stmt, recordToSQLParams(model, record)); } catch (error: any) { return new StoreQueryError(error.message, { error, @@ -80,13 +110,12 @@ export class SQLiteStorageDriver implements StorageDriver { * Run a select query against the store. */ async select( - model: Model, + collection: Collection, query: QueryDefinition, ): AsyncResult { try { - const sql = `SELECT * FROM ${query.from}`; - const stmt = await this.getOrCreatePreparedStatement(sql); - return await getAll(stmt, transformRow(model)); + const [stmt, params] = await this.getSelectStatement(collection, query); + return await getAll(stmt, params, transformRow(collection)); } catch (error: any) { return new StoreQueryError(error.message, { error, @@ -99,14 +128,12 @@ export class SQLiteStorageDriver implements StorageDriver { * Run a select query against the store. */ async selectOne( - model: Model, + collection: Collection, query: QueryDefinition, ): AsyncResult { try { - const sql = `SELECT * FROM ${query.from}`; - const stmt = await this.getOrCreatePreparedStatement(sql); - - return await getOne(stmt, transformRow(model)); + const [stmt, params] = await this.getSelectStatement(collection, query); + return await getOne(stmt, params, transformRow(collection)); } catch (error: any) { return new StoreQueryError(error.message, { error, @@ -174,15 +201,13 @@ export class SQLiteStorageDriver implements StorageDriver { record: Record, ): AsyncResult { try { - const sql = `UPDATE ${model.name} SET ${recordToSQLSet(record)} WHERE id = :id`; + const sql = `UPDATE ${model.name} SET ${recordToSQLSet(record)} WHERE id = ${keyToParam("id")}`; const stmt = await this.getOrCreatePreparedStatement(sql); - return await run( - stmt, - recordToParams(model, { - ...record, - id, - }), - ); + const params = recordToSQLParams(model, { + ...record, + id, + }); + return await run(stmt, params); } catch (error: any) { return new StoreQueryError(error.message, { error, diff --git a/packages/fabric/store-sqlite/src/sqlite-wrapper.ts b/packages/fabric/store-sqlite/src/sqlite-wrapper.ts index f98fc83..855c564 100644 --- a/packages/fabric/store-sqlite/src/sqlite-wrapper.ts +++ b/packages/fabric/store-sqlite/src/sqlite-wrapper.ts @@ -54,10 +54,11 @@ export function run( export function getAll( stmt: Statement, + params: Record, transformer: (row: any) => any, ): Promise[]> { return new Promise((resolve, reject) => { - stmt.all((err: Error | null, rows: Record[]) => { + stmt.all(params, (err: Error | null, rows: Record[]) => { if (err) { reject(err); } else { @@ -69,14 +70,15 @@ export function getAll( export function getOne( stmt: Statement, + params: Record, transformer: (row: any) => any, ): Promise> { return new Promise((resolve, reject) => { - stmt.get((err: Error | null, row: Record) => { + stmt.all(params, (err: Error | null, rows: Record[]) => { if (err) { reject(err); } else { - resolve(transformer(row)); + resolve(rows.map(transformer)[0]); } }); }); diff --git a/packages/fabric/store-sqlite/src/value-to-sql.ts b/packages/fabric/store-sqlite/src/value-to-sql.ts index d39bb13..d55618a 100644 --- a/packages/fabric/store-sqlite/src/value-to-sql.ts +++ b/packages/fabric/store-sqlite/src/value-to-sql.ts @@ -18,6 +18,8 @@ const FieldSQLInsertMap: FieldSQLInsertMap = { return v as number; }, ReferenceField: (f, v) => v, + FloatField: (f, v) => v, + DecimalField: (f, v) => v, }; export function fieldValueToSQL(field: FieldDefinition, value: any) { diff --git a/yarn.lock b/yarn.lock index dc7d075..80b024b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -420,6 +420,7 @@ __metadata: dependencies: "@fabric/core": "workspace:^" "@fabric/store-sqlite": "workspace:^" + decimal.js: "npm:^10.4.3" typescript: "npm:^5.6.2" vitest: "npm:^2.1.1" languageName: unknown @@ -1409,6 +1410,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.4.3": + version: 10.4.3 + resolution: "decimal.js@npm:10.4.3" + checksum: 10c0/6d60206689ff0911f0ce968d40f163304a6c1bc739927758e6efc7921cfa630130388966f16bf6ef6b838cb33679fbe8e7a78a2f3c478afce841fd55ac8fb8ee + languageName: node + linkType: hard + "decompress-response@npm:^6.0.0": version: 6.0.0 resolution: "decompress-response@npm:6.0.0"