frieda
v0.12.1
Published
Javascript code generator for the PlanetScale serverless driver.
Downloads
24
Maintainers
Readme
Frieda 🦮
Javascript code generator for the PlanetScale serverless driver.
This library is a work in progress. Bug reports, suggestions and PRs are entirely welcome.
- Why?
- Quick Start
- Project Structure
- Naming Conventions
- Field Types
- Model Types
- Casting
- Arbitrary
SELECT
Queries - Options
- API
- Contributing
- Who's Frieda
Why?
Frieda aims to create solid database code for typescript/javascript projects using the PlanetScale serverless driver:
- Typed javascript models based on a database's tables and views.
- Typed methods to take care of the boring stuff:
SELECT
and CrUD. - Accurate typing for more interesting stuff written in vanilla SQL.
Other key features:
- No bespoke data definition language. There's just a plain typescript file containing model types. Initially this file is populated using a set of reasonable conventions mapping MySQL column types to javascript field types. Need to change how a field is typed and cast? Just edit its javascript type.
- No other unnecessary features. Frieda is not an ORM or a query builder. Models have no notion of their relations. Beyond simple
SELECT
and CrUD, Frieda does not write SQL for you. It does not create migrations. It does not let you switch to MongoDb. Frieda assumes...- You're cool with writing a certain amount of SQL by hand.
- You're happy with PlanetScale's built-in schema workflow.
Frieda not what you want? Try Prisma (to manage the schema) and Kysely with the PlanetScale dialect (to help write queries.)
Quick Start
# install...
npm i frieda
# run...
./node_modules/.bin/frieda
Frieda will ask for a few options:
- Where to find your database URL.
- Where you want the generated database code to go.
- Whether you want to compile to javascript rather typescript. (Experimental.)
These options are saved to .friedarc.json
, so you don't have to go through the prompts every time.
Frieda then fetches the schema and generates code.
Example: Using the generated ApplicationDatabase
class
Note: this example assumes you are using typescript with compileJs
off. If you're using javascript you'll have to adjust a few things
Create a get-db.ts
file in the outputDirectory
path you specified, next to the generated files. (It doesn't have to be here, just keeping things simple.)
src/lib/db <-- outputDirectory
├── frieda-models.ts <-- generated by frieda, editable by you
├── frieda.ts <-- generated code, don't edit
└── get-db.ts <-- add this
// src/lib/db/get-db.ts
import { connect } from '@planetscale/database';
// Get the database URL. E.g., in SvelteKit:
import { DATABASE_URL } from '$env/static/private';
// import the generated AppDb class...
import { ApplicationDatabase } from './frieda.js';
let _appDb: ApplicationDatabase | undefined = undefined;
export const getDb = (): ApplicationDatabase => {
if (!_appDb) {
_appDb = new ApplicationDatabase(connect({ url: DATABASE_URL }));
}
return _appDb;
};
This example exports a getDb
functions that returns a singleton instance of the generated ApplicationDatabase
class. ApplicationDatabase
exposes:
- A
ViewDatabase
instance for each model based on a database view. This hasfind*
andcount*
methods. - A
TableDatabase
instance for each model based on a database table. In addition tofind*
andcount*
this hascreate*
,update*
anddelete*
methods. - Methods to perform arbitrary queries (not based directly on a model.)
Import getDb
and use the ApplicationDatabase
instance:
// a SvelteKit example +page.server.js file
// src/routes/cats/[catId]/+page.server.js
import { error } from '@sveltejs/kit';
// note src/lib is aliased as $lib
import { getDb } from '$lib/db/get-db.js';
export const load = async (event) => {
const db = getDb();
const id = event.params.catId;
const cat = await db.cat.find({ where: { id } });
if (!cat) {
throw error(404, `Cat ID ${id} not found!`);
}
// note `cat` is typed as `CatSelectAll` from '$lib/db/frieda'
return { cat };
};
Example: Modifying a field type
Open frieda-models.ts
file in the output directory. This is where you can edit javascript field types.
src/lib/db <-- outputDirectory
├── frieda-models.ts <-- generated by frieda, editable by you
└── frieda.ts <-- generated code, don't edit
When you first run frieda
, or when you add a table or column to the database schema, the field types are based on a few conventions. One of those conventions is that bigint
columns are types as javascript string
. So if you have a table like this...
CREATE TABLE
`Cat` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`ownerId` bigint unsigned NOT NULL,
`name` varchar(100) NOT NULL,
`fleaCount` bigint NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
)
...the fleaCount
field will string
both in the editable type...
// frieda-models.ts
type Cat = {
id: string;
ownerId: string;
name: string;
fleaCount: string;
};
...and in the generated Cat*
types:
// frieda.ts (or frieda.d.ts if compileJs is true)
export type Cat = {
id: string;
ownerId: string;
name: string;
fleaCount: string;
};
// ditto for the other Cat* types, `CatCreate`, `CatUpdate`, etc.
This is probably not what you want. Edit the Cat
type.
// frieda-models.ts
type Cat = {
id: string;
ownerId: string;
name: string;
- fleaCount: string;
+ fleaCount: bigint;
};
Re-run frieda
...
./node_modules/.bin/frieda
You won't see any changes to frieda-models.ts
(your changes to this file are always preserved as long as the model and field are not dropped in the database,) but the generated Cat*
model types in frieda.ts
(or frieda.d.ts
) will reflect the change:
// frieda.ts (or frieda.d.ts if compileJs is true)
export type Cat = {
id: string;
ownerId: string;
name: string;
- fleaCount: string;
+ fleaCount: bigint;
};
// ditto for the other Cat* types, `CatCreate`, `CatUpdate`, etc.
Project Structure
A typical project using Frieda will look like this. outputDirectory
in this example is src/lib/db
.
.
├── .frieda-metadata
│ ├── history
│ │ └── 2023-08-06T22:46:08.925Z
│ │ ├── frieda-models.ts
│ │ ├── schema.json
│ │ └── schema.sql
│ ├── .gitignore
│ ├── schema.json
│ └── schema.sql
├── .friedarc.json
└── src
├── lib
│ └── db <-- outputDirectory
| ├── frieda-models.ts
| ├── frieda.{ts|js}
│ └── frieda.d.ts (only if compileJs is true)
└── ...other source code
Metadata and option files
- The
.frieda-metadata
directory contains information about the current schema and previous versions, for reference and debugging purposes. Frieda only writes to this folder — it does not rely on the contents. The.frieda-metadata/history
folder is .gitignore'd by default since it a new version is created every time you runfrieda
. (Edit.frieda-metadata/.gitignore
to change this behavior.) .friedarc.json
saves your current options.
Generated code files
Freida generates the following two files in the outputDirectory
:
frieda-models.ts
This file contains model type definitions for each table and view. It's meant to be edited. See Modify field types infrieda-models.ts
. Note that the types here are only analyzed by Frieda — they are not used in the generated database code.frieda.{ts|js}
This file exports application-ready database code, including model types and theApplicationDatabase
class.- If the compileJs is true an additional
frieda.d.ts
file will be created next tofrieda.js
. The.d.ts
file contains the types stripped out when compilingfrieda.js
.
Notes:
- You can co-locate other files and folders in
outputDirectory
. Frieda only writes to the file paths mentioned above. - The contents of
outputDirectory
should be considered part of your source code. That is add it to git and include it in your javascript/typescript build step.
Naming Conventions
- Model types are named with the PascalCase'd table name, e.g.
UserAccount
,UserAccountCreate
, etc. for a table named with some variation ofuser_account
orUserAccount
. - Field names are the camelCase'd column name, e.g.
emailVerified
foremail_verified
.
You can use whatever naming convention you want for tables and columns, but there are a couple of edge cases:
- Frieda does not try to fix the case where two tables or two columns within the same table resolve to the same model or field name. For example, tables named
user_account
,user__account
andUserAccount
would all result inUserAccount
. Net: Be consistent when naming tables and columns. - If a table or column name would result in an invalid javascript identifier Frieda prepends an underscore. For example,
2023_stats
is a valid MySQL name but an invalid javascript identifier. It would be turned into_2023Stats
. Net: Try not to do this.
Field Types
When you first run frieda
(and thereafter when a new table or column is added) field types are based on some default conventions mapping MySQL column types to javascript field types. Mostly this will give you the javascript field type you want.
In some cases, however, you will want to override the convention or narrow the type. You can do this by editing the field in frieda-models.ts
, using typescript. Frieda does not limit or validate the javascript type based on the column type. You can type any field as anything you like, as long as it's valid typescript, but...
...a word about casting: In some circumstances Frieda uses the javascript type you define to determine how to cast values. It's therefore up to you to choose a javascript type that won't produce unexpected results. For example...
- Typing numeric columns as
boolean
will work fine. - Typing a string columns as
boolean
will not work.
Field Type Conventions
| MySql Type | Default Javascript Type |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| bigint
| string
(reasoning) |
| tinyint(1)
, boolean
, bool
| boolean
|
| tinyint
(except tinyint(1)
,) int
, integer
, smallint
, mediumint
, year
| number
|
| float
, double
, real
, decimal
, numeric
| number
|
| date
, datetime
, timestamp
| Date
|
| set('a','b')
| Set<'a'|'b'> (reasoning) |
| enum('a','b')
| 'a'|'b' |
| json
| unknown
(reasoning) |
| char
, varchar
, | string
|
| binary
, varbinary
| string
|
| text
, tinytext
, mediumtext
, longtext
| string
|
| blob
, tinyblob
, mediumblob
, longblob
| string
|
| bit
| string
|
| time
| string
|
| Everything else, including column types where there is no corresponding type in vanilla javascript, e.g. the geospatial types | string
|
Why are bigint
columns typed as string
?
- A common (the most common?) case for
bigint
columns is auto-incrementing primary keys, where it does not make (much) sense to manipulate the values in javascript or compare them other than on equality. If doing those things to keys are necessary, it's easy to convert the values withBigInt(id)
. - A stronger argument is that many folks use
JSON
to put data on the wire. Typing columns asbigint
by default would force them to convert the values to string first. - That said, there are definitely cases where you want a
bigint
column to be numeric in javascript, either asbigint
ornumber
. In that case providebigint
ornumber
as the type of the field infrieda-models.ts
. (recipe)
Why are set
columns typed as Set
?
Set
seems more readily manipulable than a string with comma-separated values. (TBH, that's just a theory. Feedback welcome from folks who actually useset
columns)- You can turn this off for an individual field by typing it as
string
infrieda-models.ts
.
Why are json
columns typed as unknown
?
- There's no other valid typescript type for this case. For example things like
{}
orany|any[]
would throw typescript linting errors in addition to (probably) being factually incorrect. - It's easy to specify a useful type in
frieda-models.ts
.
Modifying field types
The frieda-models.ts
file contains a "virtual" model type for each table and view in the database. It's the primary source of truth for Frieda to generate the "real" application model types found in frieda.ts
.
Edit this file to change the javascript type of model fields. Although the file is regenerated each time you run freida
, a change you make here is preserved — so long as the column or its table has not been dropped from the schema. Previous versions are saved in .frieda-metadata/history
.
The model types in frieda-models.ts
are not (and cannot be) exported. This prevents your code from importing the "virtual" types by accident. The types in this file only exist to be analyzed by Frieda. Changes do not automatically update the types in frieda.ts
. You need to re-run frieda
for the changes to take effect.
Field types in this file should not include |null
or optionality (?
), just the javascript type. Frieda adds |null
and optionality to the actual model types where appropriate.
type Foo = {
id: string;
- bar?: string|null;
+ bar: string;
}
Top-level import declarations are not allowed. Such imports cannot be preserved. Use inline import('foo').Bar
statements instead:
- import Stripe from 'stripe';
type StripeCustomer = {
userId: string;
- customer: Stripe.Customer;
+ customer: import('stripe').Stripe.Customer;
}
Recipe: Typing json
fields
Model fields based on json
columns can be typed with a literal type...
type Cat {
// literal type...
lastSeenLocation: {lat: number; lng: number; upTree: boolean};
}
...or an import...
type Cat {
// imported from project code...
prefs: import('../api.js').CatPreferences;
// imported from a library...
savedCard: import('stripe').Stripe.Source;
}
Remember that top level import declaration are not allowed in
frieda-models.ts
. Instead, use the inlineimport('foo').Bar
syntax shown above.
Recipe: Typing bigint
aggregate fields
As mentioned above, by default bigint
columns are typed as javascript string
. This makes sense for primary/secondary keys, but it's a pain in the neck when you actually want a numeric value, a situation that often occurs when dealing with view columns that do aggregation. Fix:
// assume all the columns in this database view are `bigint`...
type CatPersonLeaderboardStats {
// Keep as `string`, since it's a key.
catPersonId: string;
// Let's type this one as `number`, since we know there can't possibly be more than
// 9,007,199,254,740,991 cats in the whole world, much less belonging to one person. Right?
catCount: number;
// OK, we're not quite so certain about the realistic bounds on this one, or are just
// too lazy to figure it out, so let's err on the safe side.
aggregateFleaCount: bigint;
}
Model Types
For each "virtual" model type in frieda-models.ts
Frieda generates a set of types in frieda.ts
. Models based on database views only have a base model type. Models based on tables have several more types:
Generated model types are intentionally repetitive / verbose. They could be cleverer, but "clever" typescript in this case leads to a less straightforward developer experience.
Base Model Type
Table and view models | Example Type Name: Cat
This type contains all the fields in the model. If a field has been marked as INVISIBLE
it will be optional in the base model type, since INVISIBLE
columns are omitted when the table is queried with SELECT *
. Example
Select All Type
Table models only | Example Type Name: CatSelectAll
This type represents the model when queried with SELECT *
. It omits INVISIBLE
columns. You probably won't have to use it directly. Frieda uses it to infer the result type of model repo find*
methods. TKTK LINK TO SPECIAL select: 'all'
option. Example
Primary Key Type
Table models only | Example Type Name: CatPrimaryKey
This type is returned by the model repo's create
method, and is used to select models by primary key. It's an object to account for tables with multiple primary keys. Example
Create Type
Table models only | Example Type Name: CatCreate
Represents the data needed to create a model. Fields where the underlying column is GENERATED
are omitted. Fields where the underlying column is auto_increment
or has a default value are optional. Example
Update Type
Table models only | Example Type Name: CatUpdate
Represents the data needed to update a model. Primary keys and GENERATED
columns are omitted. All other fields are optional. Example
Find Unique Type
Table models only | Example Type Name: CatFindUnique
Type representing the ways one can uniquely select a model. This always includes the primary key type plus types derived from the table's other unique indexes. Example
Example: Model types generated for a table
Table:
CREATE TABLE `Cat` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`ownerId` bigint unsigned NOT NULL,
`name` varchar(100) NOT NULL,
`email` varchar(350) NOT NULL,
`lastSeen` json,
`shortDescription` varchar(320),
`longDescription` text INVISIBLE,
`fleaCount` bigint NOT NULL DEFAULT(0),
`height` double NOT NULL,
`length` double NOT NULL,
`breadth` double NOT NULL,
`volume` double GENERATED ALWAYS AS (`height` * `length` * `breadth`) STORED NOT NULL
PRIMARY KEY (`id`),
UNIQUE KEY `CatEmail` (`email`)
)
Field type changes in frieda-models.ts
:
// frieda-models.ts
type Cat = {
id: string;
ownerId: string;
name: string;
email: string;
- lastSeen: unknown;
+ lastSeen: {lat: number; lng: number; upTree: boolean}
shortDescription: string;
longDescription: string;
- fleaCount: string;
+ fleaCount: number;
height: number;
length: number;
breadth: number;
volume: number;
};
Cat
: Base Model Type
// frieda.ts
export type Cat = {
id: string;
ownerId: string;
name: string;
email: string;
lastSeen: { lat: number; lng: number; upTree: boolean } | null;
shortDescription: string | null;
longDescription?: string | null;
fleaCount: number;
height: number;
length: number;
breadth: number;
volume: number;
};
Notes:
longDescription
is optional, since the column is markedINVISIBLE
. It won't be present when the model is selected withSELECT *
.
CatSelectAll
: Select All Type
// frieda.ts
export type CatSelectAll = {
id: string;
ownerId: string;
name: string;
email: string;
lastSeen: { lat: number; lng: number; upTree: boolean } | null;
shortDescription: string | null;
fleaCount: number;
height: number;
length: number;
breadth: number;
volume: number;
};
Notes:
longDescription
is omitted, since the column is markedINVISIBLE
. It won't be present when the model is selected withSELECT *
.
CatPrimaryKey
: Primary Key Type
// frieda.ts
export type CatPrimaryKey = {
id: string;
};
CatCreate
: Create Type
// frieda.ts
export type CatCreate = {
id?: string;
ownerId: string;
name: string;
email: string;
lastSeen?: { lat: number; lng: number; upTree: boolean } | null;
shortDescription?: string | null;
longDescription?: string | null;
fleaCount?: number;
height: number;
length: number;
breadth: number;
};
Notes:
fleaCount
is optional, since the column has a default value.- The nullable fields are optional.
volume
is omitted, since it's column isGENERATED ALWAYS
CatUpdate
: Update Type
// frieda.ts
export type CatUpdate = {
ownerId?: string;
name?: string;
email?: string;
lastSeen?: { lat: number; lng: number; upTree: boolean } | null;
shortDescription?: string | null;
longDescription?: string | null;
fleaCount?: number;
height?: number;
length?: number;
breadth?: number;
};
Notes:
id
is omitted, since it's the primary key.volume
is omitted, since it's column isGENERATED ALWAYS
- All other fields are optional.
CatFindUnique
: FindUnique Type
// frieda.ts
export type CatFindUnique = CatPrimaryKey | { email: string };
Notes:
email
has a unique index.
Casting
The serverless driver returns all database column values as javascript string|null
. Casting means turning that raw string|null
value into a field value whose javascript type matches what you expect.
Frieda defines eight cast types:
| CastType
| Casting Algorithm |
| ---------------------------- | ---------------------------------- |
| 'bigint'
| (val) => BigInt(val)
|
| 'int'
| (val) => parseInt(val)
|
| 'float'
| (val) => parseFloat(val)
|
| 'boolean'
| (val) => parseInt(val) !== 0
|
| 'json'
| (val) => JSON.parse(val)
|
| 'date'
| (val) => new Date(val)
|
| 'set'
| (val) => new Set(val.split(','))
|
| 'string'
| n/a |
In all cases if a column value is null
it isn't cast — the field value will be null
.
Arbitrary SELECT
Queries
ApplicationDatabase
allows for executing arbitrary SELECT
queries with db.selectMany(query)
, db.selectFirst(query)
and db.selectFirstOrThrow(query)
. These methods accept a second, optional CustomModelCast
argument. This is a partial map from the selected fields to a CastType
.
Example:
type CatPersonStats = {
catPersonId: string;
catCount: bigint;
fleaCount: bigint;
};
const customCast: CustomModelCast<CatPersonStats> = {
catCount: 'number',
fleaCount: 'bigint'
};
const { rows } = await db.selectMany<CatPersonStats>(
sql`
SELECT
CatPerson.id AS catPersonId,
COALESCE(CatStats.catCount, 0) AS catCount,
COALESCE(CatStats.fleaCount, 0) AS fleaCount
FROM
CatPerson
LEFT JOIN (
SELECT
Cat.ownerId AS ownerId,
COUNT(*) AS catCount,
SUM(Cat.fleaCount) AS fleaCount
FROM
Cat
GROUP BY
Cat.ownerId
) AS CatStats ON CatStats.ownerId = CatPerson.id;
`,
customCast
);
rows
is typed asCatPersonStats[]
catCount
is cast withparseInt(value)
fleaCount
is cast withBigInt(value)
Options
Frieda's main options are stored in a .friedarc.json
file at the root of the project. This file should be added to git. If it doesn't exist or is somehow invalid, Frieda will prompt you for:
envFilePath
The path to an environment variables file, (e.g. .env
) containing the database URL. The variable name can be either DATABASE_URL
or FRIEDA_DATABASE_URL
. If you use host, username and password to connect to the database, you can easily construct the URL and add it to .env
:
FRIEDA_DATABASE_URL=mysql://<YOUR USERNAME>:<YOUR PASSWORD>@aws.connect.psdb.cloud
Notes:
- The URL specified here is only used by Frieda to query the schema and generate code. The generated code itself uses a PlanetScale connection passed in from application code.
- Remember to add the environment file to
.gitignore
. - You can override the value in
.friedarc.json
by passing--env-file <some-other-env-file>
tofrieda
.
outputDirectory
The folder where the generated database code should go, e.g. src/db
. After you run frieda
this folder will contain:
frieda-models.ts
A file you can edit to modify the javascript types.frieda.ts
A file that exports the generated code
Notes:
- You can keep other files and folders in the
outputDirectory
as long as they do not conflict with the paths mentioned above. - The generated code should be considered part of your source code, that is, added to git and included in your javascript/typescript build step.
- You can override the value in
.friedarc.json
by doingfrieda --output-directory <some-other-path>
.
compileJs
Experimental. Whether or not to produce javascript files rather than typescript. If set, Frieda will generate frieda.js
and frieda.d.ts
rather than frieda.ts
. This should be turned off for typescript projects — just let the project build step take care of it. The same can be said for some javascript projects where the build step can consume typescript.
Failing that, javascript is compiled with:
{
compilerOptions: {
module: ts.ModuleKind.ES2022,
target: ts.ScriptTarget.ESNext,
lib: ['esnext'],
preserveConstEnums: true,
preserveValueImports: true
}
}
Notes:
- Compiling to javascript has been tested and works on a SvelteKit javascript project. If you run into problems with a similar modern framework/build setup please file an issue.
- You can override the value in
.friedarc.json
by doingfrieda --compile-js
.
CLI-only Options
--init
Make changes to the two options above. (You can also edit.friedarc.json
directly.)--help
Show help.
API
type CastType
Documentation TKTK
type CustomModelCast
Documentation TKTK
type SchemaDefinition
Documentation TKTK
class BaseDatabase
Documentation TKTK
class ViewDatabase
Documentation TKTK
class TableDatabase
Documentation TKTK
class ApplicationDatabase
(generated)
Documentation TKTK
class ModelsDatabase
(generated)
Documentation TKTK
class TransactionDatabase
(generated)
Documentation TKTK
utility function: getSearchSql
Documentation TKTK
Contributing
TKTK, basically:
- Setup, including reference userland projects
- PRs
- Tests
Who's Frieda?
The dog. She disavows any responsibility for this project, and considers the time I've spent on it, when I could have been playing keep away with her and Mr. Sheep, to have been wasted.