@stacksjs/database 0.59.11 → 0.61.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/database",
3
3
  "type": "module",
4
- "version": "0.59.11",
4
+ "version": "0.61.1",
5
5
  "description": "The Stacks database integration.",
6
6
  "author": "Chris Breuer",
7
7
  "license": "MIT",
@@ -21,7 +21,6 @@
21
21
  "sqlite",
22
22
  "mysql",
23
23
  "postgres",
24
- "planetscale",
25
24
  "bun",
26
25
  "stacks"
27
26
  ],
@@ -58,9 +57,10 @@
58
57
  "@stacksjs/storage": "latest",
59
58
  "@stacksjs/strings": "latest",
60
59
  "@stacksjs/utils": "latest",
61
- "kysely-bun-worker": "^0.5.7"
60
+ "kysely-bun-worker": "^0.6.1"
62
61
  },
63
62
  "dependencies": {
63
+ "@stacksjs/cli": "latest",
64
64
  "@stacksjs/config": "latest",
65
65
  "@stacksjs/faker": "latest",
66
66
  "@stacksjs/path": "latest",
@@ -68,12 +68,17 @@
68
68
  "@stacksjs/storage": "latest",
69
69
  "@stacksjs/strings": "latest",
70
70
  "@stacksjs/utils": "latest",
71
- "kysely-bun-worker": "^0.5.7"
71
+ "kysely-bun-worker": "^0.6.1"
72
72
  },
73
73
  "optionalDependencies": {
74
- "mysql2": "^3.9.2"
74
+ "mysql2": "^3.9.7"
75
75
  },
76
76
  "devDependencies": {
77
- "@stacksjs/development": "latest"
77
+ "@stacksjs/development": "latest",
78
+ "@types/tar": "^6.1.13",
79
+ "debug": "^4.3.4",
80
+ "mkdirp": "^3.0.1",
81
+ "q": "^1.5.1",
82
+ "tar": "^7.1.0"
78
83
  }
79
84
  }
package/src/column.ts CHANGED
@@ -5,10 +5,14 @@ interface Options {
5
5
  autoIncrement?: boolean
6
6
  }
7
7
 
8
- type ColumnType = 'integer' | 'varchar' | 'timestamp'
8
+ type ColumnType = 'integer' | 'varchar' | 'timestamp' | `varchar(${number})`
9
9
 
10
10
  export class Column {
11
- constructor(public name: string, public type: ColumnType, public options: Options = {}) {}
11
+ constructor(
12
+ public name: string,
13
+ public type: ColumnType,
14
+ public options: Options = {},
15
+ ) {}
12
16
 
13
17
  notNullable(): this {
14
18
  this.options.notNull = true
@@ -0,0 +1,226 @@
1
+ import { log } from '@stacksjs/cli'
2
+ import { db } from '@stacksjs/database'
3
+ import { path } from '@stacksjs/path'
4
+ import { fs, glob } from '@stacksjs/storage'
5
+ import { plural, snakeCase } from '@stacksjs/strings'
6
+ import type { Attributes, Model, RelationConfig, VineType } from '@stacksjs/types'
7
+ import { isString } from '@stacksjs/validation'
8
+
9
+ export * from './mysql'
10
+ export * from './postgres'
11
+ export * from './sqlite'
12
+
13
+ export async function getLastMigrationFields(modelName: string): Promise<Attributes> {
14
+ const oldModelPath = path.frameworkPath(`database/models/${modelName}`)
15
+ const model = (await import(oldModelPath)).default as Model
16
+ let fields = {} as Attributes
17
+
18
+ if (typeof model.attributes === 'object') fields = model.attributes
19
+ else fields = JSON.parse(model.attributes) as Attributes
20
+
21
+ return fields
22
+ }
23
+
24
+ export async function modelTableName(model: Model | string): Promise<string> {
25
+ if (typeof model === 'string') {
26
+ model = (await import(model)).default as Model
27
+ }
28
+
29
+ return model.table ?? snakeCase(plural(model?.name || ''))
30
+ }
31
+
32
+ export async function hasTableBeenMigrated(tableName: string) {
33
+ log.debug(`hasTableBeenMigrated for table: ${tableName}`)
34
+
35
+ const results = await getExecutedMigrations()
36
+
37
+ return results.some((migration) => migration.name.includes(tableName))
38
+ }
39
+
40
+ export async function getExecutedMigrations() {
41
+ try {
42
+ return await db.selectFrom('migrations').select('name').execute()
43
+ } catch (error) {
44
+ return []
45
+ }
46
+ }
47
+
48
+ function hasFunction(rule: VineType, functionName: string): boolean {
49
+ return typeof rule[functionName] === 'function'
50
+ }
51
+
52
+ export function mapFieldTypeToColumnType(rule: VineType): string {
53
+ if (hasFunction(rule, 'getChoices')) {
54
+ // Condition checker if an attribute is enum, could not think any conditions atm
55
+ const enumChoices = rule.getChoices() as string[]
56
+
57
+ // Convert each string value to its corresponding string structure
58
+ const enumStructure = enumChoices.map((value) => `'${value}'`).join(', ')
59
+
60
+ // Construct the ENUM definition
61
+ const enumDefinition = `sql\`enum(${enumStructure})\``
62
+
63
+ return enumDefinition
64
+ }
65
+
66
+ if (rule[Symbol.for('schema_name')].includes('string'))
67
+ // Default column type for strings
68
+ return prepareTextColumnType(rule)
69
+
70
+ if (rule[Symbol.for('schema_name')].includes('number')) return `'integer'`
71
+
72
+ if (rule[Symbol.for('schema_name')].includes('boolean')) return `'boolean'`
73
+
74
+ if (rule[Symbol.for('schema_name')].includes('date')) return `'date'`
75
+
76
+ // need to now handle all other types
77
+
78
+ // Add cases for other types as needed, similar to the original function
79
+ switch (rule) {
80
+ case 'integer':
81
+ return `'int'`
82
+ case 'boolean':
83
+ return `'boolean'`
84
+ case 'date':
85
+ return `'date'`
86
+ case 'datetime':
87
+ return `'timestamp'`
88
+ case 'float':
89
+ return `'float'`
90
+ case 'decimal':
91
+ return `'decimal'`
92
+ default:
93
+ return `'text'` // Fallback for unknown types
94
+ }
95
+ }
96
+
97
+ export function prepareTextColumnType(rule: VineType) {
98
+ let columnType = 'varchar(255)'
99
+
100
+ // Find min and max length validations
101
+ const minLengthValidation = rule.validations.find((v: any) => v.options?.min !== undefined)
102
+ const maxLengthValidation = rule.validations.find((v: any) => v.options?.max !== undefined)
103
+
104
+ // If there's a max length validation, adjust the column type accordingly
105
+ if (maxLengthValidation) {
106
+ const maxLength = maxLengthValidation.options.max
107
+
108
+ columnType = `varchar(${maxLength})`
109
+ }
110
+
111
+ // If there's only a min length validation and no max, consider using text
112
+ // This is a simplistic approach; adjust based on your actual requirements
113
+ if (minLengthValidation && !maxLengthValidation) columnType = 'text'
114
+
115
+ return `'${columnType}'`
116
+ }
117
+
118
+ export async function checkPivotMigration(dynamicPart: string): Promise<boolean> {
119
+ const files = await fs.readdir(path.userMigrationsPath())
120
+
121
+ return files.some((migrationFile) => {
122
+ // Escape special characters in the dynamic part to ensure it's treated as a literal string
123
+ const escapedDynamicPart = dynamicPart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
124
+
125
+ // Construct the regular expression pattern dynamically
126
+ const pattern = new RegExp(`(-${escapedDynamicPart}-)`)
127
+
128
+ // Test if the input string matches the pattern
129
+ return pattern.test(migrationFile)
130
+ })
131
+ }
132
+
133
+ export async function getRelations(model: Model): Promise<RelationConfig[]> {
134
+ const relationsArray = ['hasOne', 'hasMany', 'belongsToMany', 'hasOneThrough']
135
+ const relationships = []
136
+
137
+ for (const relation of relationsArray) {
138
+ if (hasRelations(model, relation)) {
139
+ for (const relationInstance of model[relation]) {
140
+ let relationModel = relationInstance.model
141
+
142
+ if (isString(relationInstance)) {
143
+ relationModel = relationInstance
144
+ }
145
+
146
+ const modelRelationPath = path.userModelsPath(`${relationModel}.ts`)
147
+ const modelRelation = (await import(modelRelationPath)).default
148
+ const formattedModelName = model.name?.toLowerCase()
149
+
150
+ relationships.push({
151
+ relationship: relation,
152
+ model: relationModel,
153
+ table: modelRelation.table,
154
+ relationModel: model.name,
155
+ relationTable: model.table,
156
+ foreignKey: relationInstance.foreignKey || `${formattedModelName}_id`,
157
+ relationName: relationInstance.relationName || '',
158
+ throughModel: relationInstance.through || '',
159
+ throughForeignKey: relationInstance.throughForeignKey || '',
160
+ pivotTable: relationInstance?.pivotTable || `${formattedModelName}_${modelRelation.table}`,
161
+ })
162
+ }
163
+ }
164
+ }
165
+
166
+ return relationships
167
+ }
168
+
169
+ export async function fetchOtherModelRelations(model: Model): Promise<RelationConfig[]> {
170
+ const modelFiles = glob.sync(path.userModelsPath('*.ts'))
171
+
172
+ const modelRelations = []
173
+
174
+ for (let i = 0; i < modelFiles.length; i++) {
175
+ const modelFileElement = modelFiles[i] as string
176
+
177
+ const modelFile = await import(modelFileElement)
178
+
179
+ if (model.name === modelFile.default.name) continue
180
+
181
+ const relations = await getRelations(modelFile.default)
182
+
183
+ if (!relations.length) continue
184
+
185
+ const relation = relations.find((relation) => relation.model === model.name)
186
+
187
+ if (relation) modelRelations.push(relation)
188
+ }
189
+
190
+ return modelRelations
191
+ }
192
+
193
+ export async function getPivotTables(
194
+ model: Model,
195
+ ): Promise<{ table: string; firstForeignKey: string | undefined; secondForeignKey: string | undefined }[]> {
196
+ const pivotTable = []
197
+
198
+ if (model.belongsToMany && model.name) {
199
+ if ('belongsToMany' in model) {
200
+ for (const belongsToManyRelation of model.belongsToMany) {
201
+ const modelRelationPath = path.userModelsPath(`${belongsToManyRelation}.ts`)
202
+ const modelRelation = (await import(modelRelationPath)).default
203
+ const formattedModelName = model.name.toLowerCase()
204
+
205
+ const firstForeignKey =
206
+ belongsToManyRelation.firstForeignKey || `${model.name?.toLowerCase()}_${model.primaryKey}`
207
+ const secondForeignKey =
208
+ belongsToManyRelation.secondForeignKey || `${modelRelation.name?.toLowerCase()}_${model.primaryKey}`
209
+
210
+ pivotTable.push({
211
+ table: belongsToManyRelation?.pivotTable || `${formattedModelName}_${modelRelation.table}`,
212
+ firstForeignKey: firstForeignKey,
213
+ secondForeignKey: secondForeignKey,
214
+ })
215
+ }
216
+
217
+ return pivotTable
218
+ }
219
+ }
220
+
221
+ return []
222
+ }
223
+
224
+ function hasRelations(obj: any, key: string): boolean {
225
+ return key in obj
226
+ }
@@ -0,0 +1,276 @@
1
+ import { italic, log } from '@stacksjs/cli'
2
+ import { db } from '@stacksjs/database'
3
+ import { ok } from '@stacksjs/error-handling'
4
+ import { path } from '@stacksjs/path'
5
+ import { fs, glob } from '@stacksjs/storage'
6
+ import type { Attribute, Attributes, Model } from '@stacksjs/types'
7
+ import {
8
+ checkPivotMigration,
9
+ fetchOtherModelRelations,
10
+ getLastMigrationFields,
11
+ getPivotTables,
12
+ hasTableBeenMigrated,
13
+ mapFieldTypeToColumnType,
14
+ modelTableName,
15
+ } from '.'
16
+
17
+ export async function resetMysqlDatabase() {
18
+ const tables = await fetchMysqlTables()
19
+
20
+ for (const table of tables) await db.schema.dropTable(table).ifExists().execute()
21
+
22
+ await db.schema.dropTable('migrations').ifExists().execute()
23
+ await db.schema.dropTable('migration_locks').ifExists().execute()
24
+
25
+ const files = await fs.readdir(path.userMigrationsPath())
26
+ const modelFiles = await fs.readdir(path.frameworkPath('database/models'))
27
+
28
+ const userModelFiles = glob.sync(path.userModelsPath('*.ts'))
29
+
30
+ for (const userModel of userModelFiles) {
31
+ const model = (await import(userModel)).default as Model
32
+ const pivotTables = await getPivotTables(model)
33
+
34
+ for (const pivotTable of pivotTables) await db.schema.dropTable(pivotTable.table).ifExists().execute()
35
+ }
36
+
37
+ if (modelFiles.length) {
38
+ for (const modelFile of modelFiles) {
39
+ if (modelFile.endsWith('.ts')) {
40
+ const modelPath = path.frameworkPath(`database/models/${modelFile}`)
41
+
42
+ if (fs.existsSync(modelPath)) await Bun.$`rm ${modelPath}`
43
+ }
44
+ }
45
+ }
46
+
47
+ if (files.length) {
48
+ for (const file of files) {
49
+ if (file.endsWith('.ts')) {
50
+ const migrationPath = path.userMigrationsPath(`${file}`)
51
+
52
+ if (fs.existsSync(migrationPath)) await Bun.$`rm ${migrationPath}`
53
+ }
54
+ }
55
+ }
56
+
57
+ return ok('All tables dropped successfully!')
58
+ }
59
+
60
+ export async function generateMysqlMigration(modelPath: string) {
61
+ // check if any files are in the database folder
62
+ const files = await fs.readdir(path.userMigrationsPath())
63
+
64
+ if (files.length === 0) {
65
+ log.debug('No migrations found in the database folder, deleting all framework/database/*.json files...')
66
+
67
+ // delete the *.ts files in the database/models folder
68
+ const modelFiles = await fs.readdir(path.frameworkPath('database/models'))
69
+
70
+ if (modelFiles.length) {
71
+ log.debug('No existing model files in framework path...')
72
+
73
+ for (const file of modelFiles) {
74
+ if (file.endsWith('.ts')) await fs.unlink(path.frameworkPath(`database/models/${file}`))
75
+ }
76
+ }
77
+ }
78
+
79
+ const model = (await import(modelPath)).default as Model
80
+ const fileName = path.basename(modelPath)
81
+ const tableName = await modelTableName(model)
82
+
83
+ const fieldsString = JSON.stringify(model.attributes, null, 2) // Pretty print the JSON
84
+ const copiedModelPath = path.frameworkPath(`database/models/${fileName}`)
85
+
86
+ let haveFieldsChanged = false
87
+
88
+ // if the file exists, we need to check if the fields have changed
89
+ if (fs.existsSync(copiedModelPath)) {
90
+ log.info(`Fields have already been generated for ${tableName}`)
91
+
92
+ const previousFields = await getLastMigrationFields(fileName)
93
+ const previousFieldsString = JSON.stringify(previousFields, null, 2) // Convert to string for comparison
94
+
95
+ if (previousFieldsString === fieldsString) {
96
+ log.debug(`Fields have not changed for ${tableName}`)
97
+ return
98
+ }
99
+
100
+ haveFieldsChanged = true
101
+ log.debug(`Fields have changed for ${tableName}`)
102
+ } else {
103
+ log.debug(`Fields have not been generated for ${tableName}`)
104
+ }
105
+
106
+ // store the fields of the model to a file
107
+ await Bun.$`cp ${modelPath} ${copiedModelPath}`
108
+
109
+ // if the fields have changed, we need to create a new update migration
110
+ // if the fields have not changed, we need to migrate the table
111
+
112
+ // we need to check if this tableName has already been migrated
113
+ const hasBeenMigrated = await hasTableBeenMigrated(tableName as string)
114
+
115
+ log.debug(`Has ${tableName} been migrated? ${hasBeenMigrated}`)
116
+
117
+ if (haveFieldsChanged) await createAlterTableMigration(modelPath)
118
+ else await createTableMigration(modelPath)
119
+ }
120
+
121
+ async function createTableMigration(modelPath: string) {
122
+ log.debug('createTableMigration modelPath:', modelPath)
123
+
124
+ const model = (await import(modelPath)).default as Model
125
+ const tableName = await modelTableName(model)
126
+
127
+ await createPivotTableMigration(model)
128
+
129
+ const otherModelRelations = await fetchOtherModelRelations(model)
130
+
131
+ const fields = model.attributes
132
+ const useTimestamps = model?.traits?.useTimestamps ?? model?.traits?.timestampable ?? true
133
+ const useSoftDeletes = model?.traits?.useSoftDeletes ?? model?.traits?.softDeletable ?? false
134
+
135
+ let migrationContent = `import type { Database } from '@stacksjs/database'\n`
136
+ migrationContent += `import { sql } from '@stacksjs/database'\n\n`
137
+ migrationContent += `export async function up(db: Database<any>) {\n`
138
+ migrationContent += ` await db.schema\n`
139
+ migrationContent += ` .createTable('${tableName}')\n`
140
+ migrationContent += ` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())\n`
141
+
142
+ for (const [fieldName, options] of Object.entries(fields)) {
143
+ const fieldOptions = options as Attribute
144
+ const columnType = mapFieldTypeToColumnType(fieldOptions.validator?.rule)
145
+ migrationContent += ` .addColumn('${fieldName}', ${columnType}`
146
+
147
+ // Check if there are configurations that require the lambda function
148
+ if (fieldOptions.unique || fieldOptions.validator?.rule?.required) {
149
+ migrationContent += `, col => col`
150
+ if (fieldOptions.unique) migrationContent += `.unique()`
151
+ if (fieldOptions.validator?.rule?.required) migrationContent += `.notNull()`
152
+ migrationContent += ``
153
+ }
154
+
155
+ migrationContent += `)\n`
156
+ }
157
+
158
+ if (otherModelRelations?.length) {
159
+ for (const modelRelation of otherModelRelations) {
160
+ migrationContent += ` .addColumn('${modelRelation.foreignKey}', 'integer', (col) =>
161
+ col.references('${modelRelation.relationTable}.id').onDelete('cascade')
162
+ ) \n`
163
+ }
164
+ }
165
+
166
+ // Append created_at and updated_at columns if useTimestamps is true
167
+ if (useTimestamps) {
168
+ migrationContent += ` .addColumn('created_at', 'timestamp', col => col.notNull().defaultTo(sql.raw('CURRENT_TIMESTAMP')))\n`
169
+ migrationContent += ` .addColumn('updated_at', 'timestamp')\n`
170
+ }
171
+
172
+ // Append deleted_at column if useSoftDeletes is true
173
+ if (useSoftDeletes) migrationContent += ` .addColumn('deleted_at', 'timestamp')\n`
174
+
175
+ migrationContent += ` .execute()\n`
176
+ migrationContent += `}\n`
177
+
178
+ const timestamp = new Date().getTime().toString()
179
+ const migrationFileName = `${timestamp}-create-${tableName}-table.ts`
180
+ const migrationFilePath = path.userMigrationsPath(migrationFileName)
181
+
182
+ // Assuming fs.writeFileSync is available or use an equivalent method
183
+ Bun.write(migrationFilePath, migrationContent)
184
+
185
+ log.success(`Created migration: ${italic(migrationFileName)}`)
186
+ }
187
+
188
+ async function createPivotTableMigration(model: Model) {
189
+ const pivotTables = await getPivotTables(model)
190
+
191
+ if (!pivotTables.length) return
192
+
193
+ for (const pivotTable of pivotTables) {
194
+ const hasBeenMigrated = await checkPivotMigration(pivotTable.table)
195
+
196
+ if (hasBeenMigrated) return
197
+
198
+ let migrationContent = `import type { Database } from '@stacksjs/database'\n`
199
+ migrationContent += `export async function up(db: Database<any>) {\n`
200
+ migrationContent += ` await db.schema\n`
201
+ migrationContent += ` .createTable('${pivotTable.table}')\n`
202
+ migrationContent += ` .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())\n`
203
+ migrationContent += ` .addColumn('${pivotTable.firstForeignKey}', 'integer')\n`
204
+ migrationContent += ` .addColumn('${pivotTable.secondForeignKey}', 'integer')\n`
205
+ migrationContent += ` .execute()\n`
206
+ migrationContent += ` }\n`
207
+
208
+ const timestamp = new Date().getTime().toString()
209
+ const migrationFileName = `${timestamp}-create-${pivotTable.table}-table.ts`
210
+ const migrationFilePath = path.userMigrationsPath(migrationFileName)
211
+
212
+ // Assuming fs.writeFileSync is available or use an equivalent method
213
+ Bun.write(migrationFilePath, migrationContent)
214
+
215
+ log.success(`Created pivot migration: ${migrationFileName}`)
216
+ }
217
+ }
218
+
219
+ export async function createAlterTableMigration(modelPath: string) {
220
+ console.log('createAlterTableMigration')
221
+
222
+ const model = (await import(modelPath)).default as Model
223
+ const modelName = path.basename(modelPath)
224
+ const tableName = await modelTableName(model)
225
+
226
+ // Assuming you have a function to get the fields from the last migration
227
+ // For simplicity, this is not implemented here
228
+ const lastMigrationFields = await getLastMigrationFields(modelName)
229
+ const lastFields = lastMigrationFields ?? {}
230
+ const currentFields = model.attributes as Attributes
231
+
232
+ // Determine fields to add and remove
233
+ const fieldsToAdd = Object.keys(currentFields)
234
+ const fieldsToRemove = Object.keys(lastFields)
235
+
236
+ let migrationContent = `import type { Database } from '@stacksjs/database'\n`
237
+ migrationContent += `import { sql } from '@stacksjs/database'\n\n`
238
+ migrationContent += `export async function up(db: Database<any>) {\n`
239
+ migrationContent += ` await db.schema.alterTable('${tableName}')\n`
240
+
241
+ // Add new fields
242
+ for (const fieldName of fieldsToAdd) {
243
+ const options = currentFields[fieldName] as Attribute
244
+ const columnType = mapFieldTypeToColumnType(options.validator?.rule)
245
+ migrationContent += ` .addColumn('${fieldName}', '${columnType}')\n`
246
+ }
247
+
248
+ // Remove fields that no longer exist
249
+ for (const fieldName of fieldsToRemove) migrationContent += ` .dropColumn('${fieldName}')\n`
250
+
251
+ migrationContent += ` .execute();\n`
252
+ migrationContent += `}\n`
253
+
254
+ const timestamp = new Date().getTime().toString()
255
+ const migrationFileName = `${timestamp}-update-${tableName}-table.ts`
256
+ const migrationFilePath = path.userMigrationsPath(migrationFileName)
257
+
258
+ // Assuming fs.writeFileSync is available or use an equivalent method
259
+ Bun.write(migrationFilePath, migrationContent)
260
+
261
+ log.success(`Created migration: ${italic(migrationFileName)}`)
262
+ }
263
+
264
+ export async function fetchMysqlTables(): Promise<string[]> {
265
+ const modelFiles = glob.sync(path.userModelsPath('*.ts'))
266
+ const tables: string[] = []
267
+
268
+ for (const modelPath of modelFiles) {
269
+ const model = (await import(modelPath)).default as Model
270
+ const tableName = await modelTableName(model)
271
+
272
+ tables.push(tableName)
273
+ }
274
+
275
+ return tables
276
+ }