@ragestudio/scylla-odm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -0
- package/src/cql_gen/create_table.ts +66 -0
- package/src/index.ts +186 -0
- package/src/model/index.ts +93 -0
- package/src/operations/countAll.ts +18 -0
- package/src/operations/delete.ts +9 -0
- package/src/operations/find.ts +42 -0
- package/src/operations/findOne.ts +25 -0
- package/src/operations/sync.ts +22 -0
- package/src/operations/tableExists.ts +29 -0
- package/src/operations/update.ts +24 -0
- package/src/result/index.ts +90 -0
- package/src/schema/index.ts +16 -0
- package/src/types.ts +89 -0
- package/src/utils/buildMapper.js +10 -0
- package/src/utils/fillDefaults.ts +30 -0
- package/src/utils/loadSchemas.ts +39 -0
- package/src/utils/queryParser.ts +192 -0
- package/src/utils/typeChecker.ts +100 -0
- package/tsconfig.json +19 -0
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ragestudio/scylla-odm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An ODM for ScyllaDB",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "RageStudio",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"cassandra-driver": "^4.8.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type Model from "../model"
|
|
2
|
+
|
|
3
|
+
export default function (model: Model): string {
|
|
4
|
+
const desc = model.schema
|
|
5
|
+
const tableName = desc.table_name
|
|
6
|
+
const keyspace = model.driver.config.keyspace
|
|
7
|
+
const fields = desc.fields
|
|
8
|
+
const key = desc.keys
|
|
9
|
+
const clusteringOrder = desc.clustering_order
|
|
10
|
+
|
|
11
|
+
let columnsDef = ""
|
|
12
|
+
|
|
13
|
+
for (const fieldName in fields) {
|
|
14
|
+
const field = fields[fieldName]
|
|
15
|
+
const typeStr = typeof field === "string" ? field : (field as any)?.type
|
|
16
|
+
|
|
17
|
+
if (!typeStr) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Invalid field type for "${fieldName}" in model "${tableName}"`,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
columnsDef += `"${fieldName}" ${typeStr.toUpperCase()}, `
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let pkDef = ""
|
|
27
|
+
|
|
28
|
+
if (typeof key === "string") {
|
|
29
|
+
pkDef = `"${key}"`
|
|
30
|
+
} else if (Array.isArray(key) && key.length > 0) {
|
|
31
|
+
const first = key[0]
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(first)) {
|
|
34
|
+
pkDef = `(${first.map((k) => `"${k}"`).join(", ")})`
|
|
35
|
+
} else {
|
|
36
|
+
pkDef = `"${first}"`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (let i = 1; i < key.length; i++) {
|
|
40
|
+
pkDef += `, "${key[i]}"`
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Missing or invalid primary key in model "${tableName}"`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let clusterClause = ""
|
|
49
|
+
|
|
50
|
+
if (clusteringOrder) {
|
|
51
|
+
let orderDef = ""
|
|
52
|
+
|
|
53
|
+
for (const col in clusteringOrder) {
|
|
54
|
+
if (orderDef !== "") {
|
|
55
|
+
orderDef += ", "
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
orderDef += `"${col}" ${(clusteringOrder[col] as string).toUpperCase()}`
|
|
59
|
+
}
|
|
60
|
+
if (orderDef !== "") {
|
|
61
|
+
clusterClause = ` WITH CLUSTERING ORDER BY (${orderDef})`
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return `CREATE TABLE IF NOT EXISTS ${keyspace}.${tableName} (${columnsDef}PRIMARY KEY (${pkDef}))${clusterClause}`
|
|
66
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Client as T_CassandraClient,
|
|
3
|
+
ClientOptions as T_CassandraClientOptions,
|
|
4
|
+
mapping as T_CassandraMapping,
|
|
5
|
+
} from "cassandra-driver"
|
|
6
|
+
import type { ClientConfig } from "./types"
|
|
7
|
+
|
|
8
|
+
//@ts-ignore
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
//@ts-ignore
|
|
11
|
+
import Cassandra from "cassandra-driver"
|
|
12
|
+
import loadSchemas from "./utils/loadSchemas"
|
|
13
|
+
import buildMapper from "./utils/buildMapper"
|
|
14
|
+
|
|
15
|
+
import { Model } from "./model"
|
|
16
|
+
import { InferDocument } from "./types"
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MAX_RETRIES = 3
|
|
19
|
+
const DEFAULT_RETRY_DELAY = 1000
|
|
20
|
+
const { SCYLLA_CONTACT_POINTS, SCYLLA_LOCAL_DATA_CENTER, SCYLLA_KEYSPACE } =
|
|
21
|
+
process.env
|
|
22
|
+
|
|
23
|
+
export default class ScyllaClient {
|
|
24
|
+
constructor(config: ClientConfig = {}) {
|
|
25
|
+
this.config = {
|
|
26
|
+
modelsPath: path.resolve(__dirname, "../../db"),
|
|
27
|
+
contactPoints:
|
|
28
|
+
(config.contactPoints ?? SCYLLA_CONTACT_POINTS)
|
|
29
|
+
? SCYLLA_CONTACT_POINTS.split(",")
|
|
30
|
+
: ["127.0.0.1"],
|
|
31
|
+
localDataCenter:
|
|
32
|
+
config.localDataCenter ??
|
|
33
|
+
SCYLLA_LOCAL_DATA_CENTER ??
|
|
34
|
+
"datacenter1",
|
|
35
|
+
keyspace: config.keyspace ?? SCYLLA_KEYSPACE ?? "default",
|
|
36
|
+
port: 9042,
|
|
37
|
+
maxRetries: DEFAULT_MAX_RETRIES,
|
|
38
|
+
retryDelay: DEFAULT_RETRY_DELAY,
|
|
39
|
+
...config,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const clientOptions: T_CassandraClientOptions = {
|
|
43
|
+
contactPoints: this.config.contactPoints,
|
|
44
|
+
localDataCenter: this.config.localDataCenter,
|
|
45
|
+
keyspace: this.config.keyspace,
|
|
46
|
+
protocolOptions: {
|
|
47
|
+
port: this.config.port,
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (this.config.pooling) {
|
|
52
|
+
clientOptions.pooling = this.config.pooling
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.client = new Cassandra.Client(clientOptions)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
config: ClientConfig
|
|
59
|
+
client: T_CassandraClient
|
|
60
|
+
mapper: T_CassandraMapping.Mapper
|
|
61
|
+
models: Map<string, Model<any>> = new Map()
|
|
62
|
+
|
|
63
|
+
async initialize(options: { sync?: boolean } = {}) {
|
|
64
|
+
let models: Model<any>[]
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
models = await loadSchemas(this.config.modelsPath)
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(`Failed to load models: ${error.message}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
models = models.filter((schema) => schema instanceof Model)
|
|
73
|
+
|
|
74
|
+
this.mapper = new Cassandra.mapping.Mapper(this.client, {
|
|
75
|
+
models: buildMapper(models),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
for (let model of models) {
|
|
79
|
+
model._connect(this)
|
|
80
|
+
|
|
81
|
+
this.models.set(
|
|
82
|
+
model.name,
|
|
83
|
+
model as Model<InferDocument<typeof model.schema>>,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if (options?.sync === true) {
|
|
87
|
+
await model._sync()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log("Connecting to ScyllaDB")
|
|
92
|
+
await this.connectWithRetry()
|
|
93
|
+
console.log("ScyllaDB Connected")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async connectWithRetry(): Promise<void> {
|
|
97
|
+
let lastError: Error | null = null
|
|
98
|
+
|
|
99
|
+
for (let attempt = 1; attempt <= this.config.maxRetries!; attempt++) {
|
|
100
|
+
try {
|
|
101
|
+
await this.client.connect()
|
|
102
|
+
return
|
|
103
|
+
} catch (error) {
|
|
104
|
+
lastError = error
|
|
105
|
+
console.warn(
|
|
106
|
+
`Connection attempt ${attempt} failed: ${error.message}`,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (attempt < this.config.maxRetries!) {
|
|
110
|
+
console.log(`Retrying in ${this.config.retryDelay}ms...`)
|
|
111
|
+
await this.delay(this.config.retryDelay!)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Failed to connect to ScyllaDB after ${this.config.maxRetries} attempts: ${lastError?.message}`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private delay(ms: number): Promise<void> {
|
|
122
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async shutdown(): Promise<void> {
|
|
126
|
+
try {
|
|
127
|
+
await this.client.shutdown()
|
|
128
|
+
console.log("ScyllaDB connection closed")
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error("Error shutting down ScyllaDB connection:", error)
|
|
131
|
+
throw error
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async executeWithRetry<T>(
|
|
136
|
+
operation: () => Promise<T>,
|
|
137
|
+
operationName: string = "operation",
|
|
138
|
+
): Promise<T> {
|
|
139
|
+
let lastError: Error | null = null
|
|
140
|
+
|
|
141
|
+
for (let attempt = 1; attempt <= this.config.maxRetries!; attempt++) {
|
|
142
|
+
try {
|
|
143
|
+
return await operation()
|
|
144
|
+
} catch (error) {
|
|
145
|
+
lastError = error
|
|
146
|
+
|
|
147
|
+
// check if error is retryable
|
|
148
|
+
if (
|
|
149
|
+
this.isRetryableError(error) &&
|
|
150
|
+
attempt < this.config.maxRetries!
|
|
151
|
+
) {
|
|
152
|
+
console.warn(
|
|
153
|
+
`Operation ${operationName} attempt ${attempt} failed: ${error.message}`,
|
|
154
|
+
)
|
|
155
|
+
console.log(`Retrying in ${this.config.retryDelay}ms...`)
|
|
156
|
+
|
|
157
|
+
await this.delay(this.config.retryDelay!)
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// if not retryable or last attempt, throw
|
|
162
|
+
throw error
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Operation ${operationName} failed after ${this.config.maxRetries} attempts: ${lastError?.message}`,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private isRetryableError(error: any): boolean {
|
|
172
|
+
// retry on network errors, timeouts, and certain ScyllaDB errors
|
|
173
|
+
const retryableMessages = [
|
|
174
|
+
"timeout",
|
|
175
|
+
"connection",
|
|
176
|
+
"network",
|
|
177
|
+
"unavailable",
|
|
178
|
+
"overloaded",
|
|
179
|
+
"no hosts available",
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
const errorMessage = error.message?.toLowerCase() || ""
|
|
183
|
+
|
|
184
|
+
return retryableMessages.some((msg) => errorMessage.includes(msg))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import ScyllaClient from ".."
|
|
2
|
+
import { Result } from "../result"
|
|
3
|
+
|
|
4
|
+
import fillDefaults from "../utils/fillDefaults"
|
|
5
|
+
|
|
6
|
+
import { mapping } from "cassandra-driver/lib/mapping"
|
|
7
|
+
import type { DocumentResult, Query, QueryOptions } from "../types"
|
|
8
|
+
import type { Schema } from "../schema"
|
|
9
|
+
|
|
10
|
+
import findOneOP from "../operations/findOne"
|
|
11
|
+
import findOP from "../operations/find"
|
|
12
|
+
import updateOP from "../operations/update"
|
|
13
|
+
import deleteOP from "../operations/delete"
|
|
14
|
+
import countAllOP from "../operations/countAll"
|
|
15
|
+
|
|
16
|
+
import tableExistsOP from "../operations/tableExists"
|
|
17
|
+
import syncOP from "../operations/sync"
|
|
18
|
+
|
|
19
|
+
export class Model<TDoc = any> {
|
|
20
|
+
name: string
|
|
21
|
+
schema: Schema<any>
|
|
22
|
+
driver: ScyllaClient
|
|
23
|
+
mapper: mapping.ModelMapper
|
|
24
|
+
|
|
25
|
+
constructor(name: string, schema: Schema<any>) {
|
|
26
|
+
this.name = name
|
|
27
|
+
this.schema = schema
|
|
28
|
+
|
|
29
|
+
if (!Array.isArray(this.schema.keys)) {
|
|
30
|
+
throw new Error(`[${this.name}] model has missing "keys" array`)
|
|
31
|
+
}
|
|
32
|
+
if (!this.schema.table_name) {
|
|
33
|
+
throw new Error(`[${this.name}] model has missing "table_name"`)
|
|
34
|
+
}
|
|
35
|
+
if (!this.schema.fields || typeof this.schema.fields !== "object") {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`[${this.name}] model has missing or invalid "fields"`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
create = (data: Partial<TDoc>) => this._wrap(data)
|
|
43
|
+
|
|
44
|
+
find: {
|
|
45
|
+
(
|
|
46
|
+
query: Query<TDoc>,
|
|
47
|
+
options: QueryOptions & { raw: true },
|
|
48
|
+
): Promise<TDoc[]>
|
|
49
|
+
(
|
|
50
|
+
query?: Query<TDoc>,
|
|
51
|
+
options?: QueryOptions,
|
|
52
|
+
): Promise<DocumentResult<TDoc>[]>
|
|
53
|
+
} = findOP.bind(this)
|
|
54
|
+
|
|
55
|
+
findOne: {
|
|
56
|
+
(
|
|
57
|
+
query: Query<TDoc>,
|
|
58
|
+
options: QueryOptions & { raw: true },
|
|
59
|
+
): Promise<TDoc>
|
|
60
|
+
(
|
|
61
|
+
query?: Query<TDoc>,
|
|
62
|
+
options?: QueryOptions,
|
|
63
|
+
): Promise<DocumentResult<TDoc>>
|
|
64
|
+
} = findOneOP.bind(this)
|
|
65
|
+
|
|
66
|
+
update: (query: Query<TDoc>) => Promise<DocumentResult<TDoc>> =
|
|
67
|
+
updateOP.bind(this)
|
|
68
|
+
|
|
69
|
+
delete: (query: Query<TDoc>) => Promise<mapping.Result> =
|
|
70
|
+
deleteOP.bind(this)
|
|
71
|
+
|
|
72
|
+
countAll: () => Promise<number> = countAllOP.bind(this)
|
|
73
|
+
|
|
74
|
+
_sync: typeof syncOP = syncOP.bind(this)
|
|
75
|
+
_tableExists: typeof tableExistsOP = tableExistsOP.bind(this)
|
|
76
|
+
|
|
77
|
+
_wrap(row: any): DocumentResult<TDoc> | null {
|
|
78
|
+
if (!row) {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
row = fillDefaults(this.schema, row)
|
|
83
|
+
|
|
84
|
+
return new Result<TDoc>(row, this) as DocumentResult<TDoc>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_connect(driver: ScyllaClient) {
|
|
88
|
+
this.driver = driver
|
|
89
|
+
this.mapper = driver.mapper.forModel(this.name)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default Model
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type Model from "../model"
|
|
2
|
+
|
|
3
|
+
export default async function (this: Model, timeoutMs: number = 60000) {
|
|
4
|
+
const cql = `SELECT COUNT(1) FROM ${this.driver.config.keyspace}.${this.schema.table_name}`
|
|
5
|
+
|
|
6
|
+
const queryOptions = {
|
|
7
|
+
prepare: true,
|
|
8
|
+
readTimeout: timeoutMs,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const operation = async () => {
|
|
12
|
+
const result = await this.driver.client.execute(cql, [], queryOptions)
|
|
13
|
+
|
|
14
|
+
return result.rows[0].count.toNumber()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return this.driver.executeWithRetry(operation, `countAll on ${this.name}`)
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type Model from "../model"
|
|
2
|
+
import type { mapping } from "cassandra-driver/lib/mapping"
|
|
3
|
+
import type { Query, QueryOptions } from "../types"
|
|
4
|
+
import queryParser from "../utils/queryParser"
|
|
5
|
+
|
|
6
|
+
export default async function findOP<TDoc>(
|
|
7
|
+
this: Model<TDoc>,
|
|
8
|
+
query: Query<TDoc> = {},
|
|
9
|
+
options?: QueryOptions,
|
|
10
|
+
) {
|
|
11
|
+
const { $limit, $orderby, ...rest } = query
|
|
12
|
+
|
|
13
|
+
let parsedQuery = queryParser(this, rest)
|
|
14
|
+
|
|
15
|
+
const docInfo: mapping.FindDocInfo = {}
|
|
16
|
+
|
|
17
|
+
if ($limit !== undefined) {
|
|
18
|
+
if (typeof $limit !== "number" || $limit <= 0) {
|
|
19
|
+
throw new TypeError(
|
|
20
|
+
`{$limit} operator must be a number greater than 0`,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
docInfo.limit = $limit
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if ($orderby !== undefined) {
|
|
27
|
+
docInfo.orderBy = $orderby as Record<string, "asc" | "desc">
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const operation = async () => {
|
|
31
|
+
const result = await this.mapper.find(parsedQuery, docInfo)
|
|
32
|
+
const rows = result.toArray()
|
|
33
|
+
|
|
34
|
+
if (options?.raw === true) {
|
|
35
|
+
return rows
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return rows.map((row) => this._wrap(row))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return this.driver.executeWithRetry(operation, `find on ${this.name}`)
|
|
42
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { QueryOptions } from "../types"
|
|
2
|
+
import type Model from "../model"
|
|
3
|
+
import queryParser from "../utils/queryParser"
|
|
4
|
+
|
|
5
|
+
export default function (this: Model, query: any, options?: QueryOptions) {
|
|
6
|
+
query = queryParser(this, query)
|
|
7
|
+
|
|
8
|
+
const operation = async () => {
|
|
9
|
+
let result = await this.mapper.get(query)
|
|
10
|
+
|
|
11
|
+
if (!result) {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
result = this._wrap(result)
|
|
16
|
+
|
|
17
|
+
if (options?.raw === true) {
|
|
18
|
+
return result.toRaw()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return result
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return this.driver.executeWithRetry(operation, `findOne on ${this.name}`)
|
|
25
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type Model from "../model"
|
|
2
|
+
import generateCreateTableCQL from "../cql_gen/create_table"
|
|
3
|
+
|
|
4
|
+
export default async function syncOP(this: Model) {
|
|
5
|
+
const tableExists = await this._tableExists()
|
|
6
|
+
|
|
7
|
+
if (tableExists) {
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
await this.driver.client.execute(generateCreateTableCQL(this))
|
|
13
|
+
|
|
14
|
+
console.log(`Table "${this.schema.table_name}" created successfully`)
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error(
|
|
17
|
+
`Failed to create table "${this.schema.table_name}":`,
|
|
18
|
+
error,
|
|
19
|
+
)
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type Model from "../model"
|
|
2
|
+
|
|
3
|
+
export default async function (this: Model) {
|
|
4
|
+
const cql = `
|
|
5
|
+
SELECT table_name
|
|
6
|
+
FROM system_schema.tables
|
|
7
|
+
WHERE keyspace_name = ?
|
|
8
|
+
AND table_name = ?
|
|
9
|
+
`
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const result = await this.driver.client.execute(
|
|
13
|
+
cql,
|
|
14
|
+
[this.driver.config.keyspace, this.schema.table_name],
|
|
15
|
+
{
|
|
16
|
+
prepare: true,
|
|
17
|
+
},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
return result.rows.length > 0
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error(
|
|
23
|
+
`Failed to check if table "${this.schema.table_name}" exists:`,
|
|
24
|
+
error,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type Model from "../model"
|
|
2
|
+
import fillDefaults from "../utils/fillDefaults"
|
|
3
|
+
import typeChecker from "../utils/typeChecker"
|
|
4
|
+
|
|
5
|
+
export default async function (this: Model, query: any) {
|
|
6
|
+
query = fillDefaults(this.schema, query)
|
|
7
|
+
|
|
8
|
+
typeChecker(this, query)
|
|
9
|
+
|
|
10
|
+
if (typeof query.__v !== "undefined") {
|
|
11
|
+
if (Number.isNaN(query.__v)) {
|
|
12
|
+
query.__v = 0
|
|
13
|
+
} else {
|
|
14
|
+
query.__v = query.__v + 1
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const operation = async () => {
|
|
19
|
+
await this.mapper.update(query)
|
|
20
|
+
return this._wrap(query)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return this.driver.executeWithRetry(operation, `update on ${this.name}`)
|
|
24
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Model } from "../model"
|
|
2
|
+
import typeChecker from "../utils/typeChecker"
|
|
3
|
+
|
|
4
|
+
export class Result<TDoc = any> {
|
|
5
|
+
constructor(data: TDoc, model: Model<TDoc>) {
|
|
6
|
+
if (data == null) {
|
|
7
|
+
throw new Error("Cannot create Result with null or undefined data")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof data !== "object" || Array.isArray(data)) {
|
|
11
|
+
throw new Error("Result data must be an object")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
Object.assign(this, data)
|
|
15
|
+
|
|
16
|
+
Object.defineProperty(this, "_model", {
|
|
17
|
+
value: model,
|
|
18
|
+
enumerable: false,
|
|
19
|
+
writable: false,
|
|
20
|
+
configurable: false,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_model: Model<TDoc>
|
|
25
|
+
|
|
26
|
+
async save() {
|
|
27
|
+
try {
|
|
28
|
+
const data = this.toRaw()
|
|
29
|
+
|
|
30
|
+
typeChecker(this._model, data)
|
|
31
|
+
|
|
32
|
+
return await this._model.update(data as any)
|
|
33
|
+
} catch (error: any) {
|
|
34
|
+
throw new Error(`Failed to save result: ${error.message}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async delete() {
|
|
39
|
+
try {
|
|
40
|
+
return await this._model.delete(this.toRaw() as any)
|
|
41
|
+
} catch (error: any) {
|
|
42
|
+
throw new Error(`Failed to delete result: ${error.message}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
toRaw(): TDoc {
|
|
47
|
+
const raw: any = {}
|
|
48
|
+
|
|
49
|
+
for (const key in this) {
|
|
50
|
+
if (key === "_model") continue
|
|
51
|
+
|
|
52
|
+
if (this.propertyIsEnumerable(key)) {
|
|
53
|
+
const value = (this as any)[key]
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
JSON.stringify(value)
|
|
57
|
+
raw[key] = value
|
|
58
|
+
} catch (error) {
|
|
59
|
+
raw[key] = String(value)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return raw as TDoc
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isValid(): boolean {
|
|
68
|
+
try {
|
|
69
|
+
typeChecker(this._model, this.toRaw())
|
|
70
|
+
return true
|
|
71
|
+
} catch {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getChangedFields(original: Partial<TDoc>): (keyof TDoc)[] {
|
|
77
|
+
const current = this.toRaw() as Record<string, any>
|
|
78
|
+
const changed: (keyof TDoc)[] = []
|
|
79
|
+
|
|
80
|
+
for (const key in current) {
|
|
81
|
+
if (!(key in original) || current[key] !== (original as any)[key]) {
|
|
82
|
+
changed.push(key as keyof TDoc)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return changed
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default Result
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { TableKeys } from "../types"
|
|
2
|
+
|
|
3
|
+
export class Schema<T> {
|
|
4
|
+
public readonly table_name: string
|
|
5
|
+
public readonly clustering_order: any
|
|
6
|
+
public readonly keys: TableKeys
|
|
7
|
+
public readonly fields: T
|
|
8
|
+
|
|
9
|
+
constructor(params: any, fields: T) {
|
|
10
|
+
this.table_name = params.table_name
|
|
11
|
+
this.keys = params.keys
|
|
12
|
+
this.fields = fields
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default Schema
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import Result from "./result"
|
|
2
|
+
import { Schema } from "./schema"
|
|
3
|
+
|
|
4
|
+
export type ClientConfig = {
|
|
5
|
+
modelsPath?: string
|
|
6
|
+
contactPoints?: string[]
|
|
7
|
+
localDataCenter?: string
|
|
8
|
+
keyspace?: string
|
|
9
|
+
port?: number
|
|
10
|
+
maxRetries?: number
|
|
11
|
+
retryDelay?: number
|
|
12
|
+
pooling?: {
|
|
13
|
+
coreConnectionsPerHost?: Record<string, number>
|
|
14
|
+
maxRequestsPerConnection?: number
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export enum ColumnTypes {
|
|
19
|
+
Ascii = "ascii",
|
|
20
|
+
Bigint = "bigint",
|
|
21
|
+
Blob = "blob",
|
|
22
|
+
Boolean = "boolean",
|
|
23
|
+
Counter = "counter",
|
|
24
|
+
Date = "date",
|
|
25
|
+
Decimal = "decimal",
|
|
26
|
+
Double = "double",
|
|
27
|
+
Duration = "duration",
|
|
28
|
+
Float = "float",
|
|
29
|
+
Frozen = "frozen",
|
|
30
|
+
Inet = "inet",
|
|
31
|
+
Int = "int",
|
|
32
|
+
List = "list",
|
|
33
|
+
Map = "map",
|
|
34
|
+
Set = "set",
|
|
35
|
+
Smallint = "smallint",
|
|
36
|
+
Text = "text",
|
|
37
|
+
Time = "time",
|
|
38
|
+
Timestamp = "timestamp",
|
|
39
|
+
Timeuuid = "timeuuid",
|
|
40
|
+
Tinyint = "tinyint",
|
|
41
|
+
Tuple = "tuple",
|
|
42
|
+
Uuid = "uuid",
|
|
43
|
+
Varchar = "varchar",
|
|
44
|
+
Varint = "varint",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type QueryOperators<TValue> = {
|
|
48
|
+
$eq?: TValue
|
|
49
|
+
$ne?: TValue
|
|
50
|
+
$in?: TValue[]
|
|
51
|
+
$gt?: TValue
|
|
52
|
+
$gte?: TValue
|
|
53
|
+
$lt?: TValue
|
|
54
|
+
$lte?: TValue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type TableKeys = (string | TableKeys)[]
|
|
58
|
+
|
|
59
|
+
export interface Column<T> {
|
|
60
|
+
type?: ColumnTypes | string
|
|
61
|
+
required?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type InferRawData<T> = {
|
|
65
|
+
[K in keyof T as K extends `$${string}` ? never : K]: T[K] extends Column<
|
|
66
|
+
infer U
|
|
67
|
+
>
|
|
68
|
+
? U
|
|
69
|
+
: T[K]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type InferDocument<S> =
|
|
73
|
+
S extends Schema<infer T> ? InferRawData<T> : never
|
|
74
|
+
|
|
75
|
+
export type DocumentResult<TDoc> = Result<TDoc> & TDoc
|
|
76
|
+
|
|
77
|
+
export type QueryOptions = {
|
|
78
|
+
raw?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type OrderBy<TDoc> = { [K in keyof TDoc]?: "asc" | "desc" }
|
|
82
|
+
|
|
83
|
+
export type Query<TDoc> = {
|
|
84
|
+
[K in keyof TDoc]?: TDoc[K] | QueryOperators<TDoc[K]>
|
|
85
|
+
} & {
|
|
86
|
+
$and?: Query<TDoc>[]
|
|
87
|
+
$limit?: number
|
|
88
|
+
$orderby?: OrderBy<TDoc>
|
|
89
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export default function fillDefaults(schema: any, data: any) {
|
|
2
|
+
const defaults = schema.options?.defaults
|
|
3
|
+
|
|
4
|
+
if (!defaults || Object.keys(defaults).length === 0) {
|
|
5
|
+
return data
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let needsDefaults = false
|
|
9
|
+
|
|
10
|
+
for (const key in defaults) {
|
|
11
|
+
if (data[key] == null) {
|
|
12
|
+
needsDefaults = true
|
|
13
|
+
break
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!needsDefaults) {
|
|
18
|
+
return data
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result = Object.assign({}, data)
|
|
22
|
+
|
|
23
|
+
for (const key in defaults) {
|
|
24
|
+
if (result[key] == null) {
|
|
25
|
+
result[key] = defaults[key]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return result
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
export default async (fromPath: string): Promise<any[]> => {
|
|
5
|
+
if (typeof fromPath !== "string") {
|
|
6
|
+
return []
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (!fs.existsSync(fromPath)) {
|
|
10
|
+
console.warn(
|
|
11
|
+
`Cannot load models from [${fromPath}] case this path does not exist`,
|
|
12
|
+
)
|
|
13
|
+
return []
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let schemas = []
|
|
17
|
+
|
|
18
|
+
let files = await fs.promises.readdir(fromPath)
|
|
19
|
+
|
|
20
|
+
files = files.filter((file) => file.endsWith(".js") || file.endsWith(".ts"))
|
|
21
|
+
|
|
22
|
+
for await (const file of files) {
|
|
23
|
+
const name = file.replace(".js", "")
|
|
24
|
+
const file_path = path.join(fromPath, file)
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
let mod = await import(file_path)
|
|
28
|
+
|
|
29
|
+
mod = mod.default
|
|
30
|
+
|
|
31
|
+
schemas.push(mod)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(`Failed to load schema [${name}]:`, error)
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return schemas
|
|
39
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Model } from "../model"
|
|
2
|
+
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import cassandra from "cassandra-driver"
|
|
5
|
+
const { q } = cassandra.mapping
|
|
6
|
+
|
|
7
|
+
const MAX_QUERY_DEPTH = 3
|
|
8
|
+
const MAX_IN_ELEMENTS = 1000
|
|
9
|
+
|
|
10
|
+
const VALID_OPERATORS = new Set([
|
|
11
|
+
"$eq",
|
|
12
|
+
"$ne",
|
|
13
|
+
"$gt",
|
|
14
|
+
"$gte",
|
|
15
|
+
"$lt",
|
|
16
|
+
"$lte",
|
|
17
|
+
"$in",
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
function requireNotNull(value: any, operator: string): void {
|
|
21
|
+
if (value === null || value === undefined) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`${operator} operator cannot compare with null or undefined`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildOperator(operator: string, opValue: any): any {
|
|
29
|
+
if (!VALID_OPERATORS.has(operator)) {
|
|
30
|
+
throw new Error(`Invalid operator: ${operator}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
switch (operator) {
|
|
34
|
+
case "$eq":
|
|
35
|
+
return opValue
|
|
36
|
+
|
|
37
|
+
case "$ne":
|
|
38
|
+
requireNotNull(opValue, "$ne")
|
|
39
|
+
return q.notEq(opValue)
|
|
40
|
+
|
|
41
|
+
case "$in":
|
|
42
|
+
if (!Array.isArray(opValue)) {
|
|
43
|
+
throw new Error("$in operator requires an array")
|
|
44
|
+
}
|
|
45
|
+
if (opValue.length > MAX_IN_ELEMENTS) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`$in operator exceeds maximum of ${MAX_IN_ELEMENTS} elements`,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < opValue.length; i++) {
|
|
51
|
+
if (opValue[i] === null || opValue[i] === undefined) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`$in array element at index ${i} cannot be null or undefined`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return q.in_(opValue)
|
|
58
|
+
|
|
59
|
+
case "$gt":
|
|
60
|
+
requireNotNull(opValue, "$gt")
|
|
61
|
+
return q.gt(opValue)
|
|
62
|
+
|
|
63
|
+
case "$gte":
|
|
64
|
+
requireNotNull(opValue, "$gte")
|
|
65
|
+
return q.gte(opValue)
|
|
66
|
+
|
|
67
|
+
case "$lt":
|
|
68
|
+
requireNotNull(opValue, "$lt")
|
|
69
|
+
return q.lt(opValue)
|
|
70
|
+
|
|
71
|
+
case "$lte":
|
|
72
|
+
requireNotNull(opValue, "$lte")
|
|
73
|
+
return q.lte(opValue)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default function queryParser(
|
|
78
|
+
model: Model<any>,
|
|
79
|
+
query: any,
|
|
80
|
+
depth: number = 0,
|
|
81
|
+
) {
|
|
82
|
+
if (depth > MAX_QUERY_DEPTH) {
|
|
83
|
+
throw new Error(`Query depth exceeds maximum of ${MAX_QUERY_DEPTH}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!query || typeof query !== "object") {
|
|
87
|
+
return query
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const parsedQuery: Record<string, any> = {}
|
|
91
|
+
const fields = model.schema.fields
|
|
92
|
+
|
|
93
|
+
for (const field of Object.keys(query)) {
|
|
94
|
+
const value = query[field]
|
|
95
|
+
|
|
96
|
+
if (field === "$and") {
|
|
97
|
+
handleAnd(model, value, parsedQuery, depth)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (field === "$or") {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"ScyllaDB does not support OR queries across different columns. Use $in for a single column.",
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!isValidFieldName(fields, field)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Invalid field name: [${field}] or it does not exist in schema`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
parsedQuery[field] = parseField(value)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return parsedQuery
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handleAnd(
|
|
120
|
+
model: Model<any>,
|
|
121
|
+
conditions: any,
|
|
122
|
+
parsedQuery: Record<string, any>,
|
|
123
|
+
depth: number,
|
|
124
|
+
) {
|
|
125
|
+
if (!Array.isArray(conditions)) {
|
|
126
|
+
throw new Error("$and operator requires an array")
|
|
127
|
+
}
|
|
128
|
+
if (conditions.length > 10) {
|
|
129
|
+
throw new Error("$and operator exceeds maximum of 10 conditions")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
133
|
+
const condition = conditions[i]
|
|
134
|
+
if (!condition || typeof condition !== "object") {
|
|
135
|
+
throw new Error(`$and condition at index ${i} must be an object`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const parsed = queryParser(model, condition, depth + 1)
|
|
139
|
+
for (const key of Object.keys(parsed)) {
|
|
140
|
+
if (key in parsedQuery) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`$and conflict: field "${key}" appears in multiple conditions`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
Object.assign(parsedQuery, parsed)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseField(value: any): any {
|
|
151
|
+
if (
|
|
152
|
+
value === null ||
|
|
153
|
+
typeof value !== "object" ||
|
|
154
|
+
Array.isArray(value) ||
|
|
155
|
+
value instanceof Date
|
|
156
|
+
) {
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"Array values require explicit operator (e.g., $in)",
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
return value
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const operators = Object.keys(value)
|
|
166
|
+
const compiledOps = operators.map((op) => buildOperator(op, value[op]))
|
|
167
|
+
|
|
168
|
+
return compiledOps.length === 1
|
|
169
|
+
? compiledOps[0]
|
|
170
|
+
: (q.and as any)(...compiledOps)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function isValidFieldName(
|
|
174
|
+
fields: Record<string, any>,
|
|
175
|
+
fieldName: string,
|
|
176
|
+
): boolean {
|
|
177
|
+
const invalidPatterns = [
|
|
178
|
+
/^[0-9]/,
|
|
179
|
+
/[^a-zA-Z0-9_]/,
|
|
180
|
+
/^(select|insert|update|delete|drop|create|alter|truncate)$/i,
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
for (const pattern of invalidPatterns) {
|
|
184
|
+
if (pattern.test(fieldName)) return false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return fieldName in fields
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function isValidOperator(operator: string): boolean {
|
|
191
|
+
return VALID_OPERATORS.has(operator)
|
|
192
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @ts-ignore
|
|
2
|
+
import cassandra from "cassandra-driver"
|
|
3
|
+
const { types } = cassandra
|
|
4
|
+
import type { Model } from "../model"
|
|
5
|
+
import { isValidFieldName } from "./queryParser"
|
|
6
|
+
|
|
7
|
+
const stringTypes = new Set(["ascii", "text", "varchar", "inet"])
|
|
8
|
+
const intTypes = new Set(["int", "smallint", "tinyint"])
|
|
9
|
+
const floatTypes = new Set(["double", "float"])
|
|
10
|
+
const longTypes = new Set(["bigint", "counter"])
|
|
11
|
+
|
|
12
|
+
function isValidValue(value: any, expectedType: string): boolean {
|
|
13
|
+
if (value === null || value === undefined) return true
|
|
14
|
+
|
|
15
|
+
if (stringTypes.has(expectedType)) return typeof value === "string"
|
|
16
|
+
if (intTypes.has(expectedType)) return Number.isInteger(value)
|
|
17
|
+
if (floatTypes.has(expectedType)) return typeof value === "number"
|
|
18
|
+
if (longTypes.has(expectedType)) {
|
|
19
|
+
return (
|
|
20
|
+
typeof value === "bigint" ||
|
|
21
|
+
typeof value === "number" ||
|
|
22
|
+
value instanceof types.Long
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
switch (expectedType) {
|
|
27
|
+
case "boolean":
|
|
28
|
+
return typeof value === "boolean"
|
|
29
|
+
case "decimal":
|
|
30
|
+
return (
|
|
31
|
+
typeof value === "number" ||
|
|
32
|
+
typeof value === "string" ||
|
|
33
|
+
value instanceof types.BigDecimal
|
|
34
|
+
)
|
|
35
|
+
case "varint":
|
|
36
|
+
return (
|
|
37
|
+
typeof value === "bigint" ||
|
|
38
|
+
typeof value === "number" ||
|
|
39
|
+
value instanceof types.Integer
|
|
40
|
+
)
|
|
41
|
+
case "timestamp":
|
|
42
|
+
return (
|
|
43
|
+
value instanceof Date ||
|
|
44
|
+
typeof value === "number" ||
|
|
45
|
+
typeof value === "string"
|
|
46
|
+
)
|
|
47
|
+
case "date":
|
|
48
|
+
return typeof value === "string" || value instanceof types.LocalDate
|
|
49
|
+
case "time":
|
|
50
|
+
return typeof value === "string" || value instanceof types.LocalTime
|
|
51
|
+
case "uuid":
|
|
52
|
+
return typeof value === "string" || value instanceof types.Uuid
|
|
53
|
+
case "timeuuid":
|
|
54
|
+
return typeof value === "string" || value instanceof types.TimeUuid
|
|
55
|
+
case "blob":
|
|
56
|
+
return Buffer.isBuffer(value) || value instanceof Uint8Array
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (expectedType.startsWith("list<") || expectedType.startsWith("set<")) {
|
|
60
|
+
return Array.isArray(value) || value instanceof Set
|
|
61
|
+
}
|
|
62
|
+
if (expectedType.startsWith("map<")) {
|
|
63
|
+
return (
|
|
64
|
+
typeof value === "object" && !Array.isArray(value) && value !== null
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default function typeChecker(model: Model<any>, data: any): boolean {
|
|
72
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
73
|
+
throw new TypeError(
|
|
74
|
+
`[${model.name}] Validation error: Data payload must be an object`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fields = model.schema.fields
|
|
79
|
+
|
|
80
|
+
for (const [key, value] of Object.entries(data)) {
|
|
81
|
+
if (!isValidFieldName(fields, key)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`[${model.name}] Validation error: Field '${key}' does not exist in schema`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const fieldConfig = fields[key]
|
|
88
|
+
const expectedType = (fieldConfig.type || "text").toLowerCase()
|
|
89
|
+
|
|
90
|
+
if (!isValidValue(value, expectedType)) {
|
|
91
|
+
const receivedType = Array.isArray(value) ? "array" : typeof value
|
|
92
|
+
throw new TypeError(
|
|
93
|
+
`[${model.name}] Validation error: Invalid type for field '${key}'. ` +
|
|
94
|
+
`Expected[${expectedType}], but received [${receivedType}]`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return true
|
|
100
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json.schemastore.org/tsconfig",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"allowSyntheticDefaultImports": true,
|
|
5
|
+
"target": "ES2023",
|
|
6
|
+
"module": "commonjs",
|
|
7
|
+
"moduleResolution": "node",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"emitDecoratorMetadata": true,
|
|
10
|
+
"experimentalDecorators": true,
|
|
11
|
+
"strict": false,
|
|
12
|
+
"noImplicitAny": false,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"preserveConstEnums": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"baseUrl": ".",
|
|
17
|
+
},
|
|
18
|
+
"include": ["**/*.ts"],
|
|
19
|
+
}
|