Skip to content
NewFrom Mongoose to SQL: migrating MongoDB to PostgreSQL7 min read

Codemod

uql-codemod rewrites your code across UQL’s mechanical breaking changes and reports what it cannot decide. Run it first when you upgrade, then tsc, then the upgrade guide for the rest.

Terminal window
npx uql-codemod --dry-run
needs a decision: src/entity/audit.ts:9: has no 'type': declare its column as a builder, such as `f.string()`
would change: src/entity/user.ts
...
23 file(s) would change
1 item(s) need a decision; see above

needs a decision: marks something it will not guess, and worth a look: a rewrite you may want to read. Drop --dry-run to write the files. Only the edited text changes; comments and formatting stay as you wrote them. --project=<path> picks another tsconfig.json (it must cover your entities), and --include=<a,b> limits the paths. It exits 0 when nothing is left, 1 when something is, and 2 when it could not start.

Everything it inserts is type-checked against the property it describes, so a wrong guess shows up as a tsc error.

Entities and relations:

  • A column’s options to its builder: @Id() and @Field({ type: String, nullable: true, length: 80 }) to @Field(f.number().autoIncrement()) and @Field(f.string().nullable().length(80)), a NOT NULL column losing its nullable: false, and a computed field to @Computed((row) => f.string().as(...)), or @Computed((row) => row.books.count()) for an aggregate.
  • A sole numeric key to the generated one it was, f.number().id() to f.number().autoIncrement(), and .autoIncrement(false) dropped. A key a relation shares stays .id(), as does a class’s id!, which an insert then names until it is id? or listed in [filledKey]. On MongoDB, take .autoIncrement() off a numeric key.
  • Each index, check and trigger entry to the helper its callback is handed: indexes: (post, index) => [index([post.title], 'by_title')].
  • @Index, @Trigger and @Filter into @Entity’s indexes, triggers and filters, each decorator’s comments kept above its entry; a class with no @Entity becomes @Entity({ abstract: true, ... }), a base that is never a table.
  • Members named by string to callbacks, in mappedBy, include, references, and the indexes and hooks of @Entity/defineEntity: mappedBy: 'author' to (post) => post.author.
  • references: (post) => post.authorId on each to-one, declaring the authorId column where it is missing.
  • A JSON index entry to the path read off its column’s ref: { column: post.kind, jsonPath: { path: 'theme', type: Number } } to { column: post.kind.theme, type: Number }, and a jsonArray’s element type to type: [String]. Its length becomes charLength, and a column entry’s prefixLength.
  • | null on each nullable column’s property, except keys, relation aggregates and nullable: false fields.
  • The idKey brand on a key not called id, _id or uuid, and on every composite.
  • virtual to computed, and a check’s expression and a filter’s condition to where.
  • A class registered with defineEntity(Post, { ... }) to the defineEntity call it stands for, export const Post = defineEntity({ name: 'Post', ... }), the class removed: its other options as the sections chained after it, each relation as a rel thunk, a relation aggregate in .relations()’ second callback, a hook’s method as a function of the row, and the same class’s defineField, defineId, defineRelation, defineHook, defineIndex, defineTrigger and defineFilter folded in. A column’s foreign key becomes the relation joining on it, its onDelete moved into that relation, and a key no relation joins on gets a manyToOne written for it. A class with a method no hook runs, a base it extends, or a foreign key no relation can take is reported and left as written.

SQL and queries:

  • raw to sql, at the import and every use. raw('SQL') becomes the tagged template, raw(fn, alias) becomes sql(fn).as(alias), and col('cost') becomes a ref: (product) => sql`${product.cost}` in a computed, refs(Item).cost in a statement.
  • A migration builder column’s options to the same methods: table.string('email', { length: 80, nullable: true }) to table.string('email').length(80).nullable(), .defaultValue() to .default(), .primaryKey() to .id(), .computed() to .as(), and .notNullable() dropped.
  • A partial-index where string to sql, in entities and the migration builder, and the builder’s expr defaults: expr.now() to currentTimestamp, expr.uuid() to uuid, expr.raw('...') to sql`...` .
  • $exclude to a $select leaving its fields out, $select: { password: false }, at every depth of $populate.
  • An aggregate’s $agg to $select, and fields named in it or in $text’s $fields to keys: { $sum: { amount: true } }.
  • An upsert’s conflict keys to a query, upsertOne(User, { email: true }, row) to upsertOne(User, { $conflict: { email: true } }, row), and its update, a fourth argument or { update: X } options, to the query’s $update: X, the options after it kept, in upsertOne, upsertMany and a trigger’s upsertInto; QueryConflictPaths to QueryConflictKeys, and const { id } = await upsertOne(...) to const id, since it resolves to the id.
  • $lock: { wait: 'skip' } to { $wait: 'skip' }, or true for a lock that waits.
  • run and all to tags: all('SELECT * FROM t WHERE id = ?', [id]) to all`SELECT * FROM t WHERE id = ${id}` . A string built at run time goes in sql.text(...).

Imports and setup:

  • Renamed exports, with their imports and uses: QueryWhereMap, RelationKeyMap, FilterCondition, IndexColumnSchema (to IndexEntry), IndexColumnModifiers (to IndexEntryModifiers), QueryDialect, EngineFeatures, KnownMigratorDialect (to DialectName), D1Preparer and D1Database (to D1Queryable), SqlMigrationModuleOptions, buildSqlQuerierMigrationModule, Sqlite3QuerierPool (to SqliteQuerierPool), the migration builder’s IMigrationBuilder, ITableBuilder and the like (to MigrationBuilder, TableBuilder), and each driver subclass (PgDialect, NeonQuerier, LibsqlQuerier, …) to its base class.
  • MongoQuerier, isMongoQuerier and MongoSchemaIntrospector, imported from uql-orm/mongodb.
  • Entries: uql-orm/maria, uql-orm/mongo and uql-orm/bunSql to uql-orm/mariadb, uql-orm/mongodb and uql-orm/bun-sql; uql-orm/type, uql-orm/dialect, uql-orm/entity, uql-orm/querier and uql-orm/namingStrategy to uql-orm.
  • Removes import 'reflect-metadata', and experimentalDecorators/emitDecoratorMetadata from tsconfig.json.
  • @Transactional(), @InjectQuerier(), @Log() and @Serialized(), which UQL does not have. Wrap a @Transactional() method’s body in pool.transaction() on the pool you choose.
  • Removed exports with no one-to-one replacement, such as setQuerierPool or AbstractPgQuerier; internals the root no longer exports (AbstractSqlQuerier, fieldOf, …); the migration storages; and any import from uql-orm/util the root does not export (raw, refs and the rest it does export move to uql-orm).
  • Migrator’s pending() and executed(), now read off (await migrator.status()), and a bare down() (worth a look:), which reverts only the last migration: { step: Infinity } reverts them all.
  • A col() whose entity it cannot tell: a raw built apart from its query, one under a relation filter, or a column no field maps. Read it off refs(Entity).
  • Values it cannot read statically: @Field(shared), a spread, a non-literal list.
  • A partial-index where template that interpolates, since sql would bind each interpolation as a value.
  • A JSON index entry whose path is not a string literal, or whose jsonPath/jsonArray takes an option besides path, type and length.
  • A run/all with values it cannot place safely, such as a non-literal list or SQL, or mixed ? and $n.
  • SQL in a definition that names a column by hand (worth a look:). Only a ref follows a rename: (user) => sql`lower(${user.email})` .
  • An $exclude it cannot read, or one beside a $select: merge it in, each field as field: false.
  • A to-one onto a composite key (declare a column per key and pair each in references), and a to-one whose <relation>Id is not a declared column.
  • A property whose type maps to no column (Json<T>, a vector, a mixed union), a key under a computed name, and a nullable column’s property with no written type.
  • A @Field/@Id with no type, and a relation with no entity: declare the column as a builder (f.string()) and name the target (entity: () => Target).
  • Relation<T>, which uql no longer exports: write the property’s type without it, and drop the import.
  • A definer on a decorated class (defineIndex(Post, ...)), and @Entity’s fields, relations, hooks and extends: each is a decorator, or the class’s own extends, now.
  • A column option it cannot write as a method: a shape, which a JSON payload no longer takes (write a Standard Schema), or a flag such as unique: isUnique that no literal states.
  • A file that already declares a name the codemod would import (sql, refs, idKey, f, a renamed export), which it leaves unchanged.
  • A branded string id’s column, f.uuid() or f.string(). f.uuid() is a native column type and may need a migration.
  • target: esnext, which leaves decorators untransformed. Any other modern target works.