--- url: /guide/getting-started.md --- # Getting started ## What is Sutando Sutando is an object-relational mapper (ORM) that makes it enjoyable to interact with your database. When using Sutando, each database table has a corresponding "Model" that is used to interact with that table. In addition to retrieving records from the database table, Sutando models allow you to insert, update, and delete records from the table as well. Sutando is highly inspired by [Eloquent](https://laravel.com/docs/9.x/eloquent) as the behaviour is pretty much the same. The name "sutando" is derived from the Stand, a concept in the Japanese manga "JoJo's Bizarre Adventure". Just like how Stands provide power to characters, we hope that Sutando provides powerful features and flexibility to your application. ## Quick Started Install Sutando and mysql database library ::: code-group ```sh [npm] $ npm install sutando mysql2 --save ``` ```sh [yarn] $ yarn add sutando mysql2 ``` ```sh [pnpm] $ pnpm add sutando mysql2 ``` ::: The easiest way to make SQL queries is to use the Database query builder. It allows you to construct simple and complex SQL queries using JavaScript methods. In the following example, we select all the posts from the users table. ```js const { sutando, Model } = require('sutando'); // Add SQL Connection Info sutando.addConnection({ client: 'mysql2', connection: { host : '127.0.0.1', port : 3306, user : 'root', password : '', database : 'test' }, }); const db = sutando.connection(); // Using The Query Builder const users = await sutando.table('users').where('votes', '>', 100).get(); // or const users = await db.table('users').where('votes', '>', 100).get(); // Using The Schema Builder await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.integer('votes'); table.timestamps(); }); // Using The ORM class User extends Model {} const users = await User.query().where('votes', '>', 100).get(); ``` --- --- url: /guide/installation.md --- # Installation The primary target environment for Sutando is Node.js, you will need to install the sutando library, and then install the appropriate database library: `pg` for PostgreSQL, CockroachDB and Amazon Redshift, `pg-native` for PostgreSQL with native C++ `libpq` bindings (requires PostgresSQL installed to link against), `mysql` for MySQL or MariaDB, `sqlite3` for SQLite3, or `tedious` for MSSQL. ## Installing Sutando is available via npm (or yarn/pnpm). ::: code-group ```sh [npm] $ npm install sutando --save ``` ```sh [yarn] $ yarn add sutando ``` ```sh [pnpm] $ pnpm add sutando ``` ::: You also need to install one of the following depending on the database you want to use: ::: code-group ```sh [npm] $ npm install pg --save $ npm install sqlite3 --save $ npm install better-sqlite3 --save $ npm install mysql --save $ npm install mysql2 --save $ npm install tedious --save ``` ```sh [yarn] $ yarn add pg $ yarn add sqlite3 $ yarn add better-sqlite3 $ yarn add mysql $ yarn add mysql2 $ yarn add tedious ``` ```sh [pnpm] $ pnpm add pg $ pnpm add sqlite3 $ pnpm add better-sqlite3 $ pnpm add mysql $ pnpm add mysql2 $ pnpm add tedious ``` ::: ## Configuration To connect to the database, you must add a connection. The client parameter is required and determines which client adapter will be used with the library. ### MySQL ```js const { sutando } = require('./sutando'); sutando.addConnection({ client: 'mysql2', connection: { host : '127.0.0.1', port : 3306, user : 'your_database_user', password : 'your_database_password', database : 'myapp_test' }, }); // And you can add multiple connections, just specify the connection name. sutando.addConnection({ client: 'mysql2', connection: { host : '127.0.0.1', port : 3306, user : 'another_database_user', password : 'another_database_password', database : 'myapp_another' }, }, 'another_mysql'); const db = sutando.connection('another_mysql'); ``` The connection options are passed directly to the appropriate database client to create the connection, and may be either an object, a connection string, or a function returning an object: ### SQLite3 or Better-SQLite3 When you use the SQLite3 or Better-SQLite3 adapter, there is a filename required, not a network connection. For example: ```js sutando.addConnection({ client: 'sqlite3', // or 'better-sqlite3' connection: { filename: "./mydb.sqlite" } }); ``` You can also run either SQLite3 or Better-SQLite3 with an in-memory database by providing `:memory:` as the filename. For example: ```js sutando.addConnection({ client: 'sqlite3', // or 'better-sqlite3' connection: { filename: ":memory:" } }); ``` When you use the SQLite3 adapter, you can set flags used to open the connection. For example: ```js sutando.addConnection({ client: 'sqlite3', connection: { filename: "file:memDb1?mode=memory&cache=shared", flags: ['OPEN_URI', 'OPEN_SHAREDCACHE'] } }); ``` ### PostgreSQL The database version can be added in sutando configuration, when you use the PostgreSQL adapter to connect a non-standard database. ```js sutando.addConnection({ client: 'pg', version: '7.2', connection: { host : '127.0.0.1', port : 3306, user : 'your_database_user', password : 'your_database_password', database : 'myapp_test' } }); ``` --- --- url: /guide/query-builder.md --- # Query Builder Sutando query builder provides a convenient, fluent interface to creating and running database queries. It can be used to perform most database operations in your application and works perfectly with all of Sutando's supported database systems. Sutando query builder allows you to write and execute SQL queries. It is built on top of [Knex.js](https://knexjs.org/) with few opinionated changes. We have divided the query builders into following categories * The standard query builder allows you to construct SQL queries for select, update and delete operations. * The insert query builder allows you to construct SQL queries for the insert operations. * The raw query builder let you write and execute queries from a raw SQL string. ## Running Database Queries ### Running SQL Queries Once you have configured your database connection, you may use the `raw` method to run a basic query: ```js const db = sutando.connection(); const response = await db.raw('SET TIME_ZONE = ?', ['UTC']); ``` The response will be whatever the underlying SQL library (e.g. mysql2) would normally return in a normal query, so you may want to look at the documentation of the underlying library the query is executing on to determine how to handle the response. ### Retrieving All Rows From A Table You may use the `table` method provided by the DB to begin a query. The `table` method returns a fluent query builder instance for the given table, allowing you to chain more constraints onto the query and then finally retrieve the results of the query using the `get` method: ```js const db = sutando.connection(); const users = await db.table('users').get(); ``` The `get` method returns an array containing the results of the query where each result is an object. You may access each column's value by accessing the column as a property of the object: ```js const users = await db.table('users').get(); users.map(user => { console.log(user.name); }) ``` ### Retrieving A Single Row / Column From A Table If you just need to retrieve a single row from a database table, you may use the `first` method: ```js const user = await db.table('users').where('name', 'John').first(); console.log(user.email); ``` To retrieve a single row by its `id` column value, use the `find` method: ```js const user = await db.table('users').find(3); ``` ### Retrieving A List Of Column Values If you would like to retrieve an array containing the values of a single column, you may use the `pluck` method. In this example, we'll retrieve a collection of user titles: ```js const titles = await db.table('users').pluck('title'); titles.map(title => { console.log(title) }); ``` ## Chunking Results If you need to work with thousands of database records, consider using the `chunk` method. This method retrieves a small chunk of results at a time and feeds each chunk into a closure for processing. For example, let's retrieve the entire `users` table in chunks of 100 records at a time: ```js await db.table('users').orderBy('id').chunk(100, users => { users.map(user => { // do something... }) }); ``` You may stop further chunks from being processed by returning `false` from the closure: ```js await db.table('users').orderBy('id').chunk(100, users => { // Process the records... return false; }); ``` ## Aggregates The query builder also provides a variety of methods for retrieving aggregate values like count, `max`, `min`, `avg`, and `sum`. You may call any of these methods after constructing your query: ```js const count = await db.table('users').count(); const price = await db.table('orders').max('price'); ``` Of course, you may combine these methods with other clauses to fine-tune how your aggregate value is calculated: ```js const price = await db.table('orders') .where('finalized', 1) .avg('price'); ``` ### Determining If Records Exist Instead of using the `count` method to determine if any records exist that match your query's constraints, you may use the `exists` methods: ```js const isExists = await table('orders').where('finalized', 1).exists() if (isExists) { // ... } ``` ## Select Statements ### Specifying A Select Clause You may not always want to select all columns from a database table. Using the `select` method, you can specify a custom "select" clause for the query: ```js const users = await db.table('users') .select('name', 'email as user_email') .get(); ``` The `distinct` method allows you to force the query to return distinct results: ```js const users = await db.table('users').distinct().get(); ``` ## Raw Expressions Sometimes you may need to insert an arbitrary string into a query. To create a raw string expression, you may use the `raw` method: ```js const users = await db.table('users') .select(db.raw('count(*) as user_count, status')) .where('status', '<>', 1) .groupBy('status') .get(); ``` ### Raw Methods Instead of using the `raw` method, you may also use the following methods to insert a raw expression into various parts of your query. Sutando can not guarantee that any query using raw expressions is protected against SQL injection vulnerabilities. #### whereRaw The `whereRaw` methods can be used to inject a raw "where" clause into your query. These methods accept an optional array of bindings as their second argument: ```js const orders = await db.table('orders') .whereRaw('price > IF(state = "TX", ?, 100)', [200]) .get(); ``` #### havingRaw The `havingRaw` and `orHavingRaw` methods may be used to provide a raw string as the value of the "having" clause. These methods accept an optional array of bindings as their second argument: ```js const orders = await db.table('orders') .select('department', db.raw('SUM(price) as total_sales')) .groupBy('department') .havingRaw('SUM(price) > ?', [2500]) .get(); ``` #### orderByRaw The `orderByRaw` method may be used to provide a raw string as the value of the "order by" clause: ```js const orders = await db.table('orders') .orderByRaw('updated_at - created_at DESC') .get(); ``` #### groupByRaw The `groupByRaw` method may be used to provide a raw string as the value of the "group by" clause: ```js const orders = await db.table('orders') .select('city', 'state') .groupByRaw('city, state') .get(); ``` ## Joins ### Inner Join Clause The query builder may also be used to add join clauses to your queries. To perform a basic "inner join", you may use the `join` method on a query builder instance. The first argument passed to the `join` method is the name of the table you need to join to, while the remaining arguments specify the column constraints for the join. You may even join multiple tables in a single query: ```js const users = await db.table('users') .join('contacts', 'users.id', '=', 'contacts.user_id') .join('orders', 'users.id', '=', 'orders.user_id') .select('users.*', 'contacts.phone', 'orders.price') .get(); ``` ### Left Join / Right Join Clause If you would like to perform a "left join" or "right join" instead of an "inner join", use the `leftJoin` or `rightJoin` methods. These methods have the same signature as the join method: ```js const users = await db.table('users') .leftJoin('posts', 'users.id', '=', 'posts.user_id') .get(); const users = await db.table('users') .rightJoin('posts', 'users.id', '=', 'posts.user_id') .get(); ``` ### Cross Join Clause You may use the `crossJoin` method to perform a "cross join". Cross joins generate a cartesian product between the first table and the joined table: ```js const sizes = await db.table('sizes') .crossJoin('colors') .get(); ``` ### Advanced Join Clauses You may also specify more advanced join clauses. To get started, pass a closure as the second argument to the `join` method. ```js await db.table('users') .join('contacts', () => { this.on('users.id', '=', 'contacts.user_id').orOn(/* ... */); }) .get(); ``` ## Unions The query builder also provides a convenient method to "union" two or more queries together. For example, you may create an initial query and use the `union` method to union it with more queries: ```js const first = db.table('users') .whereNull('first_name'); const users = await db.table('users') .whereNull('last_name') .union(first) .get(); ``` In addition to the `union` method, the query builder provides a `unionAll` method. Queries that are combined using the `unionAll` method will not have their duplicate results removed. The `unionAll` method has the same method signature as the `union` method. ## Basic Where Clauses ### Where Clauses You may use the query builder's `where` method to add "where" clauses to the query. The most basic call to the `where` method requires three arguments. The first argument is the name of the column. The second argument is an operator, which can be any of the database's supported operators. The third argument is the value to compare against the column's value. For example, the following query retrieves users where the value of the `votes` column is equal to `100` and the value of the `age` column is greater than `35`: ```js const users = await db.table('users') .where('votes', '=', 100) .where('age', '>', 35) .get(); ``` For convenience, if you want to verify that a column is = to a given value, you may pass the value as the second argument to the `where` method. Sutando will assume you would like to use the `=` operator: ```js const users = await db.table('users').where('votes', 100).get(); ``` As previously mentioned, you may use any operator that is supported by your database system: ```js const users = await db.table('users') .where('votes', '>=', 100) .get(); const users = await db.table('users') .where('votes', '<>', 100) .get(); const users = await db.table('users') .where('name', 'like', 'T%') .get(); ``` ### Or Where Clauses When chaining together calls to the query builder's `where` method, the "where" clauses will be joined together using the `and` operator. However, you may use the `orWhere` method to join a clause to the query using the `or` operator. The `orWhere` method accepts the same arguments as the `where` method: ```js const users = await db.table('users') .where('votes', '>', 100) .orWhere('name', 'John') .get(); ``` If you need to group an "or" condition within parentheses, you may pass a closure as the first argument to the `orWhere` method: ```js const users = await db.table('users') .where('votes', '>', 100) .orWhere(query => { query.where('name', 'Abigail') .where('votes', '>', 50); }) .get(); ``` The example above will produce the following SQL: ```SQL select * from users where votes > 100 or (name = 'Abigail' and votes > 50) ``` ### Where Not Clauses The `whereNot` and `orWhereNot` methods may be used to negate a given group of query constraints. For example, the following query excludes products that are on clearance or which have a price that is less than ten: ```js const products = await db.table('products') .whereNot(() => { this.where('clearance', true).orWhere('price', '<', 10); }) .get(); ``` ### Additional Where Clauses #### whereBetween / orWhereBetween The `whereBetween` method verifies that a column's value is between two values: ```js const users = await db.table('users') .whereBetween('votes', [1, 100]) .get(); ``` #### whereNotBetween / orWhereNotBetween The `whereNotBetween` method verifies that a column's value lies outside of two values: ```js const users = await db.table('users') .whereNotBetween('votes', [1, 100]) .get(); ``` #### whereIn / whereNotIn / orWhereIn / orWhereNotIn The `whereIn` method verifies that a given column's value is contained within the given array: ```js const users = await db.table('users') .whereIn('id', [1, 2, 3]) .get(); ``` The `whereNotIn` method verifies that the given column's value is not contained in the given array: ```js const users = await db.table('users') .whereNotIn('id', [1, 2, 3]) .get(); ``` #### whereNull / whereNotNull / orWhereNull / orWhereNotNull The `whereNull` method verifies that the value of the given column is NULL: ```js const users = await db.table('users') .whereNull('updated_at') .get(); ``` The `whereNotNull` method verifies that the column's value is not NULL: ```js const users = await db.table('users') .whereNotNull('updated_at') .get(); ``` ### WhereX There's an elegant way to turn this: ```js const users = await User.query().where('approved', 1).get(); const posts = await Post.query().where('views_count', '>', 100).get(); ``` Into this: ```js const users = await User.query().whereApproved(1).get(); const posts = await Post.query().whereViewsCount('>', 100).get(); ``` ### Logical Grouping Sometimes you may need to group several "where" clauses within parentheses in order to achieve your query's desired logical grouping. In fact, you should generally always group calls to the `orWhere` method in parentheses in order to avoid unexpected query behavior. To accomplish this, you may pass a closure to the `where` method: ```js const users = await db.table('users') .where('name', '=', 'John') .where(() => { this.where('votes', '>', 100).orWhere('title', '=', 'Admin'); }) .get(); ``` As you can see, passing a closure into the `where` method instructs the query builder to begin a constraint group. The closure will receive a query builder instance which you can use to set the constraints that should be contained within the parenthesis group. The example above will produce the following SQL: ```SQL select * from users where name = 'John' and (votes > 100 or title = 'Admin') ``` ## Ordering, Grouping ### Ordering #### The `orderBy` Method The `orderBy` method allows you to sort the results of the query by a given column. The first argument accepted by the `orderBy` method should be the column you wish to sort by, while the second argument determines the direction of the sort and may be either `asc` or `desc`: ```js const users = await db.table('users') .orderBy('name', 'desc') .get(); ``` To sort by multiple columns, you may simply invoke `orderBy` as many times as necessary: ```js const users = await db.table('users') .orderBy('name', 'desc') .orderBy('email', 'asc') .get(); ``` #### The `latest` & `oldest` Methods The `latest` and `oldest` methods allow you to easily order results by date. By default, the result will be ordered by the table's `created_at` column. Or, you may pass the column name that you wish to sort by: ```js const user = await db.table('users') .latest() .first(); ``` #### Removing Existing Orderings The `clearOrder` method removes all of the "order by" clauses that have previously been applied to the query: ```js const query = db.table('users').orderBy('name'); const unorderedUsers = await query.clearOrder().get(); ``` ### Grouping #### The `groupBy` & `having` Methods As you might expect, the `groupBy` and `having` methods may be used to group the query results. The `having` method's signature is similar to that of the `where` method: ```js const users = await db.table('users') .groupBy('account_id') .having('account_id', '>', 100) .get(); ``` You can use the `havingBetween` method to filter the results within a given range: ```js const report = await db.table('orders') .selectRaw('count(id) as number_of_orders, customer_id') .groupBy('customer_id') .havingBetween('number_of_orders', [5, 15]) .get(); ``` You may pass multiple arguments to the `groupBy` method to group by multiple columns: ```js const users = await db.table('users') .groupBy('first_name', 'status') .having('account_id', '>', 100) .get(); ``` To build more advanced having statements, see the `havingRaw` method. ## Limit & Offset #### The `skip` & `take` Methods You may use the `skip` and `take` methods to limit the number of results returned from the query or to skip a given number of results in the query: ```js const users = await db.table('users').skip(10).take(5).get(); ``` Alternatively, you may use the `limit` and `offset` methods. These methods are functionally equivalent to the `take` and `skip` methods, respectively: ```js const users = await db.table('users') .offset(10) .limit(5) .get(); ``` ## Insert Statements The query builder also provides an `insert` method that may be used to insert records into the database table. The `insert` method accepts an array of column names and values: ```js await db.table('users').insert({ email: 'kayla@example.com', votes: 0 }); ``` You may insert several records at once by passing an array of arrays. Each array represents a record that should be inserted into the table: ```js await db.table('users').insert([ { email: 'picard@example.com', votes: 0 }, { email: 'janeway@example.com', votes: 0 }, ]); ``` ## Update Statements In addition to inserting records into the database, the query builder can also update existing records using the `update` method. The `update` method, like the `insert` method, accepts an array of column and value pairs indicating the columns to be updated. The `update` method returns the number of affected rows. You may constrain the update query using where clauses: ```js await db.table('users') .where('id', 1) .update({ votes: 1 }); ``` ## Increment & Decrement The query builder also provides convenient methods for incrementing or decrementing the value of a given column. Both of these methods accept at least one argument: the column to modify. A second argument may be provided to specify the amount by which the column should be incremented or decremented: ```js await db.table('users').increment('votes'); await db.table('users').increment('votes', 5); await db.table('users').decrement('votes'); await db.table('users').decrement('votes', 5); ``` ## Delete Statements The query builder's `delete` method may be used to delete records from the table. The `delete` method returns the number of affected rows. You may constrain delete statements by adding "where" clauses before calling the `delete` method: ```js const deleted = await db.table('users').delete(); const deleted = await db.table('users').where('votes', '>', 100).delete(); ``` ## Pessimistic Locking The query builder also includes a few functions to help you achieve "pessimistic locking" when executing your `select` statements. To execute a statement with a "shared lock", you may call the `forShare` method. A shared lock prevents the selected rows from being modified until your transaction is committed: ```js await db.table('users') .where('votes', '>', 100) .forShare() .get(); ``` Alternatively, you may use the `forUpdate` method. A "for update" lock prevents the selected records from being modified or from being selected with another shared lock: ```js await db.table('users') .where('votes', '>', 100) .forUpdate() .get(); ``` --- --- url: /guide/models.md --- # Models Sutando has data models built on top of the active record pattern . The data models layer of Sutando makes it super easy to perform CRUD operations, manage relationships between models. We recommend using models extensively and reach for the standard query builder for particular use cases. ## Creating your first model Let's examine a basic model class and discuss some of Sutando's key conventions: ```js const { Model } = require('sutando'); class Flight extends Model { // } ``` ### Table Names After glancing at the example above, you may have noticed that we did not tell Sutando which database table corresponds to our `Flight` model. By convention, the "snake case", plural name of the class will be used as the table name unless another name is explicitly specified. So, in this case, Sutando will assume the `Flight` model stores records in the `flights` table, while an `AirTrafficController` model would store records in an `air_traffic_controllers` table. If your model's corresponding database table does not fit this convention, you may manually specify the model's table name by defining a table property on the model: ```js const { Model } = require('sutando'); class Flight extends Model { // The table associated with the model. table = 'my_flights'; } ``` ### Primary Keys Sutando will also assume that each model's corresponding database table has a primary key column named `id`. If necessary, you may define a protected `primaryKey` property on your model to specify a different column that serves as your model's primary key: ```js const { Model } = require('sutando'); class Flight extends Model { // The primary key associated with the table. primaryKey = 'flight_id'; } ``` If you wish to use a non-incrementing or a non-numeric primary key you must define a `incrementing` property on your model that is set to false: ```js class Flight extends Model { // Indicates if the model's ID is auto-incrementing. incrementing = false; } ``` If your model's primary key is not an integer, you should define a `keyType` property on your model. This property should have a value of string: ```js class Flight extends Model { // The data type of the auto-incrementing ID. keyType = 'string'; } ``` ### UUID & String Keys You can choose to use a string instead of an auto-incrementing integer as the model's primary key. For example, use a UUID as the primary key by defining a `newUniqueId` method in the model: ::: code-group ```sh [npm] $ npm install uuid --save ``` ```sh [yarn] $ yarn add uuid ``` ```sh [pnpm] $ pnpm add uuid ``` ::: ```js const { Model, compose, HasUniqueIds } = require('sutando'); const uuid = require('uuid'); class Article extends compose(Model, HasUniqueIds) { newUniqueId() { return uuid.v4(); } // ... } const article = await Article.create({ title: 'Traveling to Europe' }); article.id; // "8f8e8478-9035-4d23-b9a7-62f4d2612ce5" ``` ### Timestamps By default, Sutando expects `created_at` and `updated_at` columns to exist on your model's corresponding database table. Sutando will automatically set these column's values when models are created or updated. If you do not want these columns to be automatically managed by Sutando, you should define a `timestamps` property on your model with a value of false: ```js const { Model } = require('sutando'); class Flight extends Model { // Indicates if the model should be timestamped. timestamps = false; } ``` If you need to customize the names of the columns used to store the timestamps, you may set `CREATED_AT` and `UPDATED_AT` properties pro on your model: ```js const { Model } = require('sutando'); class Flight extends Model { static CREATED_AT = 'creation_date'; static UPDATED_AT = 'updated_date'; } ``` ### Database Connections By default, all Sutando models will use the `default` database connection that is configured for your application. If you would like to specify a different connection that should be used when interacting with a particular model, you should define a `connection` property on the model: ```js const { Model } = require('sutando'); class Flight extends Model { connection = 'sqlite'; } ``` ### Default Attribute Values By default, a newly instantiated model instance will not contain any attribute values. If you would like to define the default values for some of your model's attributes, you may define an `attributes` property on your model. Attribute values placed in the `attributes` should be in their raw, "storable" format as if they were just read from the database: ```js const { Model } = require('sutando'); class Flight extends Model { attributes = { options: '[]', delayed: false, }; } ``` ## Retrieving Models Once you have created a model and its associated database table, you are ready to start retrieving data from your database. You can think of each Sutando model as a powerful query builder allowing you to fluently query the database table associated with the model. The model's all method will retrieve all of the records from the model's associated database table: ```js const { Flight } = require('./models'); const flights = await Flight.query().all(); flights.map(flight => { console.log(flight.name) }) ``` #### Building Queries The Sutando `all` method will return all of the results in the model's table. However, since each Sutando model serves as a query builder, you may add additional constraints to queries and then invoke the `get`/`first`/`find` method to retrieve the results: ```js const flights = await Flight.query().where('active', 1) .orderBy('name') .take(10) .get(); const flight = await Flight.query().where('active', 1).first(); const flight = await Flight.query().find(5); ``` #### Refreshing Models If you already have an instance of an Sutando model that was retrieved from the database, you can "refresh" the model using the `fresh` and `refresh` methods. The `fresh` method will re-retrieve the model from the database. The existing model instance will not be affected: ```js const flight = await Flight.query().where('number', 'FR 900').first(); const freshFlight = await flight.fresh(); ``` The `refresh` method will re-hydrate the existing model using fresh data from the database. In addition, all of its loaded relationships will be refreshed as well: ```js const flight = await Flight.query().where('number', 'FR 900').first(); flight.number = 'FR 456'; await flight.refresh(); flight.number; // "FR 900" ``` ### Collections As we have seen, Sutando methods like `all` and `get` retrieve multiple records from the database. However, these methods don't return a plain array. Instead, an instance of [Collection](collections) is returned. The Sutando Collection class extends [`collect.js`](https://collect.js.org/) class, which provides a [variety of helpful methods](collections#available-methods) for interacting with data collections. For example, the reject method may be used to remove models from a collection based on the results of an invoked closure: ```js const flights = await Flight.query().where('destination', 'Paris').get(); const newFlights = flights.reject(flight => { return flight.cancelled; }); ``` In addition to the methods provided by `collect.js`'s base collection class, the Sutando collection class provides [a few extra methods](collections#available-methods) that are specifically intended for interacting with collections of Sutando models. Since all of Sutando collections implement Javascript's iterable interfaces, you may loop over collections as if they were an array: ```js for (let flight of flights) { console.log(flight.name); } ``` ### Chunking Results Your application may run out of memory if you attempt to load tens of thousands of Sutando records via the `all` or `get` methods. Instead of using these methods, the `chunk` method may be used to process large numbers of models more efficiently. The `chunk` method will retrieve a subset of Sutando models, passing them to a closure for processing. Since only the current chunk of Sutando models is retrieved at a time, the `chunk` method will provide significantly reduced memory usage when working with a large number of models: ```js const { Flight } = require('./models'); await Flight.query().chunk(200, flights => { flights.map(flight => { // }); }); ``` ## Retrieving Single Models / Aggregates In addition to retrieving all of the records matching a given query, you may also retrieve single records using the `find` or `first` methods. Instead of returning a collection of models, these methods return a single model instance: ```js const { Flight } = require('./modles'); // Retrieve a model by its primary key... const flight = await Flight.query().find(1); // Retrieve the first model matching the query constraints... const flight = await Flight.query().where('active', 1).first(); ``` ### Not Found Errors Sometimes you may wish to throw an exception if a model is not found. This is particularly useful in routes or controllers. The `findOrFail` and `firstOrFail` methods will retrieve the first result of the query; however, if no result is found, an `ModelNotFoundError` will be thrown: ```js const { ModelNotFoundError } = requre('sutando'); try { const flight = await Flight.query().findOrFail(1); const flight = await Flight.query().where('legs', '>', 3).firstOrFail(); } catch (e) { e instanceof ModelNotFoundError; } ``` With the framework capturing `ModelNotFoundError`, a 404 HTTP response can be automatically sent back to the client: ```js const app = require('express')(); require('express-async-errors'); const { ModelNotFoundError } = requre('sutando'); app.get('/users/:id', async (req, res) => { const user = await User.query().findOrFail(req.params.id); res.send(user); }); app.use((err, req, res, next) => { if (err instanceof ModelNotFoundError) { return res.status(404).send(err.message); } next(err); }); ``` ### Retrieving Or Creating Models The `firstOrCreate` method will attempt to locate a database record using the given column / value pairs. If the model can not be found in the database, a record will be inserted with the attributes resulting from merging the first array argument with the optional second array argument: The `firstOrNew` method, like `firstOrCreate`, will attempt to locate a record in the database matching the given attributes. However, if a model is not found, a new model instance will be returned. Note that the model returned by `firstOrNew` has not yet been persisted to the database. You will need to manually call the `save` method to persist it: ```js const { Flight } = require('./modles'); // Retrieve flight by name or create it if it doesn't exist... const flight = await Flight.query().firstOrCreate({ name: 'London to Paris' }); // Retrieve flight by name or create it with the name, delayed, and arrival_time attributes... const flight = await Flight.query().firstOrCreate( { name: 'London to Paris' }, { delayed: 1, arrival_time: '11:30' } ); // Retrieve flight by name or instantiate a new Flight instance... const flight = await Flight.query().firstOrNew({ name: 'London to Paris' }); // Retrieve flight by name or instantiate with the name, delayed, and arrival_time attributes... const flight = await Flight.query().firstOrNew( { name: 'Tokyo to Sydney' }, { delayed: 1, arrival_time: '11:30' } ); ``` ### Retrieving Aggregates When interacting with Sutando models, you may also use the `count`, `sum`, `max`, and other aggregate methods provided by the [query builder](./query-builder#aggregates). As you might expect, these methods return a scalar value instead of an Sutando model instance: ```js const count = await Flight.query().where('active', 1).count(); // 100 const max = await Flight.query().where('active', 1).max('price'); // 104 const flight = await Flight.query().find(1); // flight instanceof Flight ``` ## Inserting & Updating Models ### Inserts Of course, when using Sutando, we don't only need to retrieve models from the database. We also need to insert new records. Thankfully, Sutando makes it simple. To insert a new record into the database, you should instantiate a new model instance and set attributes on the model. Then, call the `save` method on the model instance: ```js // express const { Flight } = require('./model'); app.post('/flights', async (req, res) => { // Validate the request... const flight = new Flight; flight.name = req.name; await flight.save(); res.send(flight); }); ``` In this example, we assign the name field from the incoming HTTP request to the `name` attribute of the `Flight` model instance. When we call the `save` method, a record will be inserted into the database. The model's `created_at` and `updated_at` timestamps will automatically be set when the `save` method is called, so there is no need to set them manually. Alternatively, you may use the `create` method to "save" a new model using a single Javascript statement. The inserted model instance will be returned to you by the `create` method: ```js const { Flight } = require('./model'); const flight = await Flight.query().create({ name: 'London to Paris', }); ``` ### Updates The `save` method may also be used to `update` models that already exist in the database. To update a model, you should retrieve it and set any attributes you wish to update. Then, you should call the model's save method. Again, the `updated_at` timestamp will automatically be updated, so there is no need to manually set its value: ```js const { Flight } = require('./model'); const flight = await Flight.query().find(1); flight.name = 'Paris to London'; await flight.save(); ``` #### Mass Updates Updates can also be performed against models that match a given query. In this example, all flights that are `active` and have a `destination` of `San Diego` will be marked as delayed: ```js await Flight.query().where('active', 1) .where('destination', 'San Diego') .update({ delayed: 1, }); ``` The `update` method expects an array of column and value pairs representing the columns that should be updated. The `update` method returns the number of affected rows. :::tip When issuing a mass update, the `saving`, `saved`, `updating`, and `updated` model events will not be fired for the updated models. This is because the models are never actually retrieved when issuing a mass update. ::: #### Examining Attribute Changes Sutando provides the `isDirty` methods to examine the internal state of your model and determine how its attributes have changed from when the model was originally retrieved. The `isDirty` method determines if any of the model's attributes have been changed since the model was retrieved. You may pass a specific attribute name or an array of attributes to the `isDirty` method to determine if any of the attributes are "dirty". This method also accepts an optional attribute argument: ```js const { Flight } = require('./model'); const user = await User.query().create({ first_name: 'Taylor', last_name: 'Otwell', title: 'Developer', }); user.title = 'Painter'; user.isDirty(); // true user.isDirty('title'); // true user.isDirty('first_name'); // false user.isDirty(['first_name', 'title']); // true await user.save(); user.isDirty(); // false ``` ### Upserts Occasionally, you may need to update an existing model or create a new model if no matching model exists. Like the `firstOrCreate` method, the `updateOrCreate` method persists the model, so there's no need to manually call the `save` method. In the example below, if a flight exists with a departure location of Oakland and a destination location of San Diego, its `price` and `discounted` columns will be updated. If no such flight exists, a new flight will be created which has the attributes resulting from merging the first argument object with the second argument object: ```js const flight = await Flight.query().updateOrCreate( { departure: 'Oakland', destination: 'San Diego' }, { price: 99, discounted: 1 } ); ``` ## Deleting Models To delete a model, you may call the `delete` method on the model instance: ```js const { Flight } = require('./models'); const flight = await Flight.query().find(1); await flight.delete(); ``` #### Deleting An Existing Model By Its Primary Key In the example above, we are retrieving the model from the database before calling the `delete` method. However, if you know the primary key of the model, you may delete the model without explicitly retrieving it by calling the `destroy` method. In addition to accepting the single primary key, the `destroy` method will accept multiple primary keys, an array of primary keys, or a `Collection` of primary keys: ```js await Flight.query().destroy(1); await Flight.query().destroy(1, 2, 3); await Flight.query().destroy([1, 2, 3]); ``` #### Deleting Models Using Queries Of course, you may build an Sutando query to delete all models matching your query's criteria. In this example, we will delete all flights that are marked as inactive. Like mass updates, mass deletes will not dispatch model events for the models that are deleted: ```js const deleted = await Flight.query().where('active', 0).delete(); ``` ### Soft Deleting In addition to actually removing records from your database, Sutando can also "soft delete" models. When models are soft deleted, they are not actually removed from your database. Instead, a `deleted_at` attribute is set on the model indicating the date and time at which the model was "deleted". To enable soft deletes for a model, use the `SoftDeletes` plugin and add the `deleted_at` field in the corresponding data table: ```js const { Model, compose, SoftDeletes } = require('sutando'); class Flight extends compose(Model, SoftDeletes) { // ... } ``` Now, when you call the `delete` method on the model, the `deleted_at` column will be set to the current date and time. However, the model's database record will be left in the table. When querying a model that uses soft deletes, the soft deleted models will automatically be excluded from all query results. To determine if a given model instance has been soft deleted, you may use the `trashed` method: ```js if (flight.trashed()) { // } ``` #### Restoring Soft Deleted Models Sometimes you may wish to "un-delete" a soft deleted model. To restore a soft deleted model, you may call the `restore` method on a model instance. The `restore` method will set the model's deleted\_at column to null: ```js await flight.restore(); ``` You may also use the `restore` method in a query to restore multiple models. Again, like other "mass" operations, this will not dispatch any model events for the models that are restored: ```js await Flight.query().withTrashed() .where('airline_id', 1) .restore(); ``` The `restore` method may also be used when building relationship queries: ```js await flight.related('history').restore(); ``` #### Permanently Deleting Models Sometimes you may need to truly remove a model from your database. You may use the `forceDelete` method to permanently remove a model from the database table: ```js await flight.forceDelete(); ``` You may also use the `forceDelete` method when building Sutando relationship queries: ```js await flight.related('history').forceDelete(); ``` ### Querying Soft Deleted Models #### Including Soft Deleted Models As noted above, soft deleted models will automatically be excluded from query results. However, you may force soft deleted models to be included in a query's results by calling the `withTrashed` method on the query: ```js const { Flight } = require('./models'); const flights = await Flight.query().withTrashed() .where('account_id', 1) .get(); ``` The `withTrashed` method may also be called when building a relationship query: ```js await flight.related('history').withTrashed().get(); ``` #### Retrieving Only Soft Deleted Models The `onlyTrashed` method will retrieve only soft deleted models: ```js const flights = await Flight.query().onlyTrashed() .where('airline_id', 1) .get(); ``` ## Query Scopes Scopes allow you to define common sets of query constraints that you may easily re-use throughout your application. For example, you may need to frequently retrieve all users that are considered "popular". To define a scope, prefix an Sutando model method with scope. Scopes should always return the same query builder instance or void: ```js const { Model } = require('./models'); class User extends Model { scopePopular(query){ return query.where('votes', '>', 100); } scopeActive(query){ query.where('active', 1); } } ``` ### Utilizing A Scope Once the scope has been defined, you may call the scope methods when querying the model. However, you should not include the `scope` prefix when calling the method. You can even chain calls to various scopes: ```js const { User } = require('./models'); const users = await User.query().popular().active().orderBy('created_at').get(); ``` Combining multiple Sutando model scopes via an `or` query operator may require the use of closures to achieve the correct logical grouping: ```js const users = await User.query().popular().orWhere(query => { query.active(); }).get(); ``` ### Dynamic Scopes Sometimes you may wish to define a scope that accepts parameters. To get started, just add your additional parameters to your scope method's signature. Scope parameters should be defined after the `query` parameter: ```js const { Model } = require('./models'); class User extends Model { scopeOfType(query, type){ return query.where('type', type); } } ``` Once the expected arguments have been added to your scope method's signature, you may pass the arguments when calling the scope: ```js const users = await User.query().ofType('admin').get(); ``` ## Comparing Models Sometimes you may need to determine if two models are the "same" or not. The `is` and `isNot` methods may be used to quickly verify two models have the same primary key, table, and database connection or not: ```js if (post.is(anotherPost)) { // } if (post.isNot(anotherPost)) { // } ``` --- --- url: /guide/pagination.md --- # Pagination Sutando has inbuilt support for offset-based pagination. You can paginate the results of a query by chaining the `paginate` method. The `paginate` method accepts the page number as the first argument and the rows to fetch as the second argument. Internally, we execute an additional query to count the total number of rows. ## Basic Usage ```js const users = await db.table('users') .where('vote', '>', 1) .paginate(2, 15); // instanceof Paginator const users = await User.query() .where('vote', '>', 1) .paginate(1, 15); // instanceof Paginator const users = await db.table('users') .where('vote', '>', 1) .forPage(2, 15) .get(); // instanceof Array const users = await User.query() .where('vote', '>', 1) .forPage(1, 15) .get(); // instanceof Collection users.map(user => { // }); ``` If not specified, the number of lines per page defaults to 15. If using models, you can also set the `perPage` attribute as the default number of pages per model. ```js class Post extends Model {} class User extends Model { perPage = 20; } const posts = await Post.query().paginate(); console.log(posts.perPage()); // 15 const users = await User.query().paginate(); console.log(users.perPage()); // 20 ``` The `paginate` method returns an instance of the `Paginator` . It holds the meta data for the pagination, alongside the fetched rows. Each paginator instance provides additional pagination information via the following methods: | Method | Description | | ---- | ---- | | `paginator.count()` | Get the number of items for the current page. | | `paginator.currentPage()` | Get the current page number. | | `paginator.hasMorePages()` | Determine if there are more items in the data store. | | `paginator.items()` | Get the items for the current page. | | `paginator.lastPage()` | Get the page number of the last available page. | | `paginator.perPage()` | The number of items to be shown per page. | | `paginator.total()` | Determine the total number of matching items in the data store. | ## Serializing to Object/JSON You can also serialize the paginator results to Object/JSON by calling the `toData` or `toJson` method. It returns the key names in snake\_case by default. However, you can pass a naming strategy to override the default convention. ```JSON { "total": 45, "per_page": 15, "current_page": 1, "last_page": 3, "count": 15, "data": [ { // Record... }, { // Record... } ], } ``` ### Custom Format You can override the default format by calling the `Paginator.setFormatter` method. ```js const { Paginator } = require('sutando'); Paginator.setFormatter((paginator) => { return { meta: { total: paginator.total(), per_page: paginator.perPage(), current_page: paginator.currentPage(), last_page: paginator.lastPage(), }, data: paginator.items().toData(), }; }); ``` The paginator will convert it to JSON when converting it to a string, so it can be used directly in the application's route or controller. Your express/Koa application will automatically serialize to JSON: ```js const app = require('express')(); app.get('/', async (req, res) => { const users = await User.query().paginate(req.query.page || 1); res.send(users); }); ``` --- --- url: /guide/relationships.md --- # Relationships Sutando relationships are defined as methods on your Sutando model classes. Since relationships also serve as powerful [query builders](query-builder), defining relationships as methods provides powerful method chaining and querying capabilities. For example, we may chain additional query constraints on this `posts` relationship: ```js await user.related('posts').where('active', 1).get(); ``` But, before diving too deep into using relationships, let's learn how to define each type of relationship supported by Sutando. ## One To One A one-to-one relationship is a very basic type of database relationship. For example, a `User` model might be associated with one `Phone` model. To define this relationship, we will place a phone method on the `User` model. The `relationPhone` method should call the `hasOne` method and return its result. The `hasOne` method is available to your model via the model's `Model` base class: ```js const { Model } = require('sutando'); class Phone extends Model {} class User extends Model { relationPhone() { return this.hasOne(Phone); } } ``` The first argument passed to the `hasOne` method is the name of the related model class. Once the relationship is defined, we may retrieve the related record using `getRelated` method ```js const user = await User.query().find(1); const phone = await user.getRelated('phone'); ``` Sutando determines the foreign key of the relationship based on the parent model name. In this case, the `Phone` model is automatically assumed to have a `user_id` foreign key. If you wish to override this convention, you may pass a second argument to the `hasOne` method: ```js return this.hasOne(Phone, 'foreign_key'); ``` Additionally, Sutando assumes that the foreign key should have a value matching the primary key column of the parent. In other words, Sutando will look for the value of the user's `id` column in the `user_id` column of the `Phone` record. If you would like the relationship to use a primary key value other than id or your model's `primaryKey` property, you may pass a third argument to the `hasOne` method: ```js return this.hasOne(Phone, 'foreign_key', 'local_key'); ``` ### Defining The Inverse Of The Relationship So, we can access the `Phone` model from our `User` model. Next, let's define a relationship on the `Phone` model that will let us access the user that owns the phone. We can define the inverse of a `hasOne` relationship using the `belongsTo` method: ```js const { Model } = require('sutando'); class User extends Model { relationPhone() { return this.hasOne(Phone); } } class Phone extends Model { relationUser() { return this.belongsTo(User); } } ``` When invoking the `related('user')`, Sutando will attempt to find a `User` model that has an `id` which matches the `user_id` column on the `Phone` model. Sutando determines the foreign key name by examining the name of the relationship method and suffixing the method name with `_id`. So, in this case, Sutando assumes that the `Phone` model has a user\_id column. However, if the foreign key on the `Phone` model is not `user_id`, you may pass a custom key name as the second argument to the `belongsTo` method: ```js relationUser() { return this.belongsTo(User, 'foreign_key'); } ``` If the parent model does not use id as its primary key, or you wish to find the associated model using a different column, you may pass a third argument to the `belongsTo` method specifying the parent table's custom key: ```js relationUser() { return this.belongsTo(User, 'foreign_key', 'owner_key'); } ``` ## One To Many A one-to-many relationship is used to define relationships where a single model is the parent to one or more child models. For example, a blog post may have an infinite number of comments. Like all other Sutando relationships, one-to-many relationships are defined by defining a method on your Sutando model: ```js const { Model } = require('sutando'); class Post extends Model { relationComments() { return this.hasMany(Comment); } } ``` Remember, Sutando will automatically determine the proper foreign key column for the `Comment` model. By convention, Sutando will take the "snake case" name of the parent model and suffix it with `_id`. So, in this example, Sutando will assume the foreign key column on the `Comment` model is `post_id`. Once the relationship method has been defined, we can access the collection of related comments by accessing the `getRelated('comments')` method: ```js const { Post } = require('./models'); const post = await Post.query().find(1); const comments = await post.getRelated('comments'); comments.map(comment => { // }); ``` Since all relationships also serve as query builders, you may add further constraints to the relationship query by calling the `related('comments')` method and continuing to chain conditions onto the query: ```js const post = await Post.query().find(1); const comment = await post.related('comments') .where('title', 'foo') .first(); ``` Like the `hasOne` method, you may also override the foreign and local keys by passing additional arguments to the `hasMany` method: ```js return this.hasMany(Comment, 'foreign_key'); return this.hasMany(Comment, 'foreign_key', 'local_key'); ``` ## One To Many (Inverse) / Belongs To Now that we can access all of a post's comments, let's define a relationship to allow a comment to access its parent post. To define the inverse of a `hasMany` relationship, define a relationship method on the child model which calls the `belongsTo` method: ```js const { Model } = require('sutando'); class Comment extends Model { relationPost() { return this.belongsTo(Post); } } ``` In the example above, Sutando will attempt to find a Post model that has an id which matches the `post_id` column on the `Comment` model. Sutando determines the default foreign key name by examining the name of the relationship method and suffixing the method name with a \_ followed by the name of the parent model's primary key column. So, in this example, Sutando will assume the `Post` model's foreign key on the `comments` table is `post_id`. However, if the foreign key for your relationship does not follow these conventions, you may pass a custom foreign key name as the second argument to the belongsTo method: ```js relationPost() { return this.belongsTo(Post, 'foreign_key'); } ``` If your parent model does not use id as its primary key, or you wish to find the associated model using a different column, you may pass a third argument to the `belongsTo` method specifying your parent table's custom key: ```js relationPost() { return this.belongsTo(Post, 'foreign_key', 'owner_key'); } ``` #### Default Models The `belongsTo`, `hasOne` relationships allow you to define a default model that will be returned if the given relationship is null. This pattern is often referred to as the Null Object pattern and can help remove conditional checks in your code. In the following example, the `user` relation will return an empty `User` model if no user is attached to the `Post` model: ```js reLationUser() { return this.belongsTo(User).withDefault(); } ``` To populate the default model with attributes, you may pass a object or closure to the `withDefault` method: ```js reLationUser() { return this.belongsTo(User).withDefault({ name: 'Guest Author' }); } reLationUser() { return this.belongsTo(User).withDefault((user, post) => ({ name: `Post ${post.id} Author` })); } ``` ## Many To Many Relationships Many-to-many relations are slightly more complicated than `hasOne` and `hasMany` relationships. An example of a many-to-many relationship is a user that has many roles and those roles are also shared by other users in the application. For example, a user may be assigned the role of "Author" and "Editor"; however, those roles may also be assigned to other users as well. So, a user has many roles and a role has many users. #### Table Structure To define this relationship, three database tables are needed: `users`, `roles`, and `role_user`. The `role_user` table is derived from the alphabetical order of the related model names and contains `user_id` and `role_id` columns. This table is used as an intermediate table linking the users and roles. Remember, since a role can belong to many users, we cannot simply place a `user_id` column on the `roles` table. This would mean that a role could only belong to a single user. In order to provide support for roles being assigned to multiple users, the `role_user` table is needed. We can summarize the relationship's table structure like so: ``` users id - integer name - string roles id - integer name - string role_user user_id - integer role_id - integer ``` #### Model Structure Many-to-many relationships are defined by writing a method that returns the result of the `belongsToMany` method. The `belongsToMany` method is provided by the Model base class that is used by all of your application's Sutando models. For example, let's define a `relationRoles` method on our `User` model. The first argument passed to this method is the name of the related model class: ```js const { Model } = require('sutando'); class User extends Model { relationRoles() { return this.belongsToMany(Role); } } ``` Since all relationships also serve as query builders, you may add further constraints to the relationship query by calling the `related('roles')` method and continuing to chain conditions onto the query: ```js const user = await User.query().find(1); const roles = await user.related('roles').orderBy('name').get(); ``` To determine the table name of the relationship's intermediate table, Sutando will join the two related model names in alphabetical order. However, you are free to override this convention. You may do so by passing a second argument to the `belongsToMany` method: ```js return this.belongsToMany(Role, 'role_user'); ``` In addition to customizing the name of the intermediate table, you may also customize the column names of the keys on the table by passing additional arguments to the `belongsToMany` method. The third argument is the foreign key name of the model on which you are defining the relationship, while the fourth argument is the foreign key name of the model that you are joining to: ```js return this.belongsToMany(Role, 'role_user', 'user_id', 'role_id'); ``` #### Defining The Inverse Of The Relationship To define the "inverse" of a many-to-many relationship, you should define a method on the related model which also returns the result of the `belongsToMany` method. To complete our `user` / `role` example, let's define the `relationUsers` method on the `Role` model: ```js const { Model } = require('sutando'); class Role extends Model { relationUsers() { return this.belongsToMany(User); } } ``` As you can see, the relationship is defined exactly the same as its `User` model counterpart with the exception of referencing the `User` model. Since we're reusing the `belongsToMany` method, all of the usual table and key customization options are available when defining the "inverse" of many-to-many relationships. ### Retrieving Intermediate Table Columns As you have already learned, working with many-to-many relations requires the presence of an intermediate table. Sutando provides some very helpful ways of interacting with this table. For example, let's assume our `User` model has many Role models that it is related to. After accessing this relationship, we may access the intermediate table using the `pivot` attribute on the models: ```js const { User } = require('./models'); const user = await User.query().find(1); const roles = await user.getRelated('roles'); roles.map(role => { console.log(role.pivot.created_at); }); ``` Notice that each `Role` model we retrieve is automatically assigned a pivot attribute. This attribute contains a model representing the intermediate table. By default, only the model keys will be present on the pivot model. If your intermediate table contains extra attributes, you must specify them when defining the relationship: ```js return this.belongsToMany(Role).withPivot('active', 'created_by'); ``` If you would like your intermediate table to have `created_at` and `updated_at` timestamps that are automatically maintained by Sutando, call the `withTimestamps` method when defining the relationship: ```js return this.belongsToMany(Role).withTimestamps(); ``` #### Customizing The `pivot` Attribute Name As noted previously, attributes from the intermediate table may be accessed on models via the `pivot` attribute. However, you are free to customize the name of this attribute to better reflect its purpose within your application. For example, if your application contains users that may subscribe to podcasts, you likely have a many-to-many relationship between users and podcasts. If this is the case, you may wish to rename your intermediate table attribute to `subscription` instead of `pivot`. This can be done using the `as` method when defining the relationship: ```js return this.belongsToMany(Podcast) .as('subscription') .withTimestamps(); ``` ### Filtering Queries Via Intermediate Table Columns You can also filter the results returned by `belongsToMany` relationship queries using the `wherePivot`, `wherePivotIn`, `wherePivotNotIn`, `wherePivotBetween`, `wherePivotNotBetween`, `wherePivotNull`, and `wherePivotNotNull` methods when defining the relationship: ```js return this.belongsToMany(Role) .wherePivot('approved', 1); return this.belongsToMany(Role) .wherePivotIn('priority', [1, 2]); return this.belongsToMany(Role) .wherePivotNotIn('priority', [1, 2]); return this.belongsToMany(Podcast) .as('subscriptions') .wherePivotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']); return this.belongsToMany(Podcast) .as('subscriptions') .wherePivotNotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']); return this.belongsToMany(Podcast) .as('subscriptions') .wherePivotNull('expired_at'); return this.belongsToMany(Podcast) .as('subscriptions') .wherePivotNotNull('expired_at'); ``` ### Ordering Queries Via Intermediate Table Columns You can order the results returned by `belongsToMany` relationship queries using the `orderByPivot` method. In the following example, we will retrieve all of the latest badges for the user: ```js return this.belongsToMany(Badge) .where('rank', 'gold') .orderByPivot('created_at', 'desc'); ``` ## Querying Relations Since all Sutando relationships are defined via methods, you may call those methods to obtain an instance of the relationship without actually executing a query to load the related models. In addition, all types of Sutando relationships also serve as query builders, allowing you to continue to chain constraints onto the relationship query before finally executing the SQL query against your database. For example, imagine a blog application in which a `User` model has many associated `Post` models: ```js const { Model } = require('sutando'); class User extends Model { relationPosts() { return this.hasMany(Post); } } ``` You may query the posts relationship and add additional constraints to the relationship like so: ```js const { User } = require('./models'); const user = await User.query().find(1); await user.related('posts').where('active', 1).get(); ``` You are able to use any of the Sutando [query builder](query-builder)'s methods on the relationship, so be sure to explore the query builder documentation to learn about all of the methods that are available to you. #### Chaining `orWhere` Clauses After Relationships As demonstrated in the example above, you are free to add additional constraints to relationships when querying them. However, use caution when chaining `orWhere` clauses onto a relationship, as the `orWhere` clauses will be logically grouped at the same level as the relationship constraint: ```js await user.related('posts') .where('active', 1) .orWhere('votes', '>=', 100) .get(); ``` The example above will generate the following SQL. As you can see, the or clause instructs the query to return any user with greater than 100 votes. The query is no longer constrained to a specific user: ```SQL select * from posts where user_id = ? and active = 1 or votes >= 100 ``` In most situations, you should use logical groups to group the conditional checks between parentheses: ```js await user.related('posts') .where(query => { return query.where('active', 1).orWhere('votes', '>=', 100); }) .get(); ``` The example above will produce the following SQL. Note that the logical grouping has properly grouped the constraints and the query remains constrained to a specific user: ```SQL select * from posts where user_id = ? and (active = 1 or votes >= 100) ``` ### Querying Relationship Existence When retrieving model records, you may wish to limit your results based on the existence of a relationship. For example, imagine you want to retrieve all blog posts that have at least one comment. To do so, you may pass the name of the relationship to the `has` and `orHas` methods: ```js const { Post } = require('./models'); // Retrieve all posts that have at least one comment... const posts = await Post.query().has('comments').get(); ``` You may also specify an operator and count value to further customize the query: ```js // Retrieve all posts that have three or more comments... const posts = await Post.query().has('comments', '>=', 3).get(); ``` Nested has statements may be constructed using "dot" notation. For example, you may retrieve all posts that have at least one comment that has at least one image: ```js // Retrieve posts that have at least one comment with images... const posts = await Post.query().has('comments.images').get(); ``` If you need even more power, you may use the `whereHas` and `orWhereHas` methods to define additional query constraints on your has queries, such as inspecting the content of a comment: ```js // Retrieve posts with at least one comment containing words like code%... const posts = await Post.query().whereHas('comments', query => { query.where('content', 'like', 'code%'); }).get(); // Retrieve posts with at least ten comments containing words like code%... const posts = await Post.query().whereHas('comments', query => { query.where('content', 'like', 'code%'); }, '>=', 10).get(); ``` ## Aggregating Related Models ### Counting Related Models Sometimes you may want to count the number of related models for a given relationship without actually loading the models. To accomplish this, you may use the `withCount` method. The `withCount` method will place a `{relation}_count` attribute on the resulting models: ```js const { Post } = require('./models'); const posts = await Post.query().withCount('comments').get(); posts.map(post => { console.log(post.comments_count); }); ``` By passing an array to the `withCount` method, you may add the "counts" for multiple relations as well as add additional constraints to the queries: ```js const posts = await Post.query().withCount({ comments: query => query.where('content', 'like', 'code%'); }).get(); console.log(posts.get(0).comments_count); ``` ### Deferred Count Loading Using the `loadCount` method, you may load a relationship count after the parent model has already been retrieved: ```js const book = await Book.query().first(); await book.loadCount('genres'); ``` If you need to set additional query constraints on the count query, you may pass an array keyed by the relationships you wish to count. The array values should be closures which receive the query builder instance: ```js await book.loadCount({ reviews: query => query.where('rating', 5); }) ``` ### Relationship Counting & Custom Select Statements If you're combining `withCount` with a select statement, ensure that you call `withCount` after the `select` method: ```js const posts = await Post.query().select(['title', 'body']) .withCount('comments') .get(); ``` ### Other Aggregate Functions In addition to the `withCount` method, Sutando provides `withMin`, `withMax`, `withAvg`, `withSum`, and `withExists` methods. These methods will place a `{relation}_{function}_{column}` attribute on your resulting models: ```js const { Post } = require('./models'); const posts = await Post.query().withSum('comments', 'votes').get(); posts.map(post => { console.log(post.comments_sum_votes); }); ``` Like the `loadCount` method, deferred versions of these methods are also available. These additional aggregate operations may be performed on Sutando models that have already been retrieved: ```js const post = await Post.query().first(); await post.loadSum('comments', 'votes'); ``` If you're combining these aggregate methods with a `select` statement, ensure that you call the aggregate methods after the `select` method: ```js const posts = await Post.query().select(['title', 'body']) .withExists('comments') .get(); ``` ## Eager Loading When accessing Sutando relationships as properties, the related models are "lazy loaded". This means the relationship data is not actually loaded until you first access the property. However, Sutando can "eager load" relationships at the time you query the parent model. Eager loading alleviates the `N + 1` query problem. To illustrate the `N + 1` query problem, consider a `Book` model that "belongs to" to an `Author` model: ```js const { Model } = require('sutando'); class Book extends Model { relationAuthor() { return this.belongsTo(Author); } } ``` Now, let's retrieve all books and their authors: ```js const { Book } = require('./models'); const books = await Book.query().all(); books.map(async book => { const author = await book.getRelated('author'); console.log(author.name); }); ``` This loop will execute one query to retrieve all of the books within the database table, then another query for each book in order to retrieve the book's author. So, if we have 25 books, the code above would run 26 queries: one for the original book, and 25 additional queries to retrieve the author of each book. Thankfully, we can use eager loading to reduce this operation to just two queries. When building a query, you may specify which relationships should be eager loaded using the `with` method: ```js const books = await Book.query().with('author').get(); books.map(book => { console.log(book.author.name); }); ``` For this operation, only two queries will be executed - one query to retrieve all of the books and one query to retrieve all of the authors for all of the books: ```SQL select * from books select * from authors where id in (1, 2, 3, 4, 5, ...) ``` #### Eager Loading Multiple Relationships Sometimes you may need to eager load several different relationships. To do so, just pass an array of relationships to the `with` method: ```js const books = await Book.query().with(['author', 'publisher']).get(); ``` #### Nested Eager Loading To eager load a relationship's relationships, you may use "dot" syntax. For example, let's eager load all of the book's authors and all of the author's personal contacts: ```js const books = await Book.query().with('author.contacts').get(); ``` #### Eager Loading Specific Columns You may not always need every column from the relationships you are retrieving. For this reason, Sutando allows you to specify which columns of the relationship you would like to retrieve: ```js const books = await Book.query().with('author:id,name,book_id').get(); ``` ### Constraining Eager Loads Sometimes you may wish to eager load a relationship but also specify additional query conditions for the eager loading query. You can accomplish this by passing an array of relationships to the `with` method where the object key is a relationship name and the object value is a closure that adds additional constraints to the eager loading query: ```js const users = await User.query().with({ posts: query => query.where('title', 'like', '%code%') }).get(); // or const users = await User.query().with('posts', query => { query.where('title', 'like', '%code%'); }).get(); ``` In this example, Sutando will only eager load posts where the post's `title` column contains the word code. You may call other query builder methods to further customize the eager loading operation: ```js const users = await User.query().with({ posts: query => query.orderBy('created_at', 'desc') }).get(); ``` ### Lazy Eager Loading Sometimes you may need to eager load a relationship after the parent model has already been retrieved. For example, this may be useful if you need to dynamically decide whether to load related models: ```js const { Book } = require('./models'); const books = await Book.query().all(); if (someCondition) { await books.load('author', 'publisher'); } ``` If you need to set additional query constraints on the eager loading query, you may pass an object keyed by the relationships you wish to load. The object values should be closure instances which receive the query instance: ```js await author.load({ books: query => query.orderBy('published_date', 'asc') }); ``` ## Inserting & Updating Related Models ### The `save` Method Sutando provides convenient methods for adding new models to relationships. For example, perhaps you need to add a new comment to a post. Instead of manually setting the `post_id` attribute on the `Comment` model you may insert the comment using the relationship's `save` method: ```js const { Post, Comment } = require('./models'); const comment = new Comment({ message: 'A new comment.' }); const post = await Post.query().find(1); await post.related('comments').save(comment); ``` Note that we did not access the `comments` relationship as a dynamic property. Instead, we called the `related('comments')` method to obtain an instance of the relationship. The `save` method will automatically add the appropriate `post_id` value to the new `Comment` model. If you need to save multiple related models, you may use the `saveMany` method: ```js await post.related('comments').saveMany([ new Comment({ message: 'A new comment.' }), new Comment({ message: 'Another new comment.' }), ]); ``` The `save` and `saveMany` methods will persist the given model instances, but will not add the newly persisted models to any in-memory relationships that are already loaded onto the parent model. If you plan on accessing the relationship after using the `save` or `saveMany` methods, you may wish to use the `refresh` method to reload the model and its relationships: ```js await post.related('comments').save(comment); await post.refresh(); // All comments, including the newly saved comment... post.comments; ``` #### Recursively Saving Models & Relationships If you would like to save your model and all of its associated relationships, you may use the `push` method. In this example, the `Post` model will be saved as well as its comments and the comment's authors: ```js post.comments.get(0).message = 'Message'; post.comments.get(0).author.name = 'Author Name'; await post.push(); ``` ### The `create` Method In addition to the `save` and `saveMany` methods, you may also use the `create` method, which accepts an object of attributes, creates a model, and inserts it into the database. The difference between `save` and `create` is that `save` accepts a full Sutando model instance while create accepts a plain `object`. The newly created model will be returned by the `create` method: ```js const { Post } = require('./models'); const post = await Post.query().find(1); const comment = await post.related('comments').create({ message: 'A new comment.', }); ``` You may use the `createMany` method to create multiple related models: ```js await post.related('comments').createMany([ { message: 'A new comment.' }, { message: 'Another new comment.' }, ]); ``` You may also use the `findOrNew`, `firstOrNew`, `firstOrCreate`, and `updateOrCreate` methods to create and update models on relationships. ### Belongs To Relationships If you would like to assign a child model to a new parent model, you may use the `associate` method. In this example, the `User` model defines a `belongsTo` relationship to the `Account` model. This `associate` method will set the foreign key on the child model: ```js const { Account } = require('./models'); const account = await Account.query().find(10); user.related('account').associate(account); await user.save(); ``` To remove a parent model from a child model, you may use the `dissociate` method. This method will set the relationship's foreign key to null: ```js user.related('account').dissociate(); await user.save(); ``` ### Many To Many Relationships #### Attaching / Detaching Sutando also provides methods to make working with many-to-many relationships more convenient. For example, let's imagine a user can have many roles and a role can have many users. You may use the attach method to attach a role to a user by inserting a record in the relationship's intermediate table: ```js const { User } = require('./models'); const user = await User.query().find(1); await user.related('roles').attach(roleId); ``` When attaching a relationship to a model, you may also pass an array of additional data to be inserted into the intermediate table: ```js await user.related('roles').attach(roleId, { expires: expires, }); ``` Sometimes it may be necessary to remove a role from a user. To remove a many-to-many relationship record, use the `detach` method. The `detach` method will delete the appropriate record out of the intermediate table; however, both models will remain in the database: ```js // Detach a single role from the user... await user.related('roles').detach(roleId); // Detach all roles from the user... await user.related('roles').detach(); ``` For convenience, `attach` and `detach` also accept arrays of IDs as input: ```js const user = await User.query().find(1); await user.related('roles').detach([1, 2, 3]); await user.related('roles').attach([1, 2]); ``` #### Syncing Associations You may also use the `sync` method to construct many-to-many associations. The `sync` method accepts an array of IDs to place on the intermediate table. Any IDs that are not in the given array will be removed from the intermediate table. So, after this operation is complete, only the IDs in the given array will exist in the intermediate table: ```js await user.related('roles').sync([1, 2, 3]); ``` You may also pass additional intermediate table values with the IDs: ```js await user.related('roles').sync({ 1: { expires: true }, 2: {}, 3: {} }); ``` If you would like to insert the same intermediate table values with each of the synced model IDs, you may use the `syncWithPivotValues` method: ```js await user.related('roles').syncWithPivotValues([1, 2, 3], { active: true }); ``` If you do not want to detach existing IDs that are missing from the given array, you may use the `syncWithoutDetaching` method: ```js await user.related('roles').syncWithoutDetaching([1, 2, 3]); ``` #### Updating A Record On The Intermediate Table If you need to update an existing row in your relationship's intermediate table, you may use the `updateExistingPivot` method. This method accepts the intermediate record foreign key and an object of attributes to update: ```js await user.related('roles').updateExistingPivot(roleId, { active: false, }); ``` --- --- url: /guide/collections.md --- # Collections All Sutando methods that return more than one model result will return instances of the `Collection` class, including results retrieved via the `get` method or accessed via a relationship. The Sutando collection object extends [collect.js](https://collect.js.org/) collection, so it naturally inherits dozens of methods used to fluently work with the underlying array of Sutando models. Be sure to review the Laravel collection documentation to learn all about these helpful methods! All collections also serve as iterators, allowing you to loop over them as if they were arrays: ```js const { User } = require('./models'); const users = await User.query().where('active', 1).get(); users.map(user => { console.log(user.name); }); for (let user of users) { console.log(user.name); } ``` However, as previously mentioned, collections are much more powerful than arrays and expose a variety of map / reduce operations that may be chained using an intuitive interface. For example, we may remove all inactive models and then gather the first name for each remaining user: ```js const names = (await User.query().all()).reject(user => { return user.active === false; }).map(user => { return user.name; }); ``` ## Available Methods All Sutando collections extend the base `collect.js` object; therefore, they inherit all of the powerful methods provided by the base collection class. In addition, the `Collection` class provides a superset of methods to aid with managing your model collections. Most methods return `Collection` instances; however, some methods, like `modelKeys`, return an `collect.js` instance. * [contains](#contains-key-operator-null-value-null) * [diff](#diff-items) * [except](#except-keys) * [find](#find-key) * [fresh](#fresh-with) * [intersect](#intersect-items) * [load](#load-relations) * [loadCount / loadMax / loadMin / loadSum / loadAvg](#loadcount-loadmax-loadmin-loadsum-loadavg) * [modelKeys](#modelkeys) * [makeVisible](#makevisible-attributes) * [makeHidden](#makehidden-attributes) * [only](#only-keys) * [toQuery](#toquery) * [unique](#unique-key-null-strict-false) * [toData](#todata) * [toJson](#tojson) #### contains(key, operator = null, value = null) The `contains` method may be used to determine if a given model instance is contained by the collection. This method accepts a primary key or a model instance: ```js users.contains(1); const user = await User.query().find(1); users.contains(user); ``` #### diff(items) The `diff` method returns all of the models that are not present in the given collection: ```js const otherUsers = await User.query().whereIn('id', [1, 2, 3]).get() const diffUsers = users.diff(otherUsers); ``` #### except(keys) The `except` method returns all of the models that do not have the given primary keys: ```js const exceptUsers = users.except([1, 2, 3]); ``` #### find(key) The `find` method returns the model that has a primary key matching the given key. If `key` is a model instance, `find` will attempt to return a model matching the primary key. If key is an array of keys, `find` will return all models which have a primary key in the given array: ```js const users = await User.query().all(); const user = users.find(1); ``` #### fresh(with = \[]) The `fresh` method retrieves a fresh instance of each model in the collection from the database. In addition, any specified relationships will be eager loaded: ```js const newUsers = await users.fresh(); const newUsers = await users.fresh('comments'); ``` #### intersect(items) The `intersect` method returns all of the models that are also present in the given collection: ```js const otherUsers = await User.query().whereIn('id', [1, 2, 3]).get(); const newUsers = users.intersect(otherUsers); ``` #### load(relations) The `load` method eager loads the given relationships for all models in the collection: ```js await users.load(['comments', 'posts']); await users.load('comments.author'); ``` #### loadCount / loadMax / loadMin / loadSum / loadAvg ```js await users.loadCount(['comments', 'posts']); await users.loadMax('posts', 'vote'); await users.loadMin('posts', 'vote'); await users.loadSum('posts', 'vote'); await users.loadAvg('posts', 'vote'); ``` #### modelKeys() The `modelKeys` method returns the primary keys for all models in the collection: ```js users.modelKeys(); // [1, 2, 3, 4, 5] ``` #### makeVisible(attributes) The `makeVisible` method makes attributes visible that are typically "hidden" on each model in the collection: ```js const newUsers = users.makeVisible(['address', 'phone_number']); ``` #### makeHidden(attributes) The `makeHidden` method hides attributes that are typically "visible" on each model in the collection: ```js const newUsers = users.makeHidden(['address', 'phone_number']); ``` #### only(keys) The `only` method returns all of the models that have the given primary keys: ```js const newUsers = users.only([1, 2, 3]); ``` #### toQuery() The `toQuery` method returns an query builder instance containing a whereIn constraint on the collection model's primary keys: ```js const { User } = require('./models'); const users = await User.query().where('status', 'VIP').get(); await users.toQuery().update([ 'status' => 'Administrator', ]); ``` #### unique(key = null, strict = false) The `unique` method returns all of the unique models in the collection. Any models of the same type with the same primary key as another model in the collection are removed: ```js const newUsers = users.unique(); ``` #### toData() ```js const users = await User.query().all(); return users.toData(); ``` #### toJson() ```js const users = await User.query().all(); return users.toJson(); ``` --- --- url: /guide/mutators.md --- # Mutators & Casting Accessors, mutators allow you to transform Sutando attribute values when you retrieve or set them on model instances. ## Accessors & Mutators ### Defining An Accessor To define an accessor, create a camelCase named `attribute{Attribute}` method in the model to represent the accessible attribute. This method name corresponds to the representation of the real underlying model attribute/database field. In this example, we'll define an accessor for the `first_name` attribute. The accessor will automatically be called by Sutando when attempting to retrieve the value of the `first_name` attribute: ```js const { Model, Attribute } = require('sutando'); class User extends Model { attributeFirstName() { return Attribute.make({ get: value => value.toUpperCase() }) } } ``` All accessor methods return an `Attribute` instance that defines how to access the attribute and how to change the attribute. In this example we only define how to access the property. To do this, we provide the `get` parameter to the `Attribute` class constructor. As you can see, the original value of the column is passed to the accessor, allowing you to manipulate and return the value. To access the value of the accessor, you may simply access the `first_name` attribute on a model instance: ```js const user = await User.query().find(1); const firstName = user.first_name; ``` :::tip If you would like these computed values to be added to the Object / JSON representations of your model, [you will need to append them](serialization.html#appending-values-to-json). ::: Sometimes your accessor may need to transform multiple model attributes into a single "value object". To do so, your get closure may accept a second argument of `attributes`, which will be automatically supplied to the closure and will contain a object of all of the model's current attributes: ```js attributeFullName() { return Attribute.make({ get: (value, attributes) => `${attributes.first_name} ${attributes.last_name}` }) } ``` ### Defining A Mutator To define a mutator, you may provide the `set` argument when defining your attribute. Let's define a mutator for the `first_name` attribute. This mutator will be automatically called when we attempt to set the value of the `first_name` attribute on the model: ```js const { Model, Attribute } = require('sutando'); class User extends Model { attributeFirstName() { return Attribute.make({ get: value => value.toUpperCase(), set: value => value.toLocalLowerCase() }) } } ``` The mutator will receive the value that is being set on the attribute, allowing you to manipulate the value and set the manipulated value on the Sutando model's internal `attributes` property. To use our mutator, we only need to set the `first_name` attribute on an Sutando model: ```js const user = User.query().find(1); user.first_name = 'Sally'; ``` In this example, the `set` callback will be called with the value `Sally`. The mutator will then apply the `toLocalLowerCase` function to the name and set its resulting value in the model's internal `attributes`. #### Mutating Multiple Attributes Sometimes your mutator may need to set multiple attributes on the underlying model. To do so, you may return a object from the `set` closure. Each key in the object should correspond with an underlying attribute / database column associated with the model: ```js attributeFullName() { return Attribute.make({ get: (value, attributes) => `${attributes.first_name} ${attributes.last_name}`, set: (value) => ({ first_name: value.split(' ')[0], last_name: value.split(' ')[1], }), }); } ``` ## Attribute Casting Attribute casting provides functionality similar to accessors and mutators without requiring you to define any additional methods on your model. Instead, your model's `casts` property provides a convenient method of converting attributes to common data types. The `casts` property should be a obejct where the key is the name of the attribute being cast and the value is the type you wish to cast the column to. The supported cast types are: * `integer` `int` * `float` `double` * `string` * `boolean` `bool` * `collection` * `date` * `datetime` * `json` `object` To demonstrate attribute casting, let's cast the `is_admin` attribute, which is stored in our database as an integer (`0` or `1`) to a boolean value: ```js const { Model } = require('sutando'); class User extends Model { // The attributes that should be cast. casts = { is_admin: 'boolean', }; } ``` After defining the cast, the `is_admin` attribute will always be cast to a boolean when you access it, even if the underlying value is stored in the database as an integer: ```js const user = await User.query().find(1); if (user.is_admin) { // ... } ``` :::tip You should never define a cast (or an attribute) that has the same name as a relationship or assign a cast to the model's primary key. ::: ### JSON Casting The json cast is particularly useful when working with columns that are stored as serialized `JSON`. For example, if your database has a `JSON` or `TEXT` field type that contains serialized JSON, adding the json cast to that attribute will automatically deserialize the attribute when you access it on your model: ```js const { Model } = require('sutando'); class User extends Model { // The attributes that should be cast. casts = { options: 'json', }; } ``` Once the cast is defined, you may access the `options` attribute and it will automatically be deserialized from `JSON` into a object. When you set the value of the `options` attribute, the given object will automatically be serialized back into JSON for storage: ```js const { Model } = require('sutando'); const user = await User.query().find(1); const options = user.options; options.key = value; user.options = options; await user.save(); ``` :::tip Directly modifying the attributes itself cannot update the model data, so the following usage is incorrect: ```js const user = await User.query().find(1); user.options.key = value; ``` ::: ### Date Casting By default, Sutando will cast the `created_at` and `updated_at` columns to instances of `Date`. You may cast additional date attributes by defining additional date casts within your model's `casts` property. Typically, dates should be cast using the `datetime` cast types. When defining a `date` or `datetime` cast, you may also specify the date's format. This format will be used when the [model is serialized to a object or JSON](serialization): ```js casts = { created_at: 'datetime:YYYY-MM-DD', }; ``` You may customize the default serialization format for all of your model's dates by defining a `serializeDate` method on your model. This method does not affect how your dates are formatted for storage in the database: ```js const dayjs = require('dayjs'); class User extends Model { serializeDate(date) { return dayjs(date).format('YYYY-MM-DD'); } } ``` To specify the format that should be used when actually storing a model's dates within your database, you should define a `dateFormat` property on your model: ```js class User extends Model { dateFormat = 'X' } ``` List of all available formats | Format | Output | Description | | ------ | ---------------- | ------------------------------------- | | `YY` | 18 | Two-digit year | | `YYYY` | 2018 | Four-digit year | | `M` | 1-12 | The month, beginning at 1 | | `MM` | 01-12 | The month, 2-digits | | `MMM` | Jan-Dec | The abbreviated month name | | `MMMM` | January-December | The full month name | | `D` | 1-31 | The day of the month | | `DD` | 01-31 | The day of the month, 2-digits | | `d` | 0-6 | The day of the week, with Sunday as 0 | | `dd` | Su-Sa | The min name of the day of the week | | `ddd` | Sun-Sat | The short name of the day of the week | | `dddd` | Sunday-Saturday | The name of the day of the week | | `H` | 0-23 | The hour | | `HH` | 00-23 | The hour, 2-digits | | `h` | 1-12 | The hour, 12-hour clock | | `hh` | 01-12 | The hour, 12-hour clock, 2-digits | | `m` | 0-59 | The minute | | `mm` | 00-59 | The minute, 2-digits | | `s` | 0-59 | The second | | `ss` | 00-59 | The second, 2-digits | | `SSS` | 000-999 | The millisecond, 3-digits | | `Z` | +05:00 | The offset from UTC, ±HH:mm | | `ZZ` | +0500 | The offset from UTC, ±HHmm | | `A` | AM PM | | | `a` | am pm | | | `Q` | 1-4 | Quarter | | `Do` | 1st 2nd ... 31st | Day of Month with ordinal | | `k` | 1-24 | The hour, beginning at 1 | | `kk` | 01-24 | The hour, 2-digits, beginning at 1 | | `X` | 1360013296 | Unix Timestamp in second | | `x` | 1360013296123 | Unix Timestamp in millisecond | #### Date Casting, Serialization, & Timezones By default, the `date` and `datetime` casts will serialize dates to a UTC ISO-8601 date string (2012-12-12T12:25:36.000000Z), regardless of the timezone specified in your application's timezone configuration option. If a custom format is applied to the `date` or `datetime` cast, such as `datetime:YYYYY-MM-DD HH:mm:ss`, the UTC timezone will be used during date serialization. ### Custom Casts Sutando has a variety of built-in, helpful cast types; however, you may occasionally need to define your own cast types. All custom cast classes extend the `CastsAttributes`. Classes that implement this interface must define a `get` and `set` method. The `get` method is responsible for transforming a raw value from the database into a cast value, while the `set` method should transform a cast value into a raw value that can be stored in the database. As an example, we will re-implement the built-in `json` cast type as a custom cast type: ```js // casts/json.js const { Model, CastsAttributes } = require('sutando'); class Json extends CastsAttributes { // Cast the given value. static get(model, key, value, attributes) { try { return JSON.parse(value); } catch (e) { return null; } } // Prepare the given value for storage. static set(model, key, value, attributes) { return JSON.stringify(value); } } ``` Once you have defined a custom cast type, you may attach it to a model attribute using its class: ```js const Json = require('./casts/json'); class User extends Model { // The attributes that should be cast. casts = { options: Json, }; } ``` --- --- url: /guide/serialization.md --- # Serialization When building APIs you will often need to convert your models and relationships to object or JSON. Sutando includes convenient methods for making these conversions, as well as controlling which attributes are included in the serialized representation of your models. ## Serializing Models & Collections ### Serializing To Data Object To convert a model and its loaded relationships to an array, you should use the `toData` method. This method is recursive, so all attributes and all relations (including the relations of relations) will be converted to object: ```js const user = await User.query().with('roles').first(); return user.toData(); ``` The `attributesToData` method may be used to convert a model's attributes to an object but not its relationships: ```js const user = await User.query().first(); return user.attributesToData(); ``` You may also convert entire collections of models to data object by calling the `toData` method on the collection instance: ```js const users = await User.query().all(); return users.toData(); ``` ### Serializing To JSON To convert a model to JSON, you should use the `toJson` method. Like `toData`, the `toJson` method is recursive, so all attributes and relations will be converted to JSON. You may also specify any JSON encoding options that are supported by Javascript: ```js const user = await User.query().find(1); return user.toJson(); return user.toJson(null, 2); ``` Alternatively, you may cast a model or collection to a string, which will automatically call the `toJson` method on the model or collection: ```js const user = await User.query().find(1); return String(user); return JSON.stringify(user); ``` Since models and collections are converted to JSON when cast to a string, you can return Sutando objects directly from your `express`/`Koa` application. Sutando will automatically serialize your models and collections to JSON when they are returned from routes or controllers: ```js const app = require('express')(); app.get('/', async (req, res) => { const user = await User.query().find(1); res.send(user); }); ``` #### Relationships When an Sutando model is converted to JSON, its loaded relationships will automatically be included as attributes on the JSON object. ## Hiding Attributes From JSON Sometimes you may wish to limit the attributes, such as passwords, that are included in your model's data object or JSON representation. To do so, add a `hidden` property to your model. In attributes that are listed in the `hidden` property's array will not be included in the serialized representation of your model: ```js const { Model } = requre('sutando'); class User extends Model { hidden = ['password']; } ``` Alternatively, you may use the `visible` property to define an "allow list" of attributes that should be included in your model's data objectt and JSON representation. All attributes that are not present in the `visible` array will be hidden when the model is converted to an data objectt or JSON: ```js const { Model } = requre('sutando'); class User extends Model { visible = ['first_name', 'last_name']; } ``` #### Temporarily Modifying Attribute Visibility If you would like to make some typically hidden attributes visible on a given model instance, you may use the `makeVisible` method. The `makeVisible` method returns the model instance: ```js user.makeVisible('attribute').toData(); user.makeVisible(['attribute', 'another_attribute']).toData(); ``` Likewise, if you would like to hide some attributes that are typically visible, you may use the `makeHidden` method. ```js user.makeHidden('attribute').toData(); user.makeHidden(['attribute', 'another_attribute']).toData(); ``` If you wish to temporarily override all of the visible or hidden attributes, you may use the `setVisible` and `setHidden` methods respectively: ```js user.setVisible(['id', 'name']).toData(); user.setHidden(['email', 'password', 'remember_token']).toData(); ``` ## Appending Values To JSON Occasionally, when converting models to data object or JSON, you may wish to add attributes that do not have a corresponding column in your database. To do so, first define an accessor for the value: ```js const { Model, Attribute } = requre('sutando'); class User extends Model { attributeIsAdmin() { return Attribute.make({ get: (value, attributes) => (attributes.admin === 'yes') }); } } ``` After creating the accessor, add the attribute name to the appends property of your model. Note that attribute names are typically referenced using their "snake case" serialized representation, even though the accessor's method is defined using "camel case": ```js const { Model } = requre('sutando'); class User extends Model { appends = ['is_admin']; } ``` Once the attribute has been added to the appends list, it will be included in both the model's data object and JSON representations. Attributes in the appends array will also respect the `visible` and `hidden` settings configured on the model. #### Appending At Run Time At runtime, you may instruct a model instance to append additional attributes using the `append` method. Or, you may use the `setAppends` method to override the entire array of appended properties for a given model instance: ```js user.append('is_admin').toData(); user.setAppends(['is_admin']).toData(); ``` --- --- url: /guide/transactions.md --- # Transactions You may use the `transaction` method provided by the Sutando connection to run a set of operations within a database transaction. If an exception is thrown within the transaction closure, the transaction will automatically be rolled back and the exception is re-thrown. If the closure executes successfully, the transaction will automatically be committed. You don't need to worry about manually rolling back or committing while using the transaction method: ```js const { sutando } = require('sutando'); const db = sutando.connection(); await db.transaction(async (trx) => { await User.query().transacting(trx).create(/* ... */); await db.table('users').transacting(trx).insert(/* ... */); const user = new User; user.name = 'Sally'; await user.save({ client: trx, }); }); ``` ### Manually Using Transactions If you would like to begin a transaction manually and have complete control over rollbacks and commits, you may use the `beginTransaction` method provided by the `sutando`: ```js const { sutando } = require('sutando'); const db = sutando.connection(); const trx = await db.beginTransaction(); ``` You can rollback the transaction via the `rollback` method: ```js await trx.rollback(); ``` Lastly, you can commit a transaction via the `commit` method: ```js await trx.commit(); ``` Here is a complete example: ```js const { sutando } = require('sutando'); const db = sutando.connection(); const trx = await db.beginTransaction(); try { const user = new User; user.name = 'Sally'; await user.save({ client: trx, }); await trx.commit(); } catch (e) { await trx.rollback(); console.log(e.stack); } ``` --- --- url: /guide/migrations.md --- # Database Migration Migration is database version control, which helps developers complete table structure changes and data migration in daily work. ## Quick start ### Generate migration First, generate a configuration file. ```bash $ npx sutando init ``` If you want to install Sutando's command line tool globally, you can use the following command: ```bash $ npm install -g sutando $ sutando init ``` This will generate a `sutando.config.js` file in the project directory, which is used to set database connection and other information. ```js // Update with your config settings. module.exports = { client: 'mysql2', connection: { host: 'localhost', database: 'database', user: 'root', password: 'password' }, // You can add multiple connections, just specify the connection name. connections: { pgsql: { client: 'pg', connection: { host: 'localhost', database: 'another_database', user: 'root', password: 'password' } } }, migrations: { table: 'migrations', path: 'migrations' }, models: { path: 'models', } }; ``` You can then use the `migrate:make` command to generate database migrations. New migration files are placed in your `migrations` directory by default. Each migration file name contains a timestamp to allow Sutando to determine the order of migrations: ```bash $ npx sutando migrate:make create_flights_table ``` Sutando will use the name of the migration file to guess the table name and whether the migration will create a new table. If Sutando is able to determine the name of the table from the name of the migration file, it will prepopulate the specified table in the generated migration file, or you can manually specify the table name directly in the migration file. If you want to specify a custom path for the generated migrations, you can use the `--path` option when executing the `migrate:make` command. The given path should be relative to the path where the command is executed. ### Migration structure The migration class contains two methods: `up` and `down`. The `up` method is used to add a new table, column or index to the database, while the `down` method is used to undo the operation performed by the `up` method. . In both methods, you can use Schema builders to expressively create and modify tables. To learn about all the methods available on the Schema builder, check out its documentation. For example, the following migration creates a `flights` table: ```js const { Migration } = require('sutando'); module.exports = class extends Migration { /** * Run the migrations. */ async up(schema) { await schema.createTable('flights', (table) => { table.increments('id'); table.string('name'); table.string('airline'); table.timestamps(); }); } /** * Reverse the migrations. */ async down(schema) { await schema.dropTableIfExists('flights'); } }; ``` ### Migration Connection If your migration interacts with a database connection other than the default database connection of the application, you should set the `connection` property of the migration to specify the database connection to use. ```js module.exports = class extends Migration { connection = 'pgsql'; /** * Run the migrations. */ async up(schema) { // ... } } ``` ### Execute migration Execute the `migrate:run` command to run all unexecuted migrations: ```bash $ npx sutando migrate:run ``` If you want to see which migrations have been performed so far, you can use the `migrate:status` command: ```bash $ npx sutando migrate:status ``` ### Rollback migration If you want to roll back the last migration operation, you can use `migrate:rollback`. This command will roll back the last "batch" of migrations, which may include multiple migration files: ```bash $ npx sutando migrate:rollback ``` You can roll back a specified number of migrations by adding the `step` parameter to the `rollback` command. For example, the following command will roll back the last five migrations: ```bash $ npx sutando migrate:rollback --step=5 ``` ## Tables ### Create tables Next we will create a new data table using the `createTable` method. `createTable` accepts two parameters: the first parameter is the table name, and the second parameter is a callback function: ```js const { Migration } = require('sutando'); module.exports = class extends Migration { /** * Run the migrations. */ async up(schema) { await schema.createTable('users', (table) => { table.increments('id'); table.string('name'); table.string('email'); table.timestamps(); }); } /** * Reverse the migrations. */ async down(schema) { await schema.dropTableIfExists('users'); } }; ``` When you create a table, you can use the Database Structure Builder's columns method to define the table's columns. #### Check if table/column exists You can check whether a table or column exists using the `hasTable` and `hasColumn` methods: ```js if (await schema.hasTable('users')) { // "users" table exists... } if (await schema.hasColumn('users', 'email')) { // The "users" table exists and has the "email" column... } ``` Additionally, a number of other properties and methods are available to define other places where the table is created. When using MySQL, you can use the engine method to specify the storage engine of the table: ```js await schema.createTable('users', (table) => { table.engine('InnoDB'); // ... }); ``` The `charset` and `collate` methods can be used to specify the character set and collation for tables created when using MySQL: ```js await schema.createTable('users', (table) => { table.charset('utf8mb4'); table.collate('utf8mb4_unicode_ci'); // ... }); ``` If you want to add a "comment" to a database table, you can call the `comment` method on the table instance. Currently only MySQL and Postgres support table comments: ```js await schema.createTable('calculations', (table) => { table.comment('Business calculations'); // ... }); ``` ### Update tables Schema's `table` method can be used to update an existing table. Like the `createTable` method, the `table` method accepts two parameters: the name of the table and a callback function that can be used to add columns or indexes to the table: ```js await schema.table('users', (table) => { table.integer('votes'); }); ``` ### Rename/delete tables To rename an existing table, use the `renameTable` method: ```js await schema.renameTable(from, to); ``` To drop an existing table, you can use the `dropTable` or `dropTableIfExists` method: ```js await schema.dropTable('users'); await schema.dropTableIfExists('users'); ``` ## Columns ### Create Columns Schema's `table` method can be used to update a table. Like the `createTable` method, the `table` method accepts two parameters: the table name and a callback function that can be used to add columns to the table: ```js await schema.table('users', (table) => { table.integer('votes'); }); ``` ### Available column types Schema builders provide a variety of methods for creating columns of corresponding types in tables. All available methods are listed below: #### bigIncrements The `bigIncrements` method is used to create an auto-incrementing `UNSIGNED BIGINT` type (primary key) column in the data table: ```js table.bigIncrements('id'); ``` #### bigInteger The `bigInteger` method is used to create a `BIGINT` type column in the data table: ```js table.bigInteger('votes'); ``` #### binary The `binary` method is used to create a `BLOB` type column in the data table: ```js table.binary('photo'); ``` #### boolean The `boolean` method is used to create a `BOOLEAN` type column in the data table: ```js table.boolean('confirmed'); ``` #### datetime The `datetime` method is used to create a `DATETIME` type column in the data table. The optional parameter is the total number of digits of precision: ```js table.datetime('created_at', { precision: 6 }); ``` #### date The `date` method is used to create a `DATE` type column in the data table: ```js table.date('date'); ``` #### decimal The `decimal` method is used to create a `DECIMAL` type column in the data table. The optional parameters are the total number of valid words and the total number of decimal places: ```js table.decimal('amount'); table.decimal('amount', 8, 2); ``` #### double The `double` method is used to create a `DOUBLE` type column in the data table. The optional parameters are the total number of valid words and the total number of decimal places: ```js table.double('amount', 8, 2); ``` #### enum The `enum` method is used to create a column of type `ENUM` in the data table: ```js table.enum('difficulty', ['easy', 'hard']); ``` #### float The `float` method is used to create a `FLOAT` type column in the data table. The optional parameters are the total number of valid words and the total number of decimal places: ```js table.float('amount', 8, 2); ``` #### geometry The `geometry` method is equivalent to `GEOMETRY`: ```js table.geometry('positions'); ``` #### increments The `increments` method creates an auto-incrementing column equivalent to `UNSIGNED INTEGER` as the primary key: ```js table.increments('id'); ``` #### integer The `integer` method is used to create a column of type `INTEGER` in the data table: ```js table.integer('votes'); ``` #### json The `json` method is used to create a `JSON` type column in the data table: ```js table.json('options'); ``` #### jsonb The `jsonb` method is used to create a `JSONB` type column in the data table: ```js table.jsonb('options'); ``` #### point The `point` method is used to create a `POINT` type column in the data table: ```js table.point('position'); ``` #### smallint The `smallint` method is used to create a `SMALLINT` type column in the data table: ```js table.smallint('votes'); ``` #### string The `string` method creates a `VARCHAR` equivalent column of a given length, equivalent to a VARCHAR of the specified length: ```js table.string('name', 100); ``` #### text The `text` method is used to create a `TEXT` type column in the data table: ```js table.text('description'); ``` #### time The `time` method creates a `TIME` equivalent column with optional precision (total number of digits): ```js table.time('sunrise', { precision: 6 }); ``` #### timestamp The `timestamp` method creates a column of type `TIMESTAMP` with an optional precision (total number of digits): ```js table.timestamp('sunrise', { precision: 6 }); ``` #### timestamps The `timestamps` method creates `created_at` and `updated_at` `TIMESTAMP` equivalent columns: ```js table.timestamps(); ``` #### tinyint The `tinyint` method is used to create a `TINYINT` type column in the data table: ```js table.tinyint('votes'); ``` #### uuid The `uuid` method is used to create a `UUID` type column in the data table: ```js table.uuid('id'); ``` ### Columns modifiers In addition to the column types listed above, there are several "modifiers" that can be used when adding columns to a database table. For example, if you want to set a column to be "nullable", you can use the `nullable` method: ```js await schema.table('users', (table) => { table.string('email').nullable(); }) ``` The following table shows all available column modifiers. This list does not include index modifiers: | Modifier | Description | | ---- | ---- | | `.after('column')` | Place the column "after" other columns (MySQL) | | `.charset('utf8mb4')` | Specify the character set for this column (MySQL) | | `.collate('utf8_unicode_ci')` | Specify the collation for this column (MySQL/PostgreSQL/SQL Server) | | `.comment('my comment')` | Add a comment to the column (MySQL/PostgreSQL) | | `.defaultTo(value)` | Specify a "default value" for the column | | `.first()` | Place the column "first" in the table (MySQL) | | `.nullable()` | Allows NULL values to be inserted into this column | | `.unsigned()` | Set a column of type INTEGER to UNSIGNED (MySQL) | ### Modify columns The `alter` method can modify an existing column type to a new type or modify attributes. For example, you might want to increase the length of the `string` column by using the `alter` method to increase the length of the `name` column from 25 to 50. So, we can simply update the column properties and call the `alter` method: ```js await schema.table('users', (table) => { table.string('name', 50).alter(); }); ``` When modifying a column, you must explicitly include all modifiers that you want to retain on the column definition - any missing attributes will be discarded. For example, in order to preserve the unsigned, default, and comment attributes, you must explicitly modify each attribute when modifying the column. ```js await schema.table('users', (table) => { table.integer('votes').unsigned().defaultTo(1).comment('my comment').alter(); }); ``` #### Rename columns To rename a column, you can use the `renameColumn` method provided by the schema builder: ```js await schema.table('users', (table) => { table.renameColumn('from', 'to'); }); ``` ### Delete columns To drop a column, you can use the `dropColumn` method. ```js await schema.table('users', (table) => { table.dropColumn('votes'); }); ``` If you want to delete multiple columns, you can use the `dropColumns` method. ```js await schema.table('users', (table) => { table.dropColumns('votes', 'avatar', 'location'); }); ``` ## Indexes ### Create indexes The structure builder supports several types of indexes. The following example creates a new `email` column with a unique value. We can chain the `unique` method to the column definition to create an index: ```js await schema.table('users', (table) => { table.string('email').unique(); }); ``` Alternatively, you can create the index after defining the columns. To do this, you should call the `unique` method on the structure builder, which should be passed the column name of the unique index: ```js table.unique('email'); ``` You can even pass an array to the index method to create a compound (or synthetic) index: ```js table.index(['account_id', 'created_at']); ``` When creating an index, Sutando will automatically generate a reasonable index name, but you can also pass parameters to customize the index name: ```js table.index(['name', 'last_name'], 'idx_name_last_name'); table.unique('email', { indexName: 'unique_email' }); ``` #### Available index types Below are all available indexing methods: | Command | Description | | ---- | ---- | | `table.primary('id');` | Add primary key | | `table.primary(['id', 'parent_id']);` | Add composite primary key | | `table.unique('email');` | Add unique index | | `table.index('state');` | Add a normal index | ### Delete indexes To delete an index, pass the column array to the `dropIndex` method, which will delete the index name generated based on the table name, column and key type. You can also specify the index name as the second parameter: | Command | Description | | ---- | ---- | | `table.dropPrimary('users', 'users_id_primary');` | Delete the primary key from the "users" table | | `table.dropUnique('users', 'users_email_unique');` | Delete the unique index from the "users" table | | `table.dropIndex('geo', 'geo_state_index');` | Drop the base index from the "geo" table | ### Foreign key constraints Sutando also supports the creation of foreign key constraints for enforcing referential integrity in the database layer. For example, let's define a `user_id` column on the `posts` table that references the `id` column of the `users` table: ```js await schema.createTable('posts', (table) => { table.integer('user_id').unsigned().notNullable(); table.string('title', 30); table.string('content'); table.foreign('user_id').references('id').inTable('users'); }); ``` --- --- url: /guide/hooks.md --- # Hooks Sutando models dispatch several events, allowing you to hook into the following moments in a model's lifecycle: `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`, `restoring`, `restored`, `trashed`, `forceDeleting` and `forceDeleted`. Event names ending with `-ing` are dispatched before any changes to the model are persisted, while events ending with `-ed` are dispatched after the changes to the model are persisted. ## Available hooks | Hook | Description | | ---- | ---- | | `creating`, `created` | When a new model is saved for the first time | | `updating`, `updated` | When an existing model is modified and the `save` method is called | | `saving`, `saved` | When a model is created or updated - even if the model's attributes have not been changed | | `deleting`, `deleted` | When a model is deleted, include soft deletes | | `restoring`, `restored` | When a model is restored | | `trashed` | When a model is soft deleted | | `forceDeleteing`, `forceDeleted` | When a model is hard deleted | :::tip When issuing a mass update or delete query via Sutando, the `saved`, `updated`, `deleting`, and `deleted` model events will not be dispatched for the affected models. This is because the models are never actually retrieved when performing mass updates or deletes. ::: ## Declaring Hooks There are currently two ways to programmatically add hooks: ```js class User extends Model {} User.creating(user => { // }); ``` ```js class User { static booted() { this.creating(user => { // }); this.created(user => { // }); } } ``` ## Hooks and Transactions ```js User.deleted(async (user, { client }) => { const query = user.related('posts'); if (client) { query.transacting(client); } await query.delete(); }); const trx = await sutando.beginTransaction(); await user.delete({ client: trx }); await trx.commit(); ``` --- --- url: /guide/typescript.md --- # TypeScript Support Sutando provides TypeScript support with a pragmatic approach - balancing usability and type safety. We prioritize intuitive and easy-to-use APIs over complete type safety. ## Basic Usage Here are some basic examples: ```typescript import { Model } from 'sutando' // Define a basic model class User extends Model { // Optional: declare model property types declare id: number declare name: string declare email: string } // Using the model const user = new User() user.name = 'John' await user.save() // Query example const users = await User.query() .where('age', '>', 18) .get() // Relationship example class Post extends Model { declare title: string declare content: string declare user_id: number relationUser() { return this.belongsTo(User) } } ``` ## Type Safety Notes While Sutando provides TypeScript support, we don't aim for complete type safety. This means: 1. Some dynamic features may not have complete type inference 2. Certain query builder operations might return `any` type 3. Relationship type inference may be incomplete For example: ```typescript // Dynamic queries may not have accurate type inference const result = await User.query() .select(['name', 'email']) .where('age', '>', 18) .first() // Relationship query type inference might be incomplete const userWithPosts = await User.query() .with('posts') .first() ``` ## Enhancing Type Safety with Generics To address the above type inference limitations, Sutando's query methods support generic types, allowing you to: 1. Extend model type definitions 2. Specify relationship data types 3. Add custom field types For example: ```typescript // Basic query with generics const user = await User.query() .first() // Relationship query with generics const post = await Post.query() .with('user') .first() // Custom query result type interface CustomUserResult extends User { total_posts: number; latest_login: Date; } const result = await User.query() .select(['*']) .selectRaw('COUNT(posts.id) as total_posts') .first() // Complex relationship query types const userWithPosts = await User.query() .with('posts') .first() ``` ## Why This Design? Our design philosophy is: 1. **Prioritize Developer Experience**: We want our API to remain simple and intuitive, rather than being bogged down by complex type definitions 2. **Practicality First**: In certain scenarios, we choose to sacrifice some type safety for more flexible APIs 3. **Progressive Type Support**: You can gradually add more type definitions as needed ## Best Practices Despite our approach, we still recommend: 1. Declaring types for main model properties 2. Adding type annotations to critical business logic code 3. Using type assertions or custom type guards where type safety is needed ```typescript // Declare types for important model properties class Product extends Model { declare id: number declare name: string declare price: number declare stock: number // Use explicit types for custom methods async updateStock(quantity: number): Promise { this.stock += quantity await this.save() } } ``` --- --- url: /guide/browser.md --- # Browser Support Sutando now supports running in browser environments in addition to server-side usage. The browser version supports core functionalities such as Models, Attributes, and Relations, but does not include database-related features like Query Builder. ## Using with Full-Stack Frameworks When working with full-stack frameworks like Next.js or Nuxt.js, you can define your models once and use them in both frontend and backend. This ensures code consistency and eliminates the need for duplicate definitions. However, if you need to use Node.js-specific features in your models (like file system operations), it's recommended to organize your code as follows: 1. Create a base model class containing shared logic for both frontend and backend 2. Create a server-side model class that extends the base model for Node.js-specific features ```javascript // models/base/user.js - Shared base model export class BaseUser extends Model { // Shared properties and methods } // models/server/user.js - Server-only model export class User extends BaseUser { // Node.js specific features } ``` ## Core Features ### make Function The `make` function converts API response data into model instances, allowing you to use all model features including accessors and mutators. ```javascript const { make } = require('sutando'); const user = make(User, data); ``` ### makeCollection Function The `makeCollection` function transforms an array of API response data into a collection of model instances. ```javascript const { makeCollection } = require('sutando'); const users = makeCollection(User, data); ``` ### makePaginator Function The `makePaginator` function converts paginated API response data into a Paginator instance. ```javascript const { makePaginator } = require('sutando'); const pageData = makePaginator(User, data); ``` ## Usage Examples ```javascript // Convert API response to model instance const response = await fetch('/api/users/1'); const data = await response.json(); const user = make(User, data); // Use model accessors and other features console.log(user.full_name); // Assuming a full_name accessor exists // Handle list data const usersResponse = await fetch('/api/users'); const usersData = await usersResponse.json(); const users = makeCollection(User, usersData); // Handle paginated data const pageResponse = await fetch('/api/users?page=1'); const pageData = await response.json(); const paginator = makePaginator(User, pageData); ``` --- --- url: /guide/plugin.md --- # Plugin Plug-ins are independent programs that can add new functions and extend existing functions to Sutando You can load multiple plugins to meet various needs. ## Usage For example, Sutando comes with two plug-ins. `SoftDeletes` allows the model to support soft deletion, and `HasUniqueIds` provides the function of strings as primary keys. You can use the plugin like this: ```js const { Model, compose, SoftDeletes, HasUniqueIds } = require('sutando'); class User extends SoftDeletes(Model) {} class Post extends HasUniqueIds(SoftDeletes(Model)) {} ``` However, we still recommend using the `compose` helper function to use plugins: ```js const { Model, compose, SoftDeletes, HasUniqueIds } = require('sutando'); class User extends compose(Model, SoftDeletes) {} class Post extends compose(Model, SoftDeletes, HasUniqueIds) {} ``` ## Writing a plugin If possible, Sutando plugins should be implemented as class mixins. A `mixin` is just a function that takes a class as argument and returns a subclass. ```js const SomeMixin = (Model) => { return class extends Model { // Your code } } ``` To better understand how to build a Sutando plugin, we can try to write a simple plugin that automatically sets the `slug` based on the title for the article model. It is recommended to create and export it in a separate file to ensure better management of the logic, as follows: ```js // plugins/sutando-slug.js const _ = require('lodash'); const HasSlug = (Model) => { return class extends Model { static booted() { // Execute booted of the parent class Model.booted(); // Set the creating hook this.creating(model => { // If slug is not set, it will be automatically generated based on the title attribute if (model.slug === undefined) { model.slug = _.kebabCase(model.title); } }); } } } module.exports = HasSlug; ``` This example uses [hooks](hooks). After completion, you can use the plug-in like this: ```js const { Model, compose } = require('sutando'); const HasSlug = require('./plugins/sutando-slug'); class Post extends compose( Model, HasSlug ) { // ... } const post = new Post; post.title = 'The First Post Title'; await post.save(); console.log(post.slug); // the-first-post-title ``` So there is a question, what if my database field name is not `slug`, but `slug_name`, or another name? We just need to adjust the plugin so that it accepts a field name parameter: ```js{4,13,14,15} // plugins/sutando-slug.js const _ = require('lodash'); const HasSlug = ({ column }) => (Model) => { return class extends Model { static booted() { // Execute booted of the parent class Model.booted(); // Set the creating hook this.creating(model => { // If slug is not set, it will be automatically generated based on the title attribute if (model[column] === undefined) { model[column] = _.kebabCase(model.title); } }); } } } module.exports = HasSlug; ``` Usage will also change: ```js{6} const { Model, compose } = require('sutando'); const HasSlug = require('./plugins/sutando-slug'); class Post extends compose( Model, HasSlug({ column: 'custom_slug' }) ) { // ... } const post = new Post; post.title = 'The First Post Title'; await post.save(); console.log(post.custom_slug); // the-first-post-title ``` --- --- url: /guide/plugin-list.md --- # Plugin List ## Built-in Plugins * [SoftDeletes](models#soft-deleting) Soft delete, let the model support soft deletion. * [HasUniqueIds](models#uuid-string-keys) Provide the function of strings as primary keys. ## Official Plugins * [@sutando/keeper](https://github.com/sutandojs/keeper) - Lightweight API token authentication plugin ## Third-party Plugins None --- --- url: >- /zh_CN/blog/posts/2026-nodejs-orm-selection-guide-prisma-vs-drizzle-vs-sutando.md --- Node.js 的 ORM 生态在 2026 年已经非常成熟。面对众多选择,开发者常常困惑:到底该选哪个?本文将从实际使用场景出发,对比 Prisma、Drizzle 和 Sutando 三大主流 ORM,帮你做出最佳选择。 ## 三者概览 | 维度 | Prisma | Drizzle | Sutando | |------|--------|---------|---------| | 模式 | Data Mapper | SQL 查询构造器 | Active Record | | 灵感来源 | 自创 DSL | Knex + SQL | Laravel Eloquent | | Schema 定义 | `.prisma` 文件 | TypeScript | TypeScript 类 | | 代码生成 | 需要 `prisma generate` | 不需要 | 不需要 | | 包体积 | ~1.6 MB | ~7.4kb | 小 | | 数据库支持 | MySQL/PG/SQLite/MongoDB/SQLServer | PG/MySQL/SQLite/Turso/Neon | MySQL/PG/SQLite | | 中文文档 | 有社区翻译 | 少 | 官方支持 | ## Prisma:类型安全的王者 **适合谁**:追求最大类型安全、喜欢 schema-first 方式的团队 ### 优势 * 端到端类型安全,查询参数和返回值完全类型化 * Prisma Studio 可视化数据库浏览器 * 支持数据库反向工程(`prisma db pull`) * 社区最大,教程和第三方集成最多 * 支持 MongoDB 和 SQL Server ### 劣势 * 需要学习 `.prisma` DSL * 每次改 schema 都要 `prisma generate` * 包体积较大 * 没有内置模型事件和软删除 ```ts // Prisma 查询示例 const user = await prisma.user.findUnique({ where: { id: 1 }, include: { posts: true } }); ``` ## Drizzle:SQL 优先的极简主义 **适合谁**:热爱 SQL、需要精确控制查询、在 Edge 运行的项目 ### 优势 * 极小的包体积(~7.4kb min+gzip) * 查询直接映射 SQL,性能优秀 * 类型安全的 Raw SQL(`sql` 模板标签) * Serverless 优先设计,完美支持 Turso/Neon * 无代码生成步骤 ### 劣势 * 关联需要手动写 join * 没有模型事件、软删除、查询作用域 * 学习曲线需要 SQL 知识 * 中文资料极少 ```ts // Drizzle 查询示例 const users = await db.select() .from(users) .leftJoin(posts, eq(users.id, posts.userId)) .where(eq(users.id, 1)); ``` ## Sutando:Active Record 的优雅之选 **适合谁**:从 Laravel/Rails 迁移、追求快速开发、需要丰富内置功能的开发者 ### 优势 * API 几乎与 Laravel Eloquent 一致 * 内置软删除、模型事件、查询作用域 * 关联管理最优雅(`with('posts.comments.user')`) * 无需装饰器,无需代码生成 * 官方中文文档支持 ### 劣势 * 类型安全不如 Prisma 全面 * 不支持 MongoDB 和 SQL Server * 社区相对较小 * 没有 GUI 工具 ```ts // Sutando 查询示例 const user = await User.query() .with('posts.comments') .where('active', true) .find(1); ``` ## 按场景推荐 ### 场景 1:企业级 SaaS 后台 **推荐:Prisma** 企业级项目通常需要严格的类型安全、完善的工具链和丰富的社区支持。Prisma 的 schema-first 方式适合多人协作,Prisma Studio 方便非技术人员查看数据。 ### 场景 2:Cloudflare Workers / Edge 项目 **推荐:Drizzle** Edge 环境对包体积有严格限制。Drizzle 的 ~7.4kb 体积和 Serverless 优先设计使其成为 Edge 部署的最佳选择。 ### 场景 3:从 Laravel 迁移到 Node.js **推荐:Sutando** Sutando 的 API 与 Eloquent 几乎一致,迁移成本最低。模型事件、软删除、查询作用域都是内置的,不需要额外配置。 ### 场景 4:快速原型 / MVP **推荐:Sutando** Active Record 模式写 CRUD 最快。Sutando 的方法链语法简洁直观,从想法到可用 API 只需要很少的代码。 ### 场景 5:复杂关联的数据密集型应用 **推荐:Sutando** `with('posts.comments.author.profile')` 一行搞定嵌套预加载,而 Drizzle 需要手写多个 join,Prisma 需要嵌套 include。 ### 场景 6:需要 MongoDB 支持 **推荐:Prisma** 三选一中只有 Prisma 原生支持 MongoDB。 ## 性能对比 | 指标 | Prisma | Drizzle | Sutando | |------|--------|---------|---------| | 冷启动 | 中等 | 极快 | 快 | | 查询性能 | 好 | 最好 | 好 | | 包体积 | ~1.6 MB | ~7.4kb | 小 | | 内存占用 | 中等 | 极低 | 低 | 对于大多数应用,查询性能的差异可以忽略——数据库 IO 才是瓶颈。但在高并发和 Edge 场景下,Drizzle 的优势明显。 ## 中文生态对比 | 维度 | Prisma | Drizzle | Sutando | |------|--------|---------|---------| | 官方中文文档 | 无 | 无 | 有 | | 中文教程 | 社区翻译较多 | 极少 | 官方博客 | | npm 中文说明 | 无 | 无 | 有 | | 中文社区 | 有 | 几乎无 | 建设中 | 对于中文开发者,Sutando 的官方中文支持是一个重要优势。 ## 总结 没有"最好"的 ORM,只有"最适合"的 ORM: * **Prisma**:类型安全 + 工具链 + 大社区 * **Drizzle**:SQL 控制 + 极小体积 + Edge 优先 * **Sutando**:Active Record + 内置功能 + 中文支持 根据你的团队背景、项目需求和技术栈来选择。如果你是 Laravel 开发者,Sutando 几乎是零成本切换。如果你追求极致类型安全,选 Prisma。如果你在 Edge 上构建,选 Drizzle。 试试 Sutando:`npm install sutando`——或查看[中文文档](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /zh_CN/blog/posts/the-best-node.js-orms-to-watch-in-2026.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** 2026 年的 Node.js 生态比以往任何时候都更快、更精简。随着 **边缘运行时**(Cloudflare Workers、Vercel Edge)的全面普及以及 **Bun** 成长为生产级运行时,我们与数据库交互的方式已经发生了根本性的变化。 我们已经走过了"一刀切"ORM 的时代。如今,ORM 的选择不仅仅关乎语法——它是一个影响冷启动、包体积和长期可维护性的决策。 在本指南中,我们将看看 2026 年的主要竞争者,以及为什么一个既新又令人熟悉的面孔 **Sutando ORM** 正在成为高速迭代团队的秘密武器。 *** ## 2026 年的格局:性能 vs 抽象 多年来,开发者被迫做出选择:你想要重型抽象带来的"魔法"(以性能为代价),还是原生 SQL 的"速度"(以生产力为代价)?在 2026 年,这一差距正在缩小。 ### 1. Prisma 7:托管生态系统 Prisma 依然是一个巨头。在最新的 **v7 版本** 中,Prisma 已完全迁移到 **基于 WASM 的引擎**,显著减少了早期版本在 Edge 函数上的冷启动问题。 * **最适合:** 需要严格的 schema-first 方法和内置 GUI(Prisma Studio)的大型团队。 * **不足之处:** 生成的客户端仍然可能比较臃肿,"影子数据库"迁移流程对于快速原型开发来说仍然有些复杂。 ### 2. Drizzle ORM:SQL 优先的标准 Drizzle 已成为追求极致性能的开发者的首选。它本质上是"穿着 TypeScript 外衣的 SQL"。零运行时开销,它是 Edge 的王者。 * **最适合:** 追求极致性能和*热爱*编写 SQL 的开发者。 * **不足之处:** 它要求较高的 SQL 知识水平。对于快速迭代的团队来说,缺乏"Active Record"式的魔法可能使管理复杂关联变得繁琐。 ### 3. Sutando ORM:Node.js 的"Eloquent"革命 当其他 ORM 专注于重型抽象或原生 SQL 时,**Sutando ORM** 通过专注于\*\*开发者体验(DX)\*\*且不臃肿,开辟了一片广阔的天地。 受 **Laravel Eloquent** 启发,Sutando 将 **Active Record** 模式带入了 Node.js/TypeScript 世界,现代、轻量且极其直观。 #### 为什么 Sutando 在 2026 年脱颖而出: * **Active Record 的简洁性:** 无需管理独立的 Repository 或复杂的查询构造器,你可以直接与数据交互:`User.query().find(1)`。 * **即学即用:** 如果你曾经接触过 Laravel 或 Rails,你就已经会使用 Sutando 了。它为 JavaScript 生态带来了同样的"开箱即用"的感觉。 * **轻量且适配 Edge:** 与过去臃肿的 ORM 不同,Sutando 采用模块化设计。它有最少的依赖,非常适合现代无服务器和边缘时代。 * **优雅的关联:** 在 Sutando 中处理 `hasMany`、`belongsTo` 和多态关联,可以说是整个 JS 生态中可读性最好的体验。 *** ## 快速对比:你应该选哪个? | 特性 | Prisma 7 | Drizzle | **Sutando ORM** | | :--- | :--- | :--- | :--- | | **模式** | Data Mapper | SQL 优先 | **Active Record** | | **开发体验** | 高(工具链) | 中(SQL 为主) | **卓越(直观)** | | **包体积** | 中等(WASM) | 极小 | **小 / 已优化** | | **类型安全** | 生成式 | 推断式 | **基于类 / TS** | | **理想场景** | 企业级 | 性能调优 | **快速扩展 / 初创团队** | *** ## Sutando 实战一览 在 2026 年,代码可读性本身就是一项功能,而非奢侈品。看看 Sutando 如何让你的逻辑保持简洁: ```typescript // 一次查询获取用户及其文章和评论 const user = await User.query() .with(['posts.comments']) .where('status', 'active') .first(); // 以 Active Record 风格更新 user.name = 'Gemini'; await user.save(); ``` ## 是时候切换了吗? 在 2026 年,我们正在见证后端的"重新简化"。开发者已经厌倦了与自己的工具作斗争。他们想要一个不碍事、同时又能为复杂关联提供强大抽象的 ORM。 **Sutando ORM** 代表了这一转变。它证明了你不需要在庞大的库和手写 SQL 字符串之间做出选择。你可以拥有一个美观、可链式调用且强大的 API,同时兼顾服务器资源开销。 ### 准备好更快地开发了吗? 如果你在 2026 年开始一个新项目,或者想要从"重量级"的传统 ORM 迁移出来,不妨试试 Sutando。它是让数据库交互再次变得令人愉悦的 ORM。 👉 查看 [Sutando 文档](https://sutando.org/zh_CN/guide/getting-started.html) 或运行 `npm install sutando` 立即开始。 --- --- url: /ja/blog/posts/the-best-node.js-orms-to-watch-in-2026.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** 2026年の Node.js エコシステムは、かつてないほど高速で軽量になっています。**エッジランタイム**(Cloudflare Workers、Vercel Edge)の支配と、プロダクションレベルのランタイムとしての **Bun** の台頭により、データベースとの相互作用は根本的に変化しました。 「一つですべてを解決」型の ORM の時代は終わりました。今日、ORM の選択は単なる構文の問題ではなく、コールドスタート、バンドルサイズ、長期的なメンテナンス性に影響する決断です。 このガイドでは、2026年の主要な候補と、新しい(しかし馴染みのある)**Sutando ORM** が高速開発チームの秘密兵器になりつつある理由を紹介します。 *** ## 2026年の情勢:パフォーマンス vs 抽象化 長年、開発者は選択を迫られてきました。重い抽象化の「魔法」を欲するか(パフォーマンスを犠牲にして)、生 SQL の「速度」を欲するか(生産性を犠牲にして)。2026年、その溝は埋まりつつあります。 ### 1. Prisma 7:管理されたエコシステム Prisma は依然として巨人です。最新の **v7 リリース**では、Prisma は完全に **WASM ベースのエンジン**に移行し、エッジ関数で以前のバージョンを悩ませていたコールドスタート問題を大幅に改善しました。 * **適しているケース:** 厳密なスキーマファーストアプローチと GUI(Prisma Studio)を必要とする大規模チーム。 * **課題:** 生成されたクライアントはまだ大きく、「シャドウデータベース」マイグレーションフローは迅速なプロトタイピングには複雑です。 ### 2. Drizzle ORM:SQL ファーストの標準 Drizzle はパフォーマンス重視の開発者の定番です。実質「TypeScript の衣を着た SQL」です。ランタイムオーバーヘッドゼロで、エッジの王様です。 * **適しているケース:** 最大限のパフォーマンスと SQL を書くのが好きな開発者。 * **課題:** 高い SQL 知識が必要。高速に動くチームにとって、Active Record スタイルの魔法がないため、複雑なリレーション管理が煩雑になります。 ### 3. Sutando ORM:Node.js ための「Eloquent」革命 他が重い抽象化か生 SQL のどちらかに焦点を当てる中、**Sutando ORM** は **Developer Happiness(DX)** に焦点を当て、膨張なしに大きなニッチを切り開いています。 **Laravel の Eloquent** にインスピレーションを受け、Sutando は **Active Record** パターンを Node.js/TypeScript の世界にモダンで軽量、非常に直感的な形でもたらします。 #### Sutando が2026年に勝っている理由: * **Active Record のシンプルさ:** 個別のリポジトリや複雑なクエリビルダーを管理する代わりに、データに直接アクセスします:`User.query().find(1)`。 * **馴染みの要素:** Laravel や Rails に触れたことがあれば、Sutando はもう知っています。JavaScript エコシステムに「そのまま動く」感覚をもたらします。 * **軽量・エッジ対応:** 昔の重い ORM と違い、Sutando はモジュラーに構築されています。最小限の依存関係で、モダンなサーバーレス・エッジ時代に最適です。 * **エレガントなリレーション:** Sutando で `hasMany`、`belongsTo`、ポリモーフィックリレーションを扱うのは、JS エコシステム全体で最も読みやすい体験と言えます。 *** ## クイック比較:どれを選ぶべきか? | 機能 | Prisma 7 | Drizzle | **Sutando ORM** | | :--- | :--- | :--- | :--- | | **パターン** | Data Mapper | SQL ファースト | **Active Record** | | **DX(開発者体験)** | 高い(ツール群) | 中程度(SQL 中心) | **最高(直感的)** | | **バンドルサイズ** | 中程度(WASM) | 極小 | **小 / 最適化済み** | | **型安全性** | 生成 | 推論 | **クラスベース / TS** | | **理想的な用途** | エンタープライズ | パフォーマンスチューニング | **急成長 / スタートアップ** | *** ## Sutando の実例 2026年、コードの可読性は機能であり、贅沢ではありません。Sutando でロジックがどれだけクリーンに保たれるか見てみましょう: ```typescript // ユーザーとその投稿・コメントを一度に取得 const user = await User.query() .with(['posts.comments']) .where('status', 'active') .first(); // Active Record スタイルで更新 user.name = 'Gemini'; await user.save(); ``` ## 乗り換える時? 2026年、バックエンドの「再シンプル化」が進んでいます。開発者はツールと戦うことに疲れています。サーバーのリソースを尊重しながら、複雑なリレーションのための強力な抽象化を提供する ORM を求めています。 **Sutando ORM** はこのシフトを代表します。巨大なライブラリと生 SQL 文字列のどちらかを選ぶ必要はないことを証明しています。美しく、チェーン可能で、強力な API を持ちながら、サーバーのリソースを尊重できます。 ### より速く構築する準備はできましたか? 2026年に新しいプロジェクトを始める、または「重量級」のレガシー ORM から移行するなら、Sutando を試してみてください。データベース操作を再び楽しくする ORM です。 👉 [Sutando ドキュメント](https://sutando.org/ja/guide/getting-started.html) を確認するか、`npm install sutando` を実行して今日始めましょう。 --- --- url: /blog/posts/modern-alternative-to-bookshelfjs-migrating-to-sutando.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Bookshelf.js was once the go-to ORM for Node.js developers who wanted a simple, promise-based interface on top of Knex.js. But with no updates since 2022, no TypeScript support, and the community moving on, it's time to consider a modern alternative. This guide walks through migrating from Bookshelf.js to Sutando. ## The State of Bookshelf.js Bookshelf.js hasn't seen a release since version 1.2.0 in June 2022. Several signs point to it being effectively abandoned: * **No releases in years**: The npm package hasn't been updated since 2022 * **No TypeScript support**: The library is pure CommonJS JavaScript with no built-in types — you need `@types/bookshelf` as a separate package * **Community consensus**: npm-compare, SaaSHub, and multiple blog posts all describe Bookshelf as "legacy" or "maintenance mode" * **StackOverflow questions**: Developers actively asking ["Is there an actively maintained replacement for Bookshelf.js?"](https://stackoverflow.com/questions/74914728/is-there-an-actively-maintained-replacement-for-bookshelf-js) ## Why Sutando? Sutando is a modern Active Record ORM for Node.js, inspired by Laravel's Eloquent. Here's how it compares to Bookshelf.js: | Feature | Bookshelf.js | Sutando | |---------|-------------|---------| | Pattern | Active Record (Collection-based) | Active Record (Model-based) | | TypeScript | None (needs `@types/bookshelf`) | First-class TS support | | Module System | CommonJS (`require`) | ESM + CommonJS | | Migrations | Via Knex (separate) | Built-in schema builder | | Last Release | 2022 | Active | | Relations | `forge().fetch()` | `hasMany()`, `belongsTo()` | | Soft Deletes | Manual | Built-in | | Hooks | `initialize` events | `creating`, `saved` etc. | | Scopes | Manual | Built-in | ## Concept Mapping: Bookshelf.js → Sutando ### Setup **Bookshelf.js:** ```typescript import knex from 'knex'; import bookshelf from 'bookshelf'; const knexInstance = knex({ client: 'pg', connection: process.env.DATABASE_URL, }); const db = bookshelf(knexInstance); db.plugin('registry'); db.plugin('virtuals'); db.plugin('pagination'); ``` **Sutando:** ```typescript import { sutando } from 'sutando'; const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); export default db; ``` No plugins needed — virtuals, pagination, and registry-like behavior are built in. ### Models **Bookshelf.js:** ```typescript const User = db.model('User', { tableName: 'users', posts() { return this.hasMany('Post'); }, profile() { return this.belongsTo('Profile'); }, }); // Or using extend const User = db.Model.extend({ tableName: 'users', posts() { return this.hasMany('Post'); }, }); ``` **Sutando:** ```typescript import { Model } from 'sutando'; class User extends Model { protected table = 'users'; protected fillable = ['name', 'email']; posts(): HasMany { return this.hasMany(Post); } profile(): BelongsTo { return this.belongsTo(Profile); } } ``` ### Queries **Bookshelf.js:** ```typescript // Fetch all const users = await new User().fetch(); // Fetch with relations const user = await new User({ id: 1 }).fetch({ withRelated: ['posts.comments'], }); // Where clause const users = await User.where('age', '>', 18).fetch(); // Insert const user = await new User({ name: 'Alice', email: 'alice@example.com' }).save(); // Update const user = await new User({ id: 1 }).fetch(); user.set('name', 'Bob'); await user.save(); // Delete const user = await new User({ id: 1 }).fetch(); await user.destroy(); ``` **Sutando:** ```typescript // Fetch all const users = await User.query().get(); // Fetch with relations const user = await User.query().with('posts.comments').find(1); // Where clause const users = await User.query().where('age', '>', 18).get(); // Insert const user = new User({ name: 'Alice', email: 'alice@example.com' }); await user.save(); // Update const user = await User.query().find(1); user.name = 'Bob'; await user.save(); // Delete const user = await User.query().find(1); await user.delete(); ``` ### Pagination **Bookshelf.js** requires the pagination plugin: ```typescript db.plugin('pagination'); const results = await new User().fetchPage({ page: 1, pageSize: 20, }); // results.models, results.pagination ``` **Sutando** has pagination built in: ```typescript const results = await User.query().paginate(1, 20); // results.data, results.total, results.currentPage, results.perPage ``` ### Transactions **Bookshelf.js:** ```typescript await db.transaction(async (t) => { const user = await new User({ name: 'Alice' }).save(null, { transacting: t }); await new Post({ title: 'Hello', user_id: user.id }).save(null, { transacting: t }); }); ``` **Sutando:** ```typescript await db.transaction(async () => { const user = new User({ name: 'Alice' }); await user.save(); const post = new Post({ title: 'Hello', user_id: user.id }); await post.save(); }); ``` ### Hooks / Events **Bookshelf.js:** ```typescript const User = db.Model.extend({ tableName: 'users', initialize() { this.on('saving', this.onSaving); }, onSaving(model, attrs, options) { model.set('updated_at', new Date()); }, }); ``` **Sutando:** ```typescript class User extends Model { protected table = 'users'; static saving(model: User) { model.updated_at = new Date(); } } ``` ## Step-by-Step Migration Guide ### Step 1: Install Sutando ```bash npm install sutando ``` ### Step 2: Replace the Database Connection Replace your Bookshelf initialization with Sutando: ```typescript import { sutando } from 'sutando'; const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); export default db; ``` Remove the `bookshelf` and `@types/bookshelf` packages: ```bash npm uninstall bookshelf @types/bookshelf ``` ### Step 3: Convert Models from `extend` to ES6 Classes Bookshelf's `Model.extend()` pattern should be converted to ES6 class syntax: * `tableName` → `protected table` * `hasMany('Post')` → `this.hasMany(Post)` (using class references, not string names) * `belongsTo('Profile')` → `this.belongsTo(Profile)` * `initialize()` with event listeners → static hook methods like `saving`, `creating` ### Step 4: Update Query Patterns The biggest changes in query patterns: * `new User().fetch()` → `User.query().get()` * `new User({ id: 1 }).fetch({ withRelated: [...] })` → `User.query().with('...').find(1)` * `model.set('key', value)` → `model.key = value` (direct property assignment) * `model.destroy()` → `model.delete()` * `.fetchPage()` → `.paginate()` ### Step 5: Migrate Migrations If you're using Knex migrations, they work with Sutando's schema builder too: ```typescript import { schema } from 'sutando'; exports.up = function (schema) { return schema.createTable('users', (table) => { table.increments('id'); table.string('name'); table.string('email').unique(); table.timestamps(); }); }; exports.down = function (schema) { return schema.dropTableIfExists('users'); }; ``` ## What You Gain After Migration * **First-class TypeScript**: No more `@types/bookshelf` — types are built in and accurate * **ESM support**: Use `import`/`export` natively, no more `require()` * **No plugins needed**: Pagination, virtuals, and registry are built-in features * **Direct property access**: `user.name = 'Bob'` instead of `user.set('name', 'Bob')` * **Built-in soft deletes**: Add `softDeletes()` to your model — no manual implementation * **Built-in scopes**: Define reusable query filters without custom plugin code * **Eloquent-style API**: Familiar to anyone who's used Laravel * **Active maintenance**: Regular updates and new features ## Conclusion Bookshelf.js was a great ORM in its time, but with no updates since 2022 and no TypeScript support, it's become a liability for modern Node.js projects. Sutando offers a natural upgrade path — same Active Record pattern, but with first-class TypeScript, built-in features that required plugins in Bookshelf, and an actively maintained codebase. The migration is straightforward: convert models from `extend()` to ES6 classes, update query patterns, and remove the Bookshelf dependency. Both ORMs can coexist during the transition, so you can migrate model by model. 👉 [Get started with Sutando](https://sutando.org/guide/getting-started.html) or run `npm install sutando` to try it out. --- --- url: /blog/posts/modern-alternative-to-objectionjs-migrating-to-sutando.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Objection.js has been a popular ORM in the Node.js ecosystem for years. Built on top of Knex.js, it offered a flexible, SQL-friendly approach to database operations. But with the original maintainer stepping away and TypeScript support lagging, many teams are looking for a modern alternative. This guide walks through migrating from Objection.js to Sutando. ## Why Move Away from Objection.js? Objection.js remains a capable library, but several factors make migration worth considering: ### Maintenance Status In [a GitHub discussion](https://github.com/Vincit/objection.js/discussions/2463), the original author Sami Koskimäki announced he would no longer actively maintain the project. Key points: * **No active maintainer**: The project has a single bus factor, and that person has stepped away * **No new features**: Only community forks are receiving updates * **Slow releases**: Months between patch releases, if any ### TypeScript Support Objection.js was designed before TypeScript was widely adopted. The type system works, but it requires manual type declarations and doesn't provide the seamless inference that modern TypeScript-first ORMs offer. ### No Built-in Migrations Objection.js relies entirely on Knex for migrations. While Knex is solid, you're managing two separate tools with different APIs and mental models. ## Why Sutando? Sutando is a modern Active Record ORM for Node.js, inspired by Laravel's Eloquent. It addresses the pain points Objection.js users face: | Feature | Objection.js | Sutando | |---------|-------------|---------| | Pattern | Query Builder + ORM | Active Record | | TypeScript | Manual types | First-class TS support | | Migrations | Via Knex (separate) | Built-in schema builder | | Maintenance | Stalled | Active | | Relations | `withGraphFetched` | `with()`, `hasMany()`, `belongsTo()` | | Soft Deletes | Manual | Built-in | | Hooks | `$beforeInsert` etc. | `creating`, `saved` etc. | | Scopes | Manual | Built-in | ## Concept Mapping: Objection.js → Sutando ### Models **Objection.js:** ```typescript import { Model } from 'objection'; import knex from 'knex'; const db = knex({ client: 'pg', connection: process.env.DATABASE_URL, }); Model.knex(db); class User extends Model { static tableName = 'users'; static jsonSchema = { type: 'object', required: ['name', 'email'], properties: { id: { type: 'integer' }, name: { type: 'string', minLength: 1, maxLength: 255 }, email: { type: 'string', format: 'email' }, }, }; static relationMappings = { posts: { relation: Model.HasManyRelation, modelClass: Post, join: { from: 'users.id', to: 'posts.user_id' }, }, }; } ``` **Sutando:** ```typescript import { sutando, Model } from 'sutando'; const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); class User extends Model { protected table = 'users'; protected fillable = ['name', 'email']; posts(): HasMany { return this.hasMany(Post); } } ``` ### Queries **Objection.js:** ```typescript // Simple query const users = await User.query().where('age', '>', 18); // With relations const user = await User.query() .withGraphFetched('[posts.comments]') .findById(1); // Insert const user = await User.query().insert({ name: 'Alice', email: 'alice@example.com' }); // Update await User.query().patch({ name: 'Bob' }).where('id', 1); // Delete await User.query().deleteById(1); ``` **Sutando:** ```typescript // Simple query const users = await User.query().where('age', '>', 18).get(); // With relations const user = await User.query().with('posts.comments').find(1); // Insert const user = new User({ name: 'Alice', email: 'alice@example.com' }); await user.save(); // Update const user = await User.query().find(1); user.name = 'Bob'; await user.save(); // Delete const user = await User.query().find(1); await user.delete(); ``` ### Relations **Objection.js** uses `relationMappings` with a declarative object syntax: ```typescript static relationMappings = { posts: { relation: Model.HasManyRelation, modelClass: Post, join: { from: 'users.id', to: 'posts.user_id' }, }, profile: { relation: Model.BelongsToOneRelation, modelClass: Profile, join: { from: 'users.profile_id', to: 'profiles.id' }, }, }; ``` **Sutando** uses method-based definitions, similar to Eloquent: ```typescript posts(): HasMany { return this.hasMany(Post); } profile(): BelongsTo { return this.belongsTo(Profile); } ``` ### Transactions **Objection.js:** ```typescript await User.transaction(async (trx) => { const user = await User.query(trx).insert({ name: 'Alice' }); await Post.query(trx).insert({ title: 'Hello', user_id: user.id }); }); ``` **Sutando:** ```typescript await db.transaction(async () => { const user = new User({ name: 'Alice' }); await user.save(); const post = new Post({ title: 'Hello', user_id: user.id }); await post.save(); }); ``` ### Hooks / Events **Objection.js:** ```typescript class User extends Model { static tableName = 'users'; $beforeInsert() { this.created_at = new Date().toISOString(); } $beforeUpdate() { this.updated_at = new Date().toISOString(); } } ``` **Sutando:** ```typescript import { Model } from 'sutando'; class User extends Model { protected table = 'users'; static creating(model: User) { model.created_at = new Date(); } static updating(model: User) { model.updated_at = new Date(); } } ``` ## Step-by-Step Migration Guide ### Step 1: Install Sutando ```bash npm install sutando ``` ### Step 2: Set Up the Database Connection Create a `db.ts` file: ```typescript import { sutando } from 'sutando'; export const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); export default db; ``` ### Step 3: Convert Models Gradually You don't need to migrate everything at once. Both ORMs can coexist in the same project: 1. Start by converting the simplest models (no complex relations) 2. Move on to models with `hasMany` / `belongsTo` relations 3. Finally, convert models with complex graph operations ### Step 4: Migrate Migrations If you're using Knex migrations, Sutando's schema builder is compatible: ```typescript import { schema } from 'sutando'; exports.up = function (schema) { return schema.createTable('users', (table) => { table.increments('id'); table.string('name'); table.string('email').unique(); table.timestamps(); }); }; exports.down = function (schema) { return schema.dropTableIfExists('users'); }; ``` ### Step 5: Replace Queries Go through your codebase and replace Objection.js queries with Sutando equivalents. The main changes: * `Model.query()` in Objection returns a query builder; in Sutando, you chain `.get()` or `.first()` to execute * `withGraphFetched('[posts.comments]')` becomes `with('posts.comments')` * `insert()` / `patch()` / `deleteById()` become Active Record style `save()` / `delete()` ## What You Gain After Migration * **Active Record pattern**: No more separate query builder and model — operate on model instances directly * **Built-in soft deletes**: Add `softDeletes()` to your model and get `deleted_at` handling out of the box * **Built-in scopes**: Define reusable query filters with `scope` methods * **First-class TypeScript**: Type inference works naturally with class-based models * **Eloquent-style API**: If you've ever used Laravel, the learning curve is nearly zero * **Active maintenance**: Regular releases and new features ## Conclusion Objection.js served the Node.js community well, but its maintenance status and TypeScript limitations make it a liability for long-term projects. Sutando offers a familiar, well-maintained alternative with an Active Record pattern that reduces boilerplate and improves developer experience. The migration can be done incrementally — both ORMs can coexist while you transition model by model. Start with a single model today and see the difference for yourself. 👉 [Get started with Sutando](https://sutando.org/guide/getting-started.html) or run `npm install sutando` to try it out. --- --- url: /ja/blog/posts/modern-alternative-to-bookshelfjs-migrating-to-sutando.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Bookshelf.js はかつて、Knex.js の上にシンプルで Promise ベースのインターフェースを求める Node.js 開発者の定番 ORM でした。しかし 2022 年以降アップデートがなく、TypeScript サポートもなく、コミュニティも移行してしまいました。モダンな代替を検討する時が来ています。このガイドでは、Bookshelf.js から Sutando への移行手順を解説します。 ## Bookshelf.js の現状 Bookshelf.js は 2022 年 6 月のバージョン 1.2.0 以降、新リリースがありません。事実上放置されていることを示す複数の兆候があります: * **長年リリースなし**:npm パッケージが 2022 年以降更新されていない * **TypeScript サポートなし**:ライブラリは純粋な CommonJS JavaScript で、組み込み型がない——`@types/bookshelf` の別パッケージが必要 * **コミュニティのコンセンサス**:npm-compare、SaaSHub、多数のブログが Bookshelf を「レガシー」または「メンテナンスモード」と表現 * **StackOverflow の質問**:開発者が「[Bookshelf.js の積極的にメンテナンスされている代替はありますか?](https://stackoverflow.com/questions/74914728/is-there-an-actively-maintained-replacement-for-bookshelf-js)」と活発に質問 ## なぜ Sutando なのか? Sutando は Laravel の Eloquent にインスピレーションを受けた、モダンな Node.js 向け Active Record ORM です。Bookshelf.js との比較: | 機能 | Bookshelf.js | Sutando | |------|-------------|---------| | パターン | Active Record(Collection ベース) | Active Record(Model ベース) | | TypeScript | なし(`@types/bookshelf` が必要) | ファーストクラス TS サポート | | モジュールシステム | CommonJS (`require`) | ESM + CommonJS | | マイグレーション | Knex 経由(別ツール) | 組み込み schema builder | | 最終リリース | 2022 | 活発 | | リレーション | `forge().fetch()` | `hasMany()`, `belongsTo()` | | ソフトデリート | 手動 | 組み込み | | フック | `initialize` イベント | `creating`, `saved` 等 | | スコープ | 手動 | 組み込み | ## 概念マッピング:Bookshelf.js → Sutando ### 初期化 **Bookshelf.js:** ```typescript import knex from 'knex'; import bookshelf from 'bookshelf'; const knexInstance = knex({ client: 'pg', connection: process.env.DATABASE_URL, }); const db = bookshelf(knexInstance); db.plugin('registry'); db.plugin('virtuals'); db.plugin('pagination'); ``` **Sutando:** ```typescript import { sutando } from 'sutando'; const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); export default db; ``` プラグイン不要——virtuals、pagination、registry のような機能は組み込み済みです。 ### モデル **Bookshelf.js:** ```typescript const User = db.model('User', { tableName: 'users', posts() { return this.hasMany('Post'); }, profile() { return this.belongsTo('Profile'); }, }); // または extend を使用 const User = db.Model.extend({ tableName: 'users', posts() { return this.hasMany('Post'); }, }); ``` **Sutando:** ```typescript import { Model } from 'sutando'; class User extends Model { protected table = 'users'; protected fillable = ['name', 'email']; posts(): HasMany { return this.hasMany(Post); } profile(): BelongsTo { return this.belongsTo(Profile); } } ``` ### クエリ **Bookshelf.js:** ```typescript // 全件取得 const users = await new User().fetch(); // リレーション付き取得 const user = await new User({ id: 1 }).fetch({ withRelated: ['posts.comments'], }); // 条件クエリ const users = await User.where('age', '>', 18).fetch(); // 挿入 const user = await new User({ name: 'Alice', email: 'alice@example.com' }).save(); // 更新 const user = await new User({ id: 1 }).fetch(); user.set('name', 'Bob'); await user.save(); // 削除 const user = await new User({ id: 1 }).fetch(); await user.destroy(); ``` **Sutando:** ```typescript // 全件取得 const users = await User.query().get(); // リレーション付き取得 const user = await User.query().with('posts.comments').find(1); // 条件クエリ const users = await User.query().where('age', '>', 18).get(); // 挿入 const user = new User({ name: 'Alice', email: 'alice@example.com' }); await user.save(); // 更新 const user = await User.query().find(1); user.name = 'Bob'; await user.save(); // 削除 const user = await User.query().find(1); await user.delete(); ``` ### ページネーション **Bookshelf.js** はページネーションプラグインが必要: ```typescript db.plugin('pagination'); const results = await new User().fetchPage({ page: 1, pageSize: 20, }); // results.models, results.pagination ``` **Sutando** はページネーションが組み込み: ```typescript const results = await User.query().paginate(1, 20); // results.data, results.total, results.currentPage, results.perPage ``` ### トランザクション **Bookshelf.js:** ```typescript await db.transaction(async (t) => { const user = await new User({ name: 'Alice' }).save(null, { transacting: t }); await new Post({ title: 'Hello', user_id: user.id }).save(null, { transacting: t }); }); ``` **Sutando:** ```typescript await db.transaction(async () => { const user = new User({ name: 'Alice' }); await user.save(); const post = new Post({ title: 'Hello', user_id: user.id }); await post.save(); }); ``` ### フック / イベント **Bookshelf.js:** ```typescript const User = db.Model.extend({ tableName: 'users', initialize() { this.on('saving', this.onSaving); }, onSaving(model, attrs, options) { model.set('updated_at', new Date()); }, }); ``` **Sutando:** ```typescript class User extends Model { protected table = 'users'; static saving(model: User) { model.updated_at = new Date(); } } ``` ## ステップバイステップ移行ガイド ### ステップ 1:Sutando のインストール ```bash npm install sutando ``` ### ステップ 2:データベース接続の置き換え Bookshelf の初期化を Sutando に置き換え: ```typescript import { sutando } from 'sutando'; const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); export default db; ``` `bookshelf` と `@types/bookshelf` パッケージを削除: ```bash npm uninstall bookshelf @types/bookshelf ``` ### ステップ 3:モデルを `extend` から ES6 クラスに変換 Bookshelf の `Model.extend()` パターンは ES6 クラス構文に変換: * `tableName` → `protected table` * `hasMany('Post')` → `this.hasMany(Post)`(文字列名ではなくクラス参照を使用) * `belongsTo('Profile')` → `this.belongsTo(Profile)` * `initialize()` とイベントリスナー → `saving`、`creating` 等の静的フックメソッド ### ステップ 4:クエリパターンの更新 クエリパターンの主な変更点: * `new User().fetch()` → `User.query().get()` * `new User({ id: 1 }).fetch({ withRelated: [...] })` → `User.query().with('...').find(1)` * `model.set('key', value)` → `model.key = value`(直接プロパティ代入) * `model.destroy()` → `model.delete()` * `.fetchPage()` → `.paginate()` ### ステップ 5:マイグレーションの移行 Knex マイグレーションを使用している場合、Sutando の schema builder と互換性があります: ```typescript import { schema } from 'sutando'; exports.up = function (schema) { return schema.createTable('users', (table) => { table.increments('id'); table.string('name'); table.string('email').unique(); table.timestamps(); }); }; exports.down = function (schema) { return schema.dropTableIfExists('users'); }; ``` ## 移行後に得られるもの * **ファーストクラス TypeScript**:`@types/bookshelf` は不要——型が組み込みで正確 * **ESM サポート**:`import`/`export` をネイティブに使用、`require()` は不要 * **プラグイン不要**:ページネーション、virtuals、registry は組み込み機能 * **直接プロパティアクセス**:`user.set('name', 'Bob')` ではなく `user.name = 'Bob'` * **組み込みソフトデリート**:モデルに `softDeletes()` を追加するだけ——手動実装不要 * **組み込みスコープ**:カスタムプラグインなしで再利用可能なクエリフィルターを定義 * **Eloquent スタイル API**:Laravel を使ったことがある人に馴染みやすい * **活発なメンテナンス**:定期的なアップデートと新機能 ## 結論 Bookshelf.js はその時代において優れた ORM でしたが、2022 年以降のアップデート停止と TypeScript サポートの欠如により、モダンな Node.js プロジェクトではリスクとなります。Sutando は自然なアップグレードパスを提供します——同じ Active Record パターンでありながら、ファーストクラスの TypeScript サポート、Bookshelf でプラグインが必要だった機能の組み込み、そして活発にメンテナンスされたコードベースを備えています。 移行はシンプルです:モデルを `extend()` から ES6 クラスに変換し、クエリパターンを更新し、Bookshelf の依存を削除します。両方の ORM は移行期間中に共存できるため、モデルごとに段階的に移行できます。 👉 [Sutando を始める](https://sutando.org/ja/guide/getting-started.html) または `npm install sutando` を実行して試してみましょう。 --- --- url: /zh_CN/blog/posts/modern-alternative-to-bookshelfjs-migrating-to-sutando.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Bookshelf.js 曾经是 Node.js 开发者的首选 ORM,它在 Knex.js 之上提供了简单、基于 Promise 的接口。但自 2022 年以来没有更新,没有 TypeScript 支持,社区也已经转移,是时候考虑现代替代方案了。本指南将带你从 Bookshelf.js 迁移到 Sutando。 ## Bookshelf.js 的现状 Bookshelf.js 自 2022 年 6 月发布 1.2.0 版本以来再没有新版本。多个迹象表明它实际上已被放弃: * **多年未发布**:npm 包自 2022 年以来未更新 * **没有 TypeScript 支持**:库是纯 CommonJS JavaScript,没有内置类型——需要单独安装 `@types/bookshelf` * **社区共识**:npm-compare、SaaSHub 和多篇博客都将 Bookshelf 描述为"遗留"或"维护模式" * **StackOverflow 问题**:开发者积极提问["有没有积极维护的 Bookshelf.js 替代品?"](https://stackoverflow.com/questions/74914728/is-there-an-actively-maintained-replacement-for-bookshelf-js) ## 为什么选择 Sutando? Sutando 是一个现代的 Node.js Active Record ORM,灵感来自 Laravel 的 Eloquent。以下是与 Bookshelf.js 的对比: | 特性 | Bookshelf.js | Sutando | |------|-------------|---------| | 模式 | Active Record(Collection 基础) | Active Record(Model 基础) | | TypeScript | 无(需要 `@types/bookshelf`) | 一等公民 TS 支持 | | 模块系统 | CommonJS (`require`) | ESM + CommonJS | | 迁移 | 通过 Knex(独立工具) | 内置 schema builder | | 最后发布 | 2022 | 活跃 | | 关联 | `forge().fetch()` | `hasMany()`, `belongsTo()` | | 软删除 | 手动实现 | 内置 | | 钩子 | `initialize` 事件 | `creating`, `saved` 等 | | 作用域 | 手动实现 | 内置 | ## 概念映射:Bookshelf.js → Sutando ### 初始化 **Bookshelf.js:** ```typescript import knex from 'knex'; import bookshelf from 'bookshelf'; const knexInstance = knex({ client: 'pg', connection: process.env.DATABASE_URL, }); const db = bookshelf(knexInstance); db.plugin('registry'); db.plugin('virtuals'); db.plugin('pagination'); ``` **Sutando:** ```typescript import { sutando } from 'sutando'; const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); export default db; ``` 不需要插件——virtuals、pagination 和 registry 类似的行为都是内置的。 ### 模型 **Bookshelf.js:** ```typescript const User = db.model('User', { tableName: 'users', posts() { return this.hasMany('Post'); }, profile() { return this.belongsTo('Profile'); }, }); // 或使用 extend const User = db.Model.extend({ tableName: 'users', posts() { return this.hasMany('Post'); }, }); ``` **Sutando:** ```typescript import { Model } from 'sutando'; class User extends Model { protected table = 'users'; protected fillable = ['name', 'email']; posts(): HasMany { return this.hasMany(Post); } profile(): BelongsTo { return this.belongsTo(Profile); } } ``` ### 查询 **Bookshelf.js:** ```typescript // 获取全部 const users = await new User().fetch(); // 带关联获取 const user = await new User({ id: 1 }).fetch({ withRelated: ['posts.comments'], }); // 条件查询 const users = await User.where('age', '>', 18).fetch(); // 插入 const user = await new User({ name: 'Alice', email: 'alice@example.com' }).save(); // 更新 const user = await new User({ id: 1 }).fetch(); user.set('name', 'Bob'); await user.save(); // 删除 const user = await new User({ id: 1 }).fetch(); await user.destroy(); ``` **Sutando:** ```typescript // 获取全部 const users = await User.query().get(); // 带关联获取 const user = await User.query().with('posts.comments').find(1); // 条件查询 const users = await User.query().where('age', '>', 18).get(); // 插入 const user = new User({ name: 'Alice', email: 'alice@example.com' }); await user.save(); // 更新 const user = await User.query().find(1); user.name = 'Bob'; await user.save(); // 删除 const user = await User.query().find(1); await user.delete(); ``` ### 分页 **Bookshelf.js** 需要分页插件: ```typescript db.plugin('pagination'); const results = await new User().fetchPage({ page: 1, pageSize: 20, }); // results.models, results.pagination ``` **Sutando** 内置分页: ```typescript const results = await User.query().paginate(1, 20); // results.data, results.total, results.currentPage, results.perPage ``` ### 事务 **Bookshelf.js:** ```typescript await db.transaction(async (t) => { const user = await new User({ name: 'Alice' }).save(null, { transacting: t }); await new Post({ title: 'Hello', user_id: user.id }).save(null, { transacting: t }); }); ``` **Sutando:** ```typescript await db.transaction(async () => { const user = new User({ name: 'Alice' }); await user.save(); const post = new Post({ title: 'Hello', user_id: user.id }); await post.save(); }); ``` ### 钩子 / 事件 **Bookshelf.js:** ```typescript const User = db.Model.extend({ tableName: 'users', initialize() { this.on('saving', this.onSaving); }, onSaving(model, attrs, options) { model.set('updated_at', new Date()); }, }); ``` **Sutando:** ```typescript class User extends Model { protected table = 'users'; static saving(model: User) { model.updated_at = new Date(); } } ``` ## 逐步迁移指南 ### 第 1 步:安装 Sutando ```bash npm install sutando ``` ### 第 2 步:替换数据库连接 用 Sutando 替换 Bookshelf 初始化: ```typescript import { sutando } from 'sutando'; const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); export default db; ``` 移除 `bookshelf` 和 `@types/bookshelf` 包: ```bash npm uninstall bookshelf @types/bookshelf ``` ### 第 3 步:将模型从 `extend` 转换为 ES6 类 Bookshelf 的 `Model.extend()` 模式应转换为 ES6 类语法: * `tableName` → `protected table` * `hasMany('Post')` → `this.hasMany(Post)`(使用类引用,不是字符串名称) * `belongsTo('Profile')` → `this.belongsTo(Profile)` * `initialize()` 配合事件监听 → 静态钩子方法如 `saving`、`creating` ### 第 4 步:更新查询模式 查询模式的主要变化: * `new User().fetch()` → `User.query().get()` * `new User({ id: 1 }).fetch({ withRelated: [...] })` → `User.query().with('...').find(1)` * `model.set('key', value)` → `model.key = value`(直接属性赋值) * `model.destroy()` → `model.delete()` * `.fetchPage()` → `.paginate()` ### 第 5 步:迁移数据库迁移 如果你在使用 Knex 迁移,它们也兼容 Sutando 的 schema builder: ```typescript import { schema } from 'sutando'; exports.up = function (schema) { return schema.createTable('users', (table) => { table.increments('id'); table.string('name'); table.string('email').unique(); table.timestamps(); }); }; exports.down = function (schema) { return schema.dropTableIfExists('users'); }; ``` ## 迁移后你获得了什么 * **一等公民 TypeScript**:不再需要 `@types/bookshelf`——类型内置且准确 * **ESM 支持**:原生使用 `import`/`export`,不再需要 `require()` * **无需插件**:分页、虚拟字段和 registry 都是内置功能 * **直接属性访问**:`user.name = 'Bob'` 代替 `user.set('name', 'Bob')` * **内置软删除**:在模型中添加 `softDeletes()` 即可——无需手动实现 * **内置作用域**:定义可复用的查询过滤器,无需自定义插件 * **Eloquent 风格 API**:用过 Laravel 的人都熟悉 * **活跃维护**:定期更新和新功能 ## 结论 Bookshelf.js 在它的时代是一个优秀的 ORM,但自 2022 年停止更新且没有 TypeScript 支持,它已成为现代 Node.js 项目的隐患。Sutando 提供了自然的升级路径——相同的 Active Record 模式,但有一等公民 TypeScript 支持、Bookshelf 中需要插件的内置功能,以及活跃维护的代码库。 迁移过程简单直接:将模型从 `extend()` 转换为 ES6 类,更新查询模式,移除 Bookshelf 依赖。两个 ORM 在过渡期间可以共存,因此你可以逐个模型地迁移。 👉 [开始使用 Sutando](https://sutando.org/zh_CN/guide/getting-started.html) 或运行 `npm install sutando` 来试试。 --- --- url: /blog/posts/building-rest-api-with-sutando-and-express.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Building a REST API with Sutando is straightforward. In this tutorial, we'll build a complete blog API with authentication, validation, and error handling. ## Project Setup ```bash mkdir blog-api && cd blog-api npm init -y npm install sutando mysql2 express zod npm install -D typescript @types/node @types/express tsx ``` ## Database and Models ```ts // db.ts import { sutando, Model } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', user: 'root', password: '', database: 'blog' } }); class User extends Model { table = 'users'; hidden = ['password']; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; casts = { published: 'boolean' }; relationUser() { return this.belongsTo(User, 'user_id'); } relationComments() { return this.hasMany(Comment, 'post_id'); } scopePublished(query) { return query.where('published', true); } } class Comment extends Model { table = 'comments'; relationUser() { return this.belongsTo(User, 'user_id'); } } export { sutando, User, Post, Comment }; ``` ## Validation with Zod ```ts // validators.ts import { z } from 'zod'; export const createPostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), user_id: z.number().int().positive(), }); export const updatePostSchema = z.object({ title: z.string().min(1).max(200).optional(), content: z.string().min(1).optional(), published: z.boolean().optional(), }); export const createCommentSchema = z.object({ content: z.string().min(1), user_id: z.number().int().positive(), }); ``` ## The API ```ts // app.ts import express from 'express'; import { User, Post, Comment } from './db'; import { createPostSchema, updatePostSchema, createCommentSchema } from './validators'; const app = express(); app.use(express.json()); // Error handler const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // Posts app.get('/posts', asyncHandler(async (req, res) => { const page = Number(req.query.page) || 1; const limit = Number(req.query.limit) || 20; const posts = await Post.query() .with('user', 'comments.user') .published() .orderBy('created_at', 'desc') .page(page, limit); res.json(posts); })); app.get('/posts/:id', asyncHandler(async (req, res) => { const post = await Post.query() .with('user', 'comments.user') .find(req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); res.json(post); })); app.post('/posts', asyncHandler(async (req, res) => { const data = createPostSchema.parse(req.body); const post = await Post.create(data); res.status(201).json(post); })); app.put('/posts/:id', asyncHandler(async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); const data = updatePostSchema.parse(req.body); post.fill(data); await post.save(); res.json(post); })); app.delete('/posts/:id', asyncHandler(async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); await post.delete(); res.json({ success: true }); })); // Comments app.post('/posts/:id/comments', asyncHandler(async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); const data = createCommentSchema.parse({ ...req.body, post_id: post.id }); const comment = await Comment.create(data); res.status(201).json(comment); })); // Users app.get('/users/:id', asyncHandler(async (req, res) => { const user = await User.query() .with('posts') .find(req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); res.json(user); })); // Error middleware app.use((err, req, res, next) => { if (err.name === 'ZodError') { return res.status(400).json({ error: 'Validation error', details: err.errors }); } console.error(err); res.status(500).json({ error: 'Internal server error' }); }); app.listen(3000, () => console.log('API running on port 3000')); ``` ## Key Features Demonstrated * **Pagination** via `.page(page, limit)` * **Eager loading** with `.with('user', 'comments.user')` * **Query scopes** with `.published()` * **Validation** with Zod schemas * **Error handling** with async wrapper and error middleware * **Hidden fields** (password) via `hidden` array on model ## Next Steps * Add JWT authentication middleware * Add rate limiting with express-rate-limit * Add CORS configuration * Deploy to your preferred platform Full documentation at [sutando.org](https://sutando.org/guide/getting-started.html). --- --- url: /blog/posts/database-migrations-with-sutando-complete-guide.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Database migrations are version control for your schema. Sutando includes a powerful schema builder that lets you create and modify tables programmatically. This guide covers everything from creating your first table to running migrations in production. ## The Schema Builder Sutando's schema builder provides a fluent API for table manipulation: ```ts import { sutando } from 'sutando'; // Create a table await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('email').notNullable().unique(); table.string('password'); table.boolean('active').defaultTo(true); table.timestamps(); }); // Modify an existing table await sutando.schema().table('users', table => { table.string('avatar').nullable(); table.integer('role_id').unsigned().references('id').inTable('roles'); }); // Drop a table await sutando.schema().dropTableIfExists('old_table'); ``` ## Column Types | Method | SQL Type | |--------|---------| | `increments('id')` | AUTO\_INCREMENT INTEGER | | `string('name')` | VARCHAR(255) | | `text('content')` | TEXT | | `integer('views')` | INTEGER | | `bigInteger('count')` | BIGINT | | `boolean('active')` | BOOLEAN / TINYINT | | `decimal('price', 10, 2)` | DECIMAL(10, 2) | | `date('birthday')` | DATE | | `datetime('published_at')` | DATETIME | | `timestamp('created_at')` | TIMESTAMP | | `json('metadata')` | JSON / TEXT | | `uuid('uuid')` | UUID / CHAR(36) | ## Column Modifiers ```ts await sutando.schema().createTable('products', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('sku').unique(); table.decimal('price', 10, 2).defaultTo(0); table.text('description').nullable(); table.integer('category_id').unsigned().references('id').inTable('categories'); table.timestamps(); table.softDeletes(); }); ``` ## Foreign Keys ```ts await sutando.schema().createTable('posts', table => { table.increments('id').primary(); table.string('title').notNullable(); table.integer('user_id').unsigned(); // Foreign key table.foreign('user_id') .references('id') .inTable('users') .onDelete('CASCADE') .onUpdate('CASCADE'); }); // Add foreign key to existing table await sutando.schema().table('posts', table => { table.foreign('category_id').references('id').inTable('categories'); }); // Drop foreign key await sutando.schema().table('posts', table => { table.dropForeign('posts_category_id_foreign'); }); ``` ## Indexes ```ts await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('email').unique(); table.string('name'); // Single column index table.index('name'); // Composite index table.index(['status', 'created_at']); }); ``` ## Organizing Migrations For larger projects, organize migrations as separate files: ``` migrations/ 001_create_users_table.ts 002_create_posts_table.ts 003_create_comments_table.ts 004_add_avatar_to_users.ts ``` ```ts // migrations/001_create_users_table.ts import { sutando } from 'sutando'; export async function up() { await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('email').notNullable().unique(); table.timestamps(); }); } export async function down() { await sutando.schema().dropTableIfExists('users'); } ``` ```ts // run-migrations.ts import * as migration1 from './migrations/001_create_users_table'; import * as migration2 from './migrations/002_create_posts_table'; const migrations = [migration1, migration2]; async function run() { for (const m of migrations) { await m.up(); console.log(`Ran: ${m.name}`); } } run(); ``` ## Production Best Practices 1. **Always have a `down` function** — so you can rollback 2. **Test migrations on a staging database first** 3. **Back up your database before running migrations in production** 4. **Never use `dropTable` in production without `IfExists`** 5. **Add indexes thoughtfully** — they speed up reads but slow down writes ## Conclusion Sutando's schema builder gives you full control over your database schema with a clean, chainable API. Whether you're creating tables, adding columns, or managing foreign keys, it's all straightforward and readable. Learn more in the [Sutando documentation](https://sutando.org/guide/migrations.html). --- --- url: /blog/posts/getting-started-with-sutando-build-your-first-nodejs-app.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** New to Sutando? This tutorial walks you through everything you need to build your first Node.js application with Sutando ORM — from installation to a working CRUD API. ## Prerequisites * Node.js 18+ installed * A database (MySQL, PostgreSQL, or SQLite) * Basic JavaScript/TypeScript knowledge ## Step 1: Installation Create a new project and install Sutando: ```bash mkdir my-app && cd my-app npm init -y npm install sutando ``` Install your database driver: ```bash # MySQL npm install mysql2 # PostgreSQL npm install pg # SQLite npm install better-sqlite3 ``` ## Step 2: Database Configuration Create a `database.ts` file to set up your connection: ```ts import { sutando } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', port: 3306, user: 'root', password: '', database: 'my_app' } }); export default sutando; ``` ### Using Multiple Connections ```ts sutando.addConnection({ /* ... */ }, 'primary'); sutando.addConnection({ /* ... */ }, 'replica'); // Use a specific connection const db = sutando.connection('replica'); ``` ## Step 3: Creating Tables with Schema Builder Sutando includes a schema builder for creating and modifying tables: ```ts import { sutando } from './database'; async function setup() { await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('email').notNullable().unique(); table.string('password'); table.timestamps(); }); await sutando.schema().createTable('posts', table => { table.increments('id').primary(); table.string('title').notNullable(); table.text('content'); table.integer('user_id').unsigned().references('id').inTable('users'); table.boolean('published').defaultTo(false); table.timestamps(); }); } setup(); ``` ## Step 4: Defining Models Models are the heart of Sutando. Each model corresponds to a database table: ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; // Type casts casts = { is_admin: 'boolean', metadata: 'json', }; // Relationships relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; casts = { published: 'boolean', }; relationUser() { return this.belongsTo(User, 'user_id'); } // Scope scopePublished(query) { return query.where('published', true); } } export { User, Post }; ``` ## Step 5: Basic CRUD Operations ### Create ```ts // Create a new user const user = new User(); user.name = 'Alice'; user.email = 'alice@example.com'; user.password = 'hashed_password'; await user.save(); // Or use the create method const post = await Post.create({ title: 'My First Post', content: 'Hello World!', user_id: user.id, published: true, }); ``` ### Read ```ts // Get all posts const posts = await Post.query().get(); // Find by ID const post = await Post.find(1); // Query with conditions const published = await Post.query() .where('published', true) .orderBy('created_at', 'desc') .limit(10) .get(); // Find first matching const first = await Post.query().where('title', 'My First Post').first(); // Count const count = await Post.query().where('published', true).count(); ``` ### Update ```ts const post = await Post.find(1); post.title = 'Updated Title'; post.published = true; await post.save(); // Bulk update await Post.query().where('user_id', 1).update({ published: true }); ``` ### Delete ```ts const post = await Post.find(1); await post.delete(); // Bulk delete await Post.query().where('published', false).delete(); ``` ## Step 6: Working with Relationships ### Eager Loading ```ts // Load user with their posts (2 queries total) const user = await User.query().with('posts').find(1); console.log(user.posts); // array of Post models // Nested eager loading const users = await User.query() .with('posts.comments') .get(); ``` ### Creating Related Records ```ts const user = await User.find(1); const post = await user.posts().create({ title: 'New Post', content: 'Content here', }); ``` ### Querying Relationships ```ts const user = await User.find(1); const publishedPosts = await user.posts().where('published', true).get(); ``` ## Step 7: Putting It All Together Here's a complete Express API: ```ts import express from 'express'; import './database'; import { User, Post } from './models'; const app = express(); app.use(express.json()); // List published posts app.get('/posts', async (req, res) => { const posts = await Post.query() .with('user') .published() .orderBy('created_at', 'desc') .limit(20) .get(); res.json(posts); }); // Get single post app.get('/posts/:id', async (req, res) => { const post = await Post.query() .with('user', 'comments') .find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); res.json(post); }); // Create post app.post('/posts', async (req, res) => { const post = await Post.create(req.body); res.status(201).json(post); }); // Update post app.put('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); post.fill(req.body); await post.save(); res.json(post); }); // Delete post app.delete('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); await post.delete(); res.json({ success: true }); }); app.listen(3000, () => console.log('Server running on port 3000')); ``` ## Next Steps * [Model Relationships](https://sutando.org/guide/relationships.html) — master `hasMany`, `belongsTo`, and more * [Database Migrations](https://sutando.org/guide/migrations.html) — version-control your schema * [Model Events](https://sutando.org/guide/events.html) — hook into the model lifecycle You now have a working Node.js app with Sutando. The full documentation is at [sutando.org](https://sutando.org/guide/getting-started.html). --- --- url: /ja/blog/posts/migrating-from-laravel-to-nodejs-with-sutando.md --- Laravel 開発者として、Node.js への移行を検討しているなら、Sutando は最もスムーズな移行パスを提供します。このガイドでは、Eloquent から Sutando への移行を詳しく説明します。 ## なぜ Sutando なのか? Sutando は Laravel Eloquent の API を直接参考にして作られています。Eloquent の概念がそのまま使えます: * Active Record パターン * モデルリレーション(`hasMany`, `belongsTo` など) * クエリスコープ * モデルイベント(フック) * ソフトデリート * タイプキャスト * コレクション ## クイックマッピング | Laravel Eloquent | Sutando | |------------------|---------| | `Model::find($id)` | `Model.find(id)` | | `Model::where('x', 'y')->get()` | `Model.query().where('x', 'y').get()` | | `$model->hasMany(Post::class)` | `this.hasMany(Post, 'user_id')` | | `Model::creating(fn)` | `Model.creating(fn)` | | `SoftDeletes` trait | `SoftDeletes` trait | ## モデルの移行 ```ts // Laravel: app/Models/User.php class User extends Authenticatable { use HasFactory, SoftDeletes; protected $table = 'users'; protected $casts = ['is_admin' => 'boolean']; protected $hidden = ['password']; public function posts() { return $this->hasMany(Post::class); } } // Sutando: models/User.ts import { Model, SoftDeletes } from 'sutando'; class User extends Model { use = [SoftDeletes]; table = 'users'; casts = { is_admin: 'boolean' }; hidden = ['password']; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ## クエリの移行 ```ts // Laravel $posts = Post::with('user', 'comments') ->where('published', true) ->orderBy('created_at', 'desc') ->paginate(15); // Sutando const posts = await Post.query() .with('user', 'comments') .where('published', true) .orderBy('created_at', 'desc') .paginate(15); ``` ## リレーションの移行 ```ts // Laravel: 1対多 public function posts() { return $this->hasMany(Post::class); } // Sutando relationPosts() { return this.hasMany(Post, 'user_id'); } ``` ```ts // Laravel: 多対多 public function roles() { return $this->belongsToMany(Role::class); } // Sutando relationRoles() { return this.belongsToMany(Role, 'role_user', 'user_id', 'role_id'); } ``` ## モデルイベントの移行 ```ts // Laravel protected static function booted() { static::creating(function ($post) { $post->slug = Str::slug($post->title); }); } // Sutando Post.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); ``` ## 実践例:ブログの移行 ```ts import { sutando, Model } from 'sutando'; import express from 'express'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', user: 'root', password: '', database: 'blog' } }); class User extends Model { table = 'users'; hidden = ['password']; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; relationUser() { return this.belongsTo(User, 'user_id'); } relationComments() { return this.hasMany(Comment); } } class Comment extends Model { table = 'comments'; relationUser() { return this.belongsTo(User, 'user_id'); } } const app = express(); app.use(express.json()); app.get('/posts', async (req, res) => { const posts = await Post.query() .with('user', 'comments') .where('published', true) .orderBy('created_at', 'desc') .paginate(15); res.json(posts); }); app.get('/posts/:id', async (req, res) => { const post = await Post.query() .with('user', 'comments') .find(req.params.id); res.json(post); }); app.listen(3000); ``` ## 移行のヒント 1. **段階的に移行** — すべて一度に移行する必要はありません 2. **読み取り専用 API から始める** — GET ルートを先に移行し、検証してから書き込み操作へ 3. **マイグレーションは既存のものを維持** — Sutando のスキーマビルダーは Laravel と互換性があります ## 結論 Laravel 開発者にとって、Sutando は Node.js への最も自然な移行パスです。Eloquent の概念がそのまま使えるため、学習コストが最小限に抑えられます。 [ドキュメント](https://sutando.org/ja/guide/getting-started.html) を読んで始めましょう。 --- --- url: /blog/posts/migrating-from-laravel-to-nodejs-with-sutando.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** If you're a Laravel developer considering a move to Node.js, the biggest concern is usually leaving Eloquent behind. With Sutando, you don't have to. This guide walks through migrating a Laravel application to Node.js, mapping each concept from Eloquent to Sutando. ## Why Migrate from Laravel to Node.js? Common reasons include: * **Real-time features**: WebSocket, SSE, streaming — Node.js's event loop handles these natively * **Full-stack TypeScript**: Share types between frontend and backend * **Serverless/Edge**: Deploy to Cloudflare Workers, Vercel Edge, or AWS Lambda * **Team skills**: Your team knows JavaScript better than PHP * **Ecosystem**: npm's ecosystem is larger than Composer's for certain domains ## Concept Mapping: Laravel → Node.js + Sutando | Laravel | Node.js + Sutando | |---------|-------------------| | Eloquent Model | Sutando Model class | | Migration | Sutando Schema Builder | | Seeder | Sutando Seeder | | Factory | Sutando Factory | | Route | Express / Fastify route | | Middleware | Express middleware | | Blade | React / Vue / API-only | | Service Container | Manual DI / framework-specific | | Artisan CLI | Custom npm scripts | ## Eloquent Model → Sutando Model ### Laravel (PHP) ```php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class User extends Model { protected $table = 'users'; protected $casts = [ 'is_admin' => 'boolean', 'metadata' => 'array', ]; protected $fillable = ['name', 'email', 'password']; use SoftDeletes; public function posts(): HasMany { return $this->hasMany(Post::class); } public function scopeActive($query) { return $query->where('active', true); } } ``` ### Sutando (TypeScript) ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; casts = { is_admin: 'boolean', metadata: 'json', }; fillable = ['name', 'email', 'password']; relationPosts() { return this.hasMany(Post, 'user_id'); } scopeActive(query) { return query.where('active', true); } } ``` The mapping is nearly 1:1. The main differences: * `protected $table` → `table =` * `protected $casts` → `casts =` * `$fillable` → `fillable =` * Relationship methods use `relation` prefix * Scopes use `scope` prefix * No `use` statements for traits — soft deletes are configured differently ## Query Comparison ### Basic Query ```php // Laravel $users = User::where('active', true) ->orderBy('created_at', 'desc') ->limit(10) ->get(); ``` ```ts // Sutando const users = await User.query() .where('active', true) .orderBy('created_at', 'desc') .limit(10) .get(); ``` ### Eager Loading ```php // Laravel $users = User::with('posts.comments')->get(); ``` ```ts // Sutando const users = await User.query().with('posts.comments').get(); ``` ### Create ```php // Laravel $user = User::create(['name' => 'Alice', 'email' => 'alice@example.com']); ``` ```ts // Sutando const user = await User.create({ name: 'Alice', email: 'alice@example.com' }); ``` ### Update ```php // Laravel $user = User::find(1); $user->name = 'Bob'; $user->save(); ``` ```ts // Sutando const user = await User.find(1); user.name = 'Bob'; await user.save(); ``` ### Model Events ```php // Laravel User::creating(function ($user) { $user->password = bcrypt($user->password); }); ``` ```ts // Sutando User.creating(async (user) => { user.password = await bcrypt.hash(user.password, 10); }); ``` ## Migration: Laravel → Sutando Schema Builder ### Laravel Migration ```php Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->string('password'); $table->boolean('active')->default(true); $table->timestamps(); }); ``` ### Sutando Schema Builder ```ts await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name'); table.string('email').unique(); table.string('password'); table.boolean('active').defaultTo(true); table.timestamps(); }); ``` ## Routes: Laravel → Express ### Laravel ```php Route::get('/posts', [PostController::class, 'index']); Route::post('/posts', [PostController::class, 'store']); Route::get('/posts/{id}', [PostController::class, 'show']); ``` ### Express ```ts app.get('/posts', async (req, res) => { const posts = await Post.query().with('user').get(); res.json(posts); }); app.post('/posts', async (req, res) => { const post = await Post.create(req.body); res.status(201).json(post); }); app.get('/posts/:id', async (req, res) => { const post = await Post.query().with('user', 'comments').find(req.params.id); res.json(post); }); ``` ## Practical Example: Migrating a Blog Let's say you have a Laravel blog with User, Post, and Comment models. Here's the full Sutando equivalent: ```ts import { sutando, Model } from 'sutando'; import express from 'express'; // Database setup sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', user: 'root', password: '', database: 'blog' } }); // Models class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } relationComments() { return this.hasMany(Comment, 'user_id'); } } class Post extends Model { table = 'posts'; casts = { published: 'boolean' }; relationUser() { return this.belongsTo(User, 'user_id'); } relationComments() { return this.hasMany(Comment, 'post_id'); } scopePublished(query) { return query.where('published', true); } } class Comment extends Model { table = 'comments'; relationUser() { return this.belongsTo(User, 'user_id'); } relationPost() { return this.belongsTo(Post, 'post_id'); } } // Express app const app = express(); app.use(express.json()); app.get('/posts', async (req, res) => { const posts = await Post.query() .with('user', 'comments.user') .published() .orderBy('created_at', 'desc') .limit(20) .get(); res.json(posts); }); app.post('/posts', async (req, res) => { const post = await Post.create({ ...req.body, published: false, }); res.status(201).json(post); }); app.listen(3000); ``` ## What Doesn't Map Directly Some Laravel features need manual implementation in Node.js: * **Authentication**: Laravel's built-in auth → use JWT, Passport.js, or custom middleware * **Validation**: Laravel's FormRequest → use Zod, Joi, or express-validator * **Queue/Jobs**: Laravel Queue → use BullMQ, or Cloudflare Queues * **Mail**: Laravel Mail → use Nodemailer, or Cloudflare Email * **Artisan commands**: → custom npm scripts or a CLI framework like Commander ## Migration Strategy 1. **Keep the same database** — Sutando works with existing MySQL/PostgreSQL schemas 2. **Migrate route by route** — use Nginx as a reverse proxy to split traffic 3. **Start with read-only routes** — they're lowest risk 4. **Keep sessions in Redis** — both PHP and Node can read from Redis 5. **Don't rewrite everything at once** — the strangler fig pattern works well ## Conclusion Migrating from Laravel to Node.js doesn't mean abandoning Eloquent. Sutando brings the same Active Record experience to Node.js with nearly identical API. You can keep your database schema, migrate routes incrementally, and your team can be productive from day one. Start with `npm install sutando` and check the [documentation](https://sutando.org/guide/getting-started.html). --- --- url: /blog/posts/model-relationships-in-sutando-hasmany-belongsto-and-beyond.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Relationships are the heart of any ORM. Sutando brings Laravel Eloquent's relationship system to Node.js, making it easy to define and query complex data relationships. This guide covers every relationship type with practical examples. ## Defining Relationships In Sutando, relationships are defined as methods on your model with a `relation` prefix: ```ts class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ## One-to-Many: hasMany A user has many posts: ```ts class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; relationUser() { return this.belongsTo(User, 'user_id'); } } ``` ### Querying ```ts // Eager load const user = await User.query().with('posts').find(1); console.log(user.posts); // Post[] // Query the relationship const user = await User.find(1); const publishedPosts = await user.posts().where('published', true).get(); // Create related const post = await user.posts().create({ title: 'New Post', content: 'Hello!', }); // Save related const post = new Post(); post.title = 'New Post'; await user.posts().save(post); ``` ## One-to-One: hasOne A user has one profile: ```ts class User extends Model { table = 'users'; relationProfile() { return this.hasOne(Profile, 'user_id'); } } class Profile extends Model { table = 'profiles'; relationUser() { return this.belongsTo(User, 'user_id'); } } ``` ```ts const user = await User.query().with('profile').find(1); console.log(user.profile); // Profile ``` ## Many-to-Many: belongsToMany A user belongs to many roles through a pivot table: ```ts class User extends Model { table = 'users'; relationRoles() { return this.belongsToMany(Role, 'user_roles', 'user_id', 'role_id'); } } class Role extends Model { table = 'roles'; relationUsers() { return this.belongsToMany(User, 'user_roles', 'role_id', 'user_id'); } } ``` ```ts // Get user with roles const user = await User.query().with('roles').find(1); console.log(user.roles); // Role[] // Attach / detach await user.roles().attach([1, 2, 3]); // attach role IDs await user.roles().detach([2]); // detach role ID 2 await user.roles().sync([1, 3]); // sync: detach all others, attach these // With pivot data await user.roles().attach(1, { expires_at: '2026-12-31' }); ``` ## Polymorphic Relationships A tag can belong to either a Post or a Video: ```ts class Tag extends Model { table = 'tags'; relationTaggable() { return this.morphTo(); } } class Post extends Model { table = 'posts'; relationTags() { return this.morphMany(Tag, 'taggable'); } } class Video extends Model { table = 'videos'; relationTags() { return this.morphMany(Tag, 'taggable'); } } ``` ```ts // Add tags to a post await post.tags().create({ name: 'laravel' }); await post.tags().create({ name: 'nodejs' }); // Get tags with their taggable parent const tags = await Tag.query().with('taggable').get(); ``` ## Nested Eager Loading Load relationships across multiple levels: ```ts // Load user → posts → comments → user const users = await User.query() .with('posts.comments.user') .get(); // Load specific relationships at each level const users = await User.query() .with({ posts: query => query.with('comments.user').where('published', true) }) .get(); ``` ## Preventing N+1 Queries The N+1 problem is the most common ORM performance issue. Sutando solves it with eager loading: ```ts // BAD: N+1 (1 + N queries) const users = await User.query().get(); for (const user of users) { console.log(await user.posts); // 1 query per user } // GOOD: 2 queries total const users = await User.query().with('posts').get(); for (const user of users) { console.log(user.posts); // already loaded } ``` ## Relationship Methods Summary | Method | Use Case | Example | |--------|---------|---------| | `hasMany` | One-to-Many | User → Posts | | `belongsTo` | Inverse One-to-Many | Post → User | | `hasOne` | One-to-One | User → Profile | | `belongsToMany` | Many-to-Many | User ↔ Roles | | `morphMany` | Polymorphic One-to-Many | Post/Video → Tags | | `morphTo` | Polymorphic Inverse | Tag → Post/Video | ## Conclusion Sutando's relationship system mirrors Eloquent's, making complex data relationships simple to define and query. With eager loading, you avoid N+1 problems while keeping your code clean and readable. Learn more in the [Sutando documentation](https://sutando.org/guide/relationships.html). --- --- url: /ja/blog/posts/nodejs-active-record-orm-complete-guide.md --- Node.js で Active Record パターンの ORM を探しているなら、このガイドはあなたのためのものです。Active Record の基本から Sutando を使った実践的な使い方まで、すべてカバーします。 ## Active Record パターンとは? Active Record は、データベースの各行をモデルオブジェクトとして表現するデザインパターンです。モデルインスタンスは一行のデータを保持し、保存、更新、削除などの操作を直接行えます。 ```ts // Active Record の例 const user = new User(); user.name = '山田太郎'; user.email = 'yamada@example.com'; await user.save(); // データベースに保存 ``` 対照的に、Data Mapper パターン(Prisma など)では、エンティティとマッパーが分離されています: ```ts // Data Mapper の例(Prisma) const user = await prisma.user.create({ data: { name: '山田太郎', email: 'yamada@example.com' } }); ``` Active Record の利点はシンプルさと直感性です。Laravel の Eloquent や Ruby on Rails の ActiveRecord がこのパターンを採用しています。 ## なぜ Node.js で Active Record なのか? Node.js エコシステムには多くの ORM がありますが、Active Record パターンをちゃんと実装しているものは少ないです: * **Prisma** — Data Mapper パターン、スキーマファースト * **Drizzle** — SQL ファースト、軽量 * **TypeORM** — Active Record をサポートしているが、デコレーター依存とメンテナンス問題あり * **Sutando** — Active Record パターン、Eloquent ライクな API Sutando は、Laravel Eloquent の開発体験を Node.js にもたらすために作られました。 ## Sutando の主な機能 ### 1. シンプルなモデル定義 デコレーター不要、プレーンなクラスプロパティでモデルを定義: ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; casts = { is_admin: 'boolean', metadata: 'json', }; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ### 2. 直感的なクエリビルダー メソッドチェーンでクエリを構築: ```ts // 公開済みの記事を最新順で取得 const posts = await Post.query() .where('published', true) .orderBy('created_at', 'desc') .limit(10) .get(); // リレーションを事前読み込み(N+1 問題を回避) const users = await User.query().with('posts').get(); ``` ### 3. リレーション管理 hasMany、belongsTo、hasOne、belongsToMany など、一般的なリレーションタイプをサポート: ```ts class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; relationUser() { return this.belongsTo(User, 'user_id'); } } // 使用例 const user = await User.find(1); const posts = await user.posts; // ユーザーの記事を取得 ``` ### 4. ソフトデリート `deleted_at` カラムを追加するだけで、論理削除が有効になります: ```ts import { Model, SoftDeletes } from 'sutando'; class Post extends Model { use = [SoftDeletes]; table = 'posts'; } await post.delete(); // ソフトデリート(deleted_at に日付を設定) await post.forceDelete(); // 完全に削除 ``` ### 5. モデルイベント(フック) モデルのライフサイクルにフックできます: ```ts Post.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); Post.updating(async (post) => { post.updated_at = new Date(); }); ``` ### 6. クエリスコープ 再利用可能なクエリ条件を定義: ```ts class Post extends Model { scopePublished(query) { return query.where('published', true); } } // 使用例 const posts = await Post.query().published().get(); ``` ## 実践例:ブログ API ### セットアップ ```bash npm install sutando mysql2 express ``` ### データベース設定 ```ts import { sutando } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', port: 3306, user: 'root', password: '', database: 'blog' } }); ``` ### モデル定義 ```ts import { Model } from 'sutando'; class Post extends Model { table = 'posts'; relationAuthor() { return this.belongsTo(User, 'user_id'); } relationComments() { return this.hasMany(Comment); } } class Comment extends Model { table = 'comments'; relationPost() { return this.belongsTo(Post); } } class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ### CRUD 操作 ```ts import express from 'express'; const app = express(); app.use(express.json()); // 作成 app.post('/posts', async (req, res) => { const post = new Post(); post.title = req.body.title; post.content = req.body.content; post.user_id = req.body.userId; await post.save(); res.json(post); }); // 一覧取得(リレーション事前読み込み) app.get('/posts', async (req, res) => { const posts = await Post.query() .with('author', 'comments') .orderBy('created_at', 'desc') .limit(20) .get(); res.json(posts); }); // 個別取得 app.get('/posts/:id', async (req, res) => { const post = await Post.query().with('author', 'comments').find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); res.json(post); }); // 更新 app.put('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); post.title = req.body.title; post.content = req.body.content; await post.save(); res.json(post); }); // 削除 app.delete('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); await post.delete(); res.json({ success: true }); }); app.listen(3000); ``` ## まとめ Active Record パターンは、特に Laravel や Rails の経験がある開発者にとって、最も直感的な ORM パターンです。Sutando はこのパターンを Node.js でもたらし、デコレーター不要のクリーンな API と豊富な機能を提供します。 次のステップ: * [スタートガイド](https://sutando.org/ja/guide/getting-started.html) * [モデルリレーション](https://sutando.org/ja/guide/relationships.html) * [マイグレーション](https://sutando.org/ja/guide/migrations.html) --- --- url: /zh_CN/blog/posts/nodejs-active-record-orm-complete-guide.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Active Record 是最直观的数据库交互模式之一。如果你用过 Laravel 的 Eloquent 或 Ruby on Rails,你就已经知道它有多高效。在本指南中,我们将探讨什么是 Active Record、它与其他 ORM 模式的区别,以及如何在 Node.js 中使用 Sutando 来实践这一模式。 ## 什么是 Active Record 模式? Active Record 是一种设计模式,每个模型类对应一张数据库表,每个实例代表一行记录。模型本身负责持久化——你直接在实例上调用 `save()`、`delete()`、`update()` 等方法。 ```ts const user = new User(); user.name = 'Alice'; user.email = 'alice@example.com'; await user.save(); // INSERT INTO users ... ``` 这与 Data Mapper 模式(如 Prisma、TypeORM 的 EntityRepository 或 Java 的 Hibernate)有本质区别——后者由独立的映射器/仓库层来处理数据库操作: ```ts // Data Mapper 风格(如 Prisma) const user = await prisma.user.create({ data: { name: 'Alice', email: 'alice@example.com' } }); ``` ### Active Record vs Data Mapper:哪个更好? 没有绝对的"更好",各有取舍: | 维度 | Active Record | Data Mapper | |--------|--------------|-------------| | 简洁性 | 高——方法直接在模型上 | 中等——需要独立的仓库层 | | 可测试性 | 好——可以 mock 模型方法 | 优秀——可以注入仓库 | | 耦合度 | 模型知道数据库的存在 | 模型不感知持久化层 | | 学习曲线 | 低,适合初学者 | 较高——概念更多 | | 最适合 | 快速开发、CRUD 应用 | 复杂领域、企业级应用 | 对于大多数 Node.js 项目——尤其是 API、SaaS 产品和原型开发——Active Record 的简洁性是巨大优势。你写的代码更少,迭代更快,认知负担更低。 ## Node.js 中的 Active Record 实现 Node.js 生态有几个 Active Record 实现: ### 1. Sutando ORM [Sutando](https://sutando.org/zh_CN/guide/getting-started.html) 是 Laravel Eloquent 在 Node.js 中最忠实的移植。它支持 MySQL、PostgreSQL 和 SQLite,功能包括: * 流畅的查询构造器,支持 `where`、`orderBy`、`with`(预加载) * 模型关联:`hasMany`、`belongsTo`、`hasOne`、`morphTo` * 软删除、模型事件/钩子、全局作用域 * 数据库迁移和 Schema 构造器 * 工厂和种子用于测试 ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post); } } // 查询 const users = await User.query().where('active', true).get(); // 创建 const user = new User(); user.name = 'Alice'; await user.save(); // 关联 const user = await User.query().with('posts').find(1); ``` ### 2. AdonisJS Lucid Lucid 是 AdonisJS 框架内置的 ORM,同样是 Active Record 风格,也受 Eloquent 启发。但它与 AdonisJS 生态紧密耦合——无法在独立的 Express 或 Fastify 项目中使用。 ### 3. TypeORM(Active Record 模式) TypeORM 同时支持 Active Record 和 Data Mapper 两种模式。在 Active Record 模式下,模型继承 `BaseEntity`: ```ts import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from 'typeorm'; @Entity() class User extends BaseEntity { @PrimaryGeneratedColumn() id: number; @Column() name: string; } const user = new User(); user.name = 'Alice'; await user.save(); ``` TypeORM 可以用,但它严重依赖装饰器,而且因维护问题和 TypeScript 装饰器的不稳定性受到批评。 ## 用 Active Record 构建 CRUD 应用 让我们用 Sutando 构建一个简单的博客 API,演示 Active Record 的实际用法。 ### 安装 ```bash npm install sutando mysql2 express ``` ### 数据库配置 ```ts import { sutando } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', port: 3306, user: 'root', password: '', database: 'blog' } }); ``` ### 定义模型 ```ts import { Model } from 'sutando'; class Post extends Model { table = 'posts'; relationAuthor() { return this.belongsTo(User, 'user_id'); } relationComments() { return this.hasMany(Comment); } } class Comment extends Model { table = 'comments'; relationPost() { return this.belongsTo(Post); } } class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ### CRUD 操作 ```ts import express from 'express'; const app = express(); app.use(express.json()); // 创建 app.post('/posts', async (req, res) => { const post = new Post(); post.title = req.body.title; post.content = req.body.content; post.user_id = req.body.userId; await post.save(); res.json(post); }); // 查询列表(带预加载) app.get('/posts', async (req, res) => { const posts = await Post.query() .with('author', 'comments') .orderBy('created_at', 'desc') .limit(20) .get(); res.json(posts); }); // 查询单条 app.get('/posts/:id', async (req, res) => { const post = await Post.query().with('author', 'comments').find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); res.json(post); }); // 更新 app.put('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); post.title = req.body.title; post.content = req.body.content; await post.save(); res.json(post); }); // 删除 app.delete('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); await post.delete(); res.json({ success: true }); }); app.listen(3000); ``` ## 高级 Active Record 功能 ### 预加载 Active Record 最强大的功能之一是预加载——在单次查询中加载关联,避免 N+1 问题: ```ts // 不好:N+1 查询 const users = await User.query().get(); for (const user of users) { const posts = await user.posts; // 每个用户一次查询 } // 好:总共 2 次查询 const users = await User.query().with('posts').get(); ``` ### 查询作用域 作用域让你定义可复用的查询约束: ```ts class Post extends Model { scopePublished(query) { return query.where('published', true); } } // 使用 const posts = await Post.query().published().get(); ``` ### 模型事件 在模型生命周期中插入自定义逻辑: ```ts Post.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); Post.deleting(async (post) => { await post.comments.delete(); // 级联删除 }); ``` ## 何时选择 Active Record **Active Record 适合以下场景:** * 你希望快速开发,尽量少写样板代码 * 你的应用以 CRUD 为主 * 你从 Laravel、Rails 等框架迁移过来 * 你重视代码的可读性和自文档化 * 你在构建原型或 MVP **考虑 Data Mapper 的场景:** * 你有复杂的领域逻辑,需要与持久化层解耦 * 你在构建大型企业应用,需要严格关注点分离 * 你需要轻松替换持久化层 ## 总结 Active Record 仍然是 Node.js 中最高产的数据库交互模式之一。通过 Sutando,你可以获得忠实的 Eloquent 风格体验——框架无关、轻量且直观。无论你是从 Laravel 迁移还是开始新的 Node.js 项目,Active Record + Sutando 都能让你专注于业务逻辑,而不是与 ORM 搏斗。 准备好试试了吗?查看 [Sutando 文档](https://sutando.org/zh_CN/guide/getting-started.html) 或运行 `npm install sutando` 立即开始。 --- --- url: /blog/posts/nodejs-active-record-orm-complete-guide.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** The Active Record pattern is one of the most intuitive ways to interact with a database. If you've ever used Laravel's Eloquent or Ruby on Rails, you already know how productive it can be. In this guide, we'll explore what Active Record is, how it differs from other ORM patterns, and how to use it in Node.js with Sutando. ## What Is the Active Record Pattern? Active Record is a design pattern where each model class corresponds to a database table, and each instance represents a row. The model itself handles persistence — you call methods like `save()`, `delete()`, and `update()` directly on the instance. ```ts const user = new User(); user.name = 'Alice'; user.email = 'alice@example.com'; await user.save(); // INSERT INTO users ... ``` This is fundamentally different from the Data Mapper pattern (used by Prisma, TypeORM's EntityRepository, or Java's Hibernate), where a separate mapper/repository handles database operations: ```ts // Data Mapper style (e.g., Prisma) const user = await prisma.user.create({ data: { name: 'Alice', email: 'alice@example.com' } }); ``` ### Active Record vs Data Mapper: Which Is Better? Neither is universally "better." Each has trade-offs: | Aspect | Active Record | Data Mapper | |--------|--------------|-------------| | Simplicity | High — methods on the model | Medium — separate repository layer | | Testability | Good — mock model methods | Excellent — inject repositories | | Coupling | Model knows about DB | Model is persistence-ignorant | | Learning curve | Low for beginners | Higher — more concepts | | Best for | Rapid development, CRUD apps | Complex domains, enterprise apps | For most Node.js projects — especially APIs, SaaS products, and prototyping — Active Record's simplicity wins. You write less code, iterate faster, and the cognitive overhead is minimal. ## Active Record Implementations in Node.js The Node.js ecosystem has several Active Record implementations: ### 1. Sutando ORM [Sutando](https://sutando.org) is the most faithful port of Laravel's Eloquent to Node.js. It supports MySQL, PostgreSQL, and SQLite, with features like: * Fluent query builder with `where`, `orderBy`, `with` (eager loading) * Model relationships: `hasMany`, `belongsTo`, `hasOne`, `morphTo` * Soft deletes, model events/hooks, and global scopes * Database migrations and schema builder * Factories and seeders for testing ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post); } } // Query const users = await User.query().where('active', true).get(); // Create const user = new User(); user.name = 'Alice'; await user.save(); // Relationships const user = await User.query().with('posts').find(1); ``` ### 2. AdonisJS Lucid Lucid is the built-in ORM for the AdonisJS framework. It's also Active Record and inspired by Eloquent. However, it's tightly coupled to the AdonisJS ecosystem — you can't use it in a standalone Express or Fastify project. ### 3. TypeORM (Active Record mode) TypeORM supports both Active Record and Data Mapper patterns. In Active Record mode, models extend a base `BaseEntity` class: ```ts import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from 'typeorm'; @Entity() class User extends BaseEntity { @PrimaryGeneratedColumn() id: number; @Column() name: string; } const user = new User(); user.name = 'Alice'; await user.save(); ``` TypeORM works, but it relies heavily on decorators and has faced criticism for maintenance issues and TypeScript decorator instability. ## Building a CRUD App with Active Record Let's build a simple blog API using Sutando to demonstrate Active Record in action. ### Setup ```bash npm install sutando mysql2 express ``` ### Database Configuration ```ts import { sutando } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', port: 3306, user: 'root', password: '', database: 'blog' } }); ``` ### Define Models ```ts import { Model } from 'sutando'; class Post extends Model { table = 'posts'; relationAuthor() { return this.belongsTo(User, 'user_id'); } relationComments() { return this.hasMany(Comment); } } class Comment extends Model { table = 'comments'; relationPost() { return this.belongsTo(Post); } } class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ### CRUD Operations ```ts import express from 'express'; const app = express(); app.use(express.json()); // Create app.post('/posts', async (req, res) => { const post = new Post(); post.title = req.body.title; post.content = req.body.content; post.user_id = req.body.userId; await post.save(); res.json(post); }); // Read (with eager loading) app.get('/posts', async (req, res) => { const posts = await Post.query() .with('author', 'comments') .orderBy('created_at', 'desc') .limit(20) .get(); res.json(posts); }); // Read single app.get('/posts/:id', async (req, res) => { const post = await Post.query().with('author', 'comments').find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); res.json(post); }); // Update app.put('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); post.title = req.body.title; post.content = req.body.content; await post.save(); res.json(post); }); // Delete app.delete('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); await post.delete(); res.json({ success: true }); }); app.listen(3000); ``` ## Advanced Active Record Features ### Eager Loading One of the most powerful Active Record features is eager loading — loading relationships in a single query to avoid N+1 problems: ```ts // Bad: N+1 queries const users = await User.query().get(); for (const user of users) { const posts = await user.posts; // 1 query per user } // Good: 2 queries total const users = await User.query().with('posts').get(); ``` ### Scopes Scopes let you define reusable query constraints: ```ts class Post extends Model { scopePublished(query) { return query.where('published', true); } } // Usage const posts = await Post.query().published().get(); ``` ### Model Events Hook into the model lifecycle to run custom logic: ```ts Post.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); Post.deleting(async (post) => { await post.comments.delete(); // cascade delete }); ``` ## When to Choose Active Record **Active Record is a great fit when:** * You want rapid development with minimal boilerplate * Your app is primarily CRUD-oriented * You're coming from Laravel, Rails, or similar frameworks * You value readable, self-documenting code * You're building prototypes or MVPs **Consider Data Mapper when:** * You have complex domain logic that needs to be persistence-ignorant * You're building a large enterprise application with strict separation of concerns * You need to swap out persistence layers easily ## Conclusion Active Record remains one of the most productive patterns for database interaction in Node.js. With Sutando, you get a faithful Eloquent-style experience that's framework-agnostic, lightweight, and intuitive. Whether you're migrating from Laravel or just starting a new Node.js project, Active Record with Sutando lets you focus on your application logic rather than fighting your ORM. Ready to try it? Check out the [Sutando documentation](https://sutando.org/guide/getting-started.html) or run `npm install sutando` to get started. --- --- url: /zh_CN/blog/posts/nodejs-orm-beginner-tutorial-build-first-app-with-sutando.md --- 如果你刚开始接触 Node.js 的数据库操作,这篇教程就是为你准备的。我们将从零开始,用 Sutando 构建一个完整的应用,涵盖安装、配置、模型定义、CRUD 操作和关联查询。 ## 什么是 ORM? ORM(对象关系映射)让你用代码对象操作数据库,而不是直接写 SQL。比如,不用写 `SELECT * FROM users WHERE active = 1`,而是写: ```ts const users = await User.query().where('active', true).get(); ``` Sutando 使用 Active Record 模式,每个模型类对应一张表,每个实例代表一行记录。 ## 第 1 步:安装 ```bash mkdir my-app && cd my-app npm init -y npm install sutando mysql2 ``` ## 第 2 步:连接数据库 创建 `db.ts`: ```ts import { sutando } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', port: 3306, user: 'root', password: '你的密码', database: 'my_app' } }); export default sutando; ``` ## 第 3 步:创建表 用 Schema 构造器创建表: ```ts import sutando from './db'; async function setup() { await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('email').notNullable().unique(); table.timestamps(); }); await sutando.schema().createTable('posts', table => { table.increments('id').primary(); table.string('title').notNullable(); table.text('content'); table.integer('user_id').unsigned().references('id').inTable('users'); table.boolean('published').defaultTo(false); table.timestamps(); }); console.log('表创建成功'); } setup(); ``` 运行 `npx tsx db.ts` 创建表。 ## 第 4 步:定义模型 创建 `models.ts`: ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; // 定义关联:一个用户有多篇文章 relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; // 类型转换 casts = { published: 'boolean', }; // 定义关联:一篇文章属于一个用户 relationUser() { return this.belongsTo(User, 'user_id'); } // 查询作用域:只查已发布的 scopePublished(query) { return query.where('published', true); } } export { User, Post }; ``` ## 第 5 步:CRUD 操作 ### 创建 ```ts import { User, Post } from './models'; // 方式一:实例化后保存 const user = new User(); user.name = '张三'; user.email = 'zhangsan@example.com'; await user.save(); // 方式二:用 create 方法 const post = await Post.create({ title: '我的第一篇文章', content: 'Hello World!', user_id: user.id, published: true, }); ``` ### 查询 ```ts // 查询所有 const allPosts = await Post.query().get(); // 条件查询 const published = await Post.query() .where('published', true) .orderBy('created_at', 'desc') .limit(10) .get(); // 按 ID 查找 const post = await Post.find(1); // 使用作用域 const publishedPosts = await Post.query().published().get(); // 预加载关联(避免 N+1 问题) const users = await User.query().with('posts').get(); // users[0].posts → 该用户的所有文章 ``` ### 更新 ```ts const post = await Post.find(1); post.title = '更新后的标题'; post.published = true; await post.save(); // 批量更新 await Post.query().where('user_id', 1).update({ published: true }); ``` ### 删除 ```ts const post = await Post.find(1); await post.delete(); // 批量删除 await Post.query().where('published', false).delete(); ``` ## 第 6 步:关联操作 ### 创建关联记录 ```ts const user = await User.find(1); const post = await user.posts().create({ title: '新文章', content: '内容', }); ``` ### 查询关联 ```ts const user = await User.find(1); const publishedPosts = await user.posts().where('published', true).get(); ``` ### 嵌套预加载 ```ts // 一次查询加载用户 → 文章 → 评论 → 评论作者 const users = await User.query() .with('posts.comments.user') .get(); ``` ## 第 7 步:完整 API 示例 ```ts import express from 'express'; import './db'; import { User, Post } from './models'; const app = express(); app.use(express.json()); // 文章列表 app.get('/posts', async (req, res) => { const posts = await Post.query() .with('user') .published() .orderBy('created_at', 'desc') .limit(20) .get(); res.json(posts); }); // 文章详情 app.get('/posts/:id', async (req, res) => { const post = await Post.query().with('user').find(req.params.id); if (!post) return res.status(404).json({ error: '文章不存在' }); res.json(post); }); // 创建文章 app.post('/posts', async (req, res) => { const post = await Post.create(req.body); res.status(201).json(post); }); // 更新文章 app.put('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: '文章不存在' }); post.fill(req.body); await post.save(); res.json(post); }); // 删除文章 app.delete('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: '文章不存在' }); await post.delete(); res.json({ success: true }); }); app.listen(3000, () => console.log('服务器运行在 http://localhost:3000')); ``` ## 常见问题 ### 什么是 N+1 问题? ```ts // 不好:N+1 查询(1 次查用户 + N 次查每个用户的文章) const users = await User.query().get(); for (const user of users) { console.log(await user.posts); // 每个用户一次查询 } // 好:2 次查询 const users = await User.query().with('posts').get(); ``` ### 什么是查询作用域? 作用域是可复用的查询条件,避免到处重复写 `where('published', true)`: ```ts // 定义 scopePublished(query) { return query.where('published', true); } // 使用 const posts = await Post.query().published().get(); ``` ### 什么是模型事件? 在模型创建、更新、删除时自动执行的逻辑: ```ts Post.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); ``` ## 下一步 * [模型关联文档](https://sutando.org/zh_CN/guide/relationships.html) * [数据库迁移文档](https://sutando.org/zh_CN/guide/migrations.html) * [软删除和事件文档](https://sutando.org/zh_CN/guide/soft-deletes.html) 你现在已经有了一个完整的 Sutando 应用。完整文档请访问 [sutando.org](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /ja/blog/posts/modern-alternative-to-objectionjs-migrating-to-sutando.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Objection.js は長年 Node.js エコシステムで人気のある ORM でした。Knex.js の上に構築され、柔軟で SQL に近いデータベース操作を提供してきました。しかし、原作者がメンテナンスを終了し、TypeScript サポートも時代遅れになり、多くのチームがモダンな代替を探しています。このガイドでは、Objection.js から Sutando への移行手順を解説します。 ## Objection.js から移行すべき理由 Objection.js は依然として有用なライブラリですが、以下の理由から移行を検討する価値があります: ### メンテナンス状況 [GitHub ディスカッション](https://github.com/Vincit/objection.js/discussions/2463) で、原作者の Sami Koskimäki がプロジェクトの積極的なメンテナンスを終了することを発表しました: * **アクティブなメンテナー不在**:プロジェクトの bus factor が極めて高く、唯一のメンテナーが離脱 * **新機能なし**:コミュニティの fork のみが更新を受信 * **リリースの遅延**:パッチリリース間に数ヶ月、あるいはそれ以上の間隔 ### TypeScript サポート Objection.js は TypeScript が広く普及する前に設計されました。型システムは機能しますが、手動での型宣言が必要で、モダンな TypeScript ファースト ORM が提供するシームレスな型推論は得られません。 ### 組み込みマイグレーションなし Objection.js はマイグレーションに完全に Knex に依存しています。Knex は堅実ですが、異なる API とメンタルモデルを持つ 2 つのツールを管理する必要があります。 ## なぜ Sutando なのか? Sutando は Laravel の Eloquent にインスピレーションを受けた、モダンな Node.js 向け Active Record ORM です。Objection.js ユーザーが直面する痛点を解決します: | 機能 | Objection.js | Sutando | |------|-------------|---------| | パターン | Query Builder + ORM | Active Record | | TypeScript | 手動型 | ファーストクラス TS サポート | | マイグレーション | Knex 経由(別ツール) | 組み込み schema builder | | メンテナンス | 停滞 | 活発 | | リレーション | `withGraphFetched` | `with()`, `hasMany()`, `belongsTo()` | | ソフトデリート | 手動 | 組み込み | | フック | `$beforeInsert` 等 | `creating`, `saved` 等 | | スコープ | 手動 | 組み込み | ## 概念マッピング:Objection.js → Sutando ### モデル **Objection.js:** ```typescript import { Model } from 'objection'; import knex from 'knex'; const db = knex({ client: 'pg', connection: process.env.DATABASE_URL, }); Model.knex(db); class User extends Model { static tableName = 'users'; static jsonSchema = { type: 'object', required: ['name', 'email'], properties: { id: { type: 'integer' }, name: { type: 'string', minLength: 1, maxLength: 255 }, email: { type: 'string', format: 'email' }, }, }; static relationMappings = { posts: { relation: Model.HasManyRelation, modelClass: Post, join: { from: 'users.id', to: 'posts.user_id' }, }, }; } ``` **Sutando:** ```typescript import { sutando, Model } from 'sutando'; const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); class User extends Model { protected table = 'users'; protected fillable = ['name', 'email']; posts(): HasMany { return this.hasMany(Post); } } ``` ### クエリ **Objection.js:** ```typescript // シンプルなクエリ const users = await User.query().where('age', '>', 18); // リレーション付き const user = await User.query() .withGraphFetched('[posts.comments]') .findById(1); // 挿入 const user = await User.query().insert({ name: 'Alice', email: 'alice@example.com' }); // 更新 await User.query().patch({ name: 'Bob' }).where('id', 1); // 削除 await User.query().deleteById(1); ``` **Sutando:** ```typescript // シンプルなクエリ const users = await User.query().where('age', '>', 18).get(); // リレーション付き const user = await User.query().with('posts.comments').find(1); // 挿入 const user = new User({ name: 'Alice', email: 'alice@example.com' }); await user.save(); // 更新 const user = await User.query().find(1); user.name = 'Bob'; await user.save(); // 削除 const user = await User.query().find(1); await user.delete(); ``` ### リレーション **Objection.js** は `relationMappings` で宣言的オブジェクト構文を使用: ```typescript static relationMappings = { posts: { relation: Model.HasManyRelation, modelClass: Post, join: { from: 'users.id', to: 'posts.user_id' }, }, profile: { relation: Model.BelongsToOneRelation, modelClass: Profile, join: { from: 'users.profile_id', to: 'profiles.id' }, }, }; ``` **Sutando** はメソッドベースの定義を使用、Eloquent に類似: ```typescript posts(): HasMany { return this.hasMany(Post); } profile(): BelongsTo { return this.belongsTo(Profile); } ``` ### トランザクション **Objection.js:** ```typescript await User.transaction(async (trx) => { const user = await User.query(trx).insert({ name: 'Alice' }); await Post.query(trx).insert({ title: 'Hello', user_id: user.id }); }); ``` **Sutando:** ```typescript await db.transaction(async () => { const user = new User({ name: 'Alice' }); await user.save(); const post = new Post({ title: 'Hello', user_id: user.id }); await post.save(); }); ``` ### フック / イベント **Objection.js:** ```typescript class User extends Model { static tableName = 'users'; $beforeInsert() { this.created_at = new Date().toISOString(); } $beforeUpdate() { this.updated_at = new Date().toISOString(); } } ``` **Sutando:** ```typescript import { Model } from 'sutando'; class User extends Model { protected table = 'users'; static creating(model: User) { model.created_at = new Date(); } static updating(model: User) { model.updated_at = new Date(); } } ``` ## ステップバイステップ移行ガイド ### ステップ 1:Sutando のインストール ```bash npm install sutando ``` ### ステップ 2:データベース接続の設定 `db.ts` ファイルを作成: ```typescript import { sutando } from 'sutando'; export const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); export default db; ``` ### ステップ 3:モデルを段階的に変換 すべてを一度に移行する必要はありません。両方の ORM は同じプロジェクトで共存できます: 1. まず最もシンプルなモデル(複雑なリレーションなし)を変換 2. 次に `hasMany` / `belongsTo` リレーションを持つモデルを変換 3. 最後に複雑なグラフ操作を持つモデルを変換 ### ステップ 4:マイグレーションの移行 Knex マイグレーションを使用している場合、Sutando の schema builder は互換性があります: ```typescript import { schema } from 'sutando'; exports.up = function (schema) { return schema.createTable('users', (table) => { table.increments('id'); table.string('name'); table.string('email').unique(); table.timestamps(); }); }; exports.down = function (schema) { return schema.dropTableIfExists('users'); }; ``` ### ステップ 5:クエリの置き換え コードベースを巡回し、Objection.js クエリを Sutando の等価コードに置き換えます。主な変更点: * Objection の `Model.query()` はクエリビルダーを返します;Sutando では `.get()` または `.first()` をチェーンして実行 * `withGraphFetched('[posts.comments]')` が `with('posts.comments')` に * `insert()` / `patch()` / `deleteById()` が Active Record スタイルの `save()` / `delete()` に ## 移行後に得られるもの * **Active Record パターン**:クエリビルダーとモデルを分離する必要なし——モデルインスタンスを直接操作 * **組み込みソフトデリート**:モデルに `softDeletes()` を追加するだけで `deleted_at` の処理が自動化 * **組み込みスコープ**:`scope` メソッドで再利用可能なクエリフィルターを定義 * **ファーストクラス TypeScript**:クラスベースのモデルで型推論が自然に機能 * **Eloquent スタイル API**:Laravel を使ったことがあれば、学習曲線はほぼゼロ * **活発なメンテナンス**:定期的なリリースと新機能 ## 結論 Objection.js は Node.js コミュニティに貢献してきましたが、メンテナンス状況と TypeScript の制限により、長期プロジェクトではリスクとなります。Sutando は馴染みのある、よくメンテナンスされた代替を提供し、Active Record パターンでボイラープレートを削減し、開発体験を向上させます。 移行は段階的に行えます——両方の ORM が共存しながら、モデルごとに移行できます。今日からひとつのモデルで始めて、違いを実感してください。 👉 [Sutando を始める](https://sutando.org/ja/guide/getting-started.html) または `npm install sutando` を実行して試してみましょう。 --- --- url: /zh_CN/blog/posts/modern-alternative-to-objectionjs-migrating-to-sutando.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Objection.js 多年来一直是 Node.js 生态中流行的 ORM。基于 Knex.js 构建,它提供了灵活的、SQL 友好的数据库操作方式。但随着原作者宣布不再维护,TypeScript 支持也落后于时代,许多团队开始寻找现代替代方案。本指南将带你从 Objection.js 迁移到 Sutando。 ## 为什么离开 Objection.js? Objection.js 仍然是一个可用的库,但以下几个因素让迁移值得考虑: ### 维护状态 在 [GitHub 讨论区](https://github.com/Vincit/objection.js/discussions/2463) 中,原作者 Sami Koskimäki 宣布不再积极维护该项目: * **没有活跃维护者**:项目的 bus factor 极高,唯一的维护者已经离开 * **没有新功能**:只有社区 fork 在接收更新 * **发布缓慢**:补丁版本之间间隔数月,甚至更久 ### TypeScript 支持 Objection.js 设计于 TypeScript 普及之前。类型系统可以工作,但需要手动声明类型,无法提供现代 TypeScript 优先 ORM 的无缝类型推断。 ### 没有内置迁移 Objection.js 完全依赖 Knex 进行迁移。虽然 Knex 很可靠,但你需要管理两个独立的工具,它们有不同的 API 和心智模型。 ## 为什么选择 Sutando? Sutando 是一个现代的 Node.js Active Record ORM,灵感来自 Laravel 的 Eloquent。它解决了 Objection.js 用户面临的痛点: | 特性 | Objection.js | Sutando | |------|-------------|---------| | 模式 | Query Builder + ORM | Active Record | | TypeScript | 手动类型 | 一等公民 TS 支持 | | 迁移 | 通过 Knex(独立工具) | 内置 schema builder | | 维护 | 停滞 | 活跃 | | 关联 | `withGraphFetched` | `with()`, `hasMany()`, `belongsTo()` | | 软删除 | 手动实现 | 内置 | | 钩子 | `$beforeInsert` 等 | `creating`, `saved` 等 | | 作用域 | 手动实现 | 内置 | ## 概念映射:Objection.js → Sutando ### 模型 **Objection.js:** ```typescript import { Model } from 'objection'; import knex from 'knex'; const db = knex({ client: 'pg', connection: process.env.DATABASE_URL, }); Model.knex(db); class User extends Model { static tableName = 'users'; static jsonSchema = { type: 'object', required: ['name', 'email'], properties: { id: { type: 'integer' }, name: { type: 'string', minLength: 1, maxLength: 255 }, email: { type: 'string', format: 'email' }, }, }; static relationMappings = { posts: { relation: Model.HasManyRelation, modelClass: Post, join: { from: 'users.id', to: 'posts.user_id' }, }, }; } ``` **Sutando:** ```typescript import { sutando, Model } from 'sutando'; const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); class User extends Model { protected table = 'users'; protected fillable = ['name', 'email']; posts(): HasMany { return this.hasMany(Post); } } ``` ### 查询 **Objection.js:** ```typescript // 简单查询 const users = await User.query().where('age', '>', 18); // 带关联 const user = await User.query() .withGraphFetched('[posts.comments]') .findById(1); // 插入 const user = await User.query().insert({ name: 'Alice', email: 'alice@example.com' }); // 更新 await User.query().patch({ name: 'Bob' }).where('id', 1); // 删除 await User.query().deleteById(1); ``` **Sutando:** ```typescript // 简单查询 const users = await User.query().where('age', '>', 18).get(); // 带关联 const user = await User.query().with('posts.comments').find(1); // 插入 const user = new User({ name: 'Alice', email: 'alice@example.com' }); await user.save(); // 更新 const user = await User.query().find(1); user.name = 'Bob'; await user.save(); // 删除 const user = await User.query().find(1); await user.delete(); ``` ### 关联 **Objection.js** 使用 `relationMappings` 声明式对象语法: ```typescript static relationMappings = { posts: { relation: Model.HasManyRelation, modelClass: Post, join: { from: 'users.id', to: 'posts.user_id' }, }, profile: { relation: Model.BelongsToOneRelation, modelClass: Profile, join: { from: 'users.profile_id', to: 'profiles.id' }, }, }; ``` **Sutando** 使用基于方法的定义,类似 Eloquent: ```typescript posts(): HasMany { return this.hasMany(Post); } profile(): BelongsTo { return this.belongsTo(Profile); } ``` ### 事务 **Objection.js:** ```typescript await User.transaction(async (trx) => { const user = await User.query(trx).insert({ name: 'Alice' }); await Post.query(trx).insert({ title: 'Hello', user_id: user.id }); }); ``` **Sutando:** ```typescript await db.transaction(async () => { const user = new User({ name: 'Alice' }); await user.save(); const post = new Post({ title: 'Hello', user_id: user.id }); await post.save(); }); ``` ### 钩子 / 事件 **Objection.js:** ```typescript class User extends Model { static tableName = 'users'; $beforeInsert() { this.created_at = new Date().toISOString(); } $beforeUpdate() { this.updated_at = new Date().toISOString(); } } ``` **Sutando:** ```typescript import { Model } from 'sutando'; class User extends Model { protected table = 'users'; static creating(model: User) { model.created_at = new Date(); } static updating(model: User) { model.updated_at = new Date(); } } ``` ## 逐步迁移指南 ### 第 1 步:安装 Sutando ```bash npm install sutando ``` ### 第 2 步:设置数据库连接 创建 `db.ts` 文件: ```typescript import { sutando } from 'sutando'; export const db = sutando({ client: 'pg', connection: process.env.DATABASE_URL, }); export default db; ``` ### 第 3 步:逐步转换模型 不需要一次性迁移所有内容。两个 ORM 可以在同一个项目中共存: 1. 先转换最简单的模型(没有复杂关联的) 2. 再转换有 `hasMany` / `belongsTo` 关联的模型 3. 最后转换有复杂图操作的模型 ### 第 4 步:迁移数据库迁移 如果你在使用 Knex 迁移,Sutando 的 schema builder 是兼容的: ```typescript import { schema } from 'sutando'; exports.up = function (schema) { return schema.createTable('users', (table) => { table.increments('id'); table.string('name'); table.string('email').unique(); table.timestamps(); }); }; exports.down = function (schema) { return schema.dropTableIfExists('users'); }; ``` ### 第 5 步:替换查询 遍历代码库,将 Objection.js 查询替换为 Sutando 等价代码。主要变化: * Objection 的 `Model.query()` 返回查询构建器;Sutando 中需要链式调用 `.get()` 或 `.first()` 来执行 * `withGraphFetched('[posts.comments]')` 变成 `with('posts.comments')` * `insert()` / `patch()` / `deleteById()` 变成 Active Record 风格的 `save()` / `delete()` ## 迁移后你获得了什么 * **Active Record 模式**:不再需要分离的查询构建器和模型——直接操作模型实例 * **内置软删除**:在模型中添加 `softDeletes()` 即可获得 `deleted_at` 自动处理 * **内置作用域**:通过 `scope` 方法定义可复用的查询过滤器 * **一等公民 TypeScript**:类型推断与基于类的模型自然配合 * **Eloquent 风格 API**:如果你用过 Laravel,学习曲线几乎为零 * **活跃维护**:定期发布和新功能 ## 结论 Objection.js 为 Node.js 社区做出了贡献,但它的维护状态和 TypeScript 局限性使其成为长期项目的隐患。Sutando 提供了一个熟悉的、维护良好的替代方案,Active Record 模式减少了样板代码,提升了开发体验。 迁移可以增量进行——两个 ORM 可以共存,你逐个模型地过渡。今天就从一个模型开始,亲自感受差异。 👉 [开始使用 Sutando](https://sutando.org/zh_CN/guide/getting-started.html) 或运行 `npm install sutando` 来试试。 --- --- url: /guide/schema-builder.md --- # Schema Builder ### Support later... --- --- url: /zh_CN/guide/schema-builder.md --- # Schema Builder ### Support later... --- --- url: /blog/posts/soft-deletes-hooks-and-scopes-in-sutando.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Sutando includes several powerful features that go beyond basic CRUD: soft deletes, model events (hooks), and query scopes. This guide covers all three with practical examples. ## Soft Deletes Soft deletes allow you to "delete" records without actually removing them from the database. A `deleted_at` column marks records as deleted. ### Setup Add a `deleted_at` column to your table: ```ts await sutando.schema().table('posts', table => { table.timestamp('deleted_at').nullable(); }); ``` Enable soft deletes on your model: ```ts import { Model, SoftDeletes } from 'sutando'; class Post extends Model { table = 'posts'; use = [SoftDeletes]; } ``` ### Usage ```ts // Soft delete — sets deleted_at instead of removing const post = await Post.find(1); await post.delete(); // Record still exists in the database with deleted_at set // Normal queries exclude soft-deleted records const posts = await Post.query().get(); // deleted records excluded // Include soft-deleted records const allPosts = await Post.query().withTrashed().get(); // Only soft-deleted records const trashedPosts = await Post.query().onlyTrashed().get(); // Restore a soft-deleted record const post = await Post.query().withTrashed().find(1); await post.restore(); // Permanently delete await post.forceDelete(); ``` ## Model Events (Hooks) Sutando fires events throughout a model's lifecycle, letting you hook into create, update, and delete operations. ### Available Events | Event | When | |-------|------| | `creating` | Before a new record is created | | `created` | After a new record is created | | `updating` | Before a record is updated | | `updated` | After a record is updated | | `saving` | Before save (create or update) | | `saved` | After save (create or update) | | `deleting` | Before a record is deleted | | `deleted` | After a record is deleted | | `restoring` | Before a soft-deleted record is restored | | `restored` | After a soft-deleted record is restored | ### Registering Hooks ```ts // Hash password before creating a user User.creating(async (user) => { user.password = await bcrypt.hash(user.password, 10); }); // Generate slug before saving a post Post.saving(async (post) => { if (post.isDirty('title')) { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); } }); // Cascade delete comments when a post is deleted Post.deleting(async (post) => { await post.comments().delete(); }); // Log after creation Post.created(async (post) => { console.log(`Post created: ${post.title}`); }); ``` ### Stopping an Operation Return `false` from a `creating` or `updating` hook to abort: ```ts User.creating(async (user) => { const exists = await User.query().where('email', user.email).exists(); if (exists) return false; // prevents creation }); ``` ## Query Scopes Scopes are reusable query constraints that you can chain together. ### Defining Scopes ```ts class Post extends Model { table = 'posts'; scopePublished(query) { return query.where('published', true); } scopePopular(query) { return query.where('views', '>', 1000); } scopeRecent(query, days = 7) { return query.where('created_at', '>', new Date(Date.now() - days * 86400000)); } scopeByUser(query, userId) { return query.where('user_id', userId); } } ``` ### Using Scopes ```ts // Chain multiple scopes const posts = await Post.query() .published() .popular() .recent(30) .orderBy('views', 'desc') .limit(10) .get(); // With parameters const userPosts = await Post.query() .byUser(5) .published() .get(); ``` ### Global Scopes Global scopes apply to all queries on a model automatically: ```ts class Post extends Model { table = 'posts'; static booted() { super.booted(); this.addGlobalScope('published', (query) => { query.where('published', true); }); } } // All queries now exclude unpublished posts const posts = await Post.query().get(); // Skip global scope when needed const allPosts = await Post.query().withoutGlobalScope('published').get(); ``` ## Combining All Three Here's a real-world example combining soft deletes, hooks, and scopes: ```ts import { Model, SoftDeletes } from 'sutando'; class Post extends Model { table = 'posts'; use = [SoftDeletes]; casts = { published: 'boolean' }; // Scopes scopePublished(query) { return query.where('published', true); } scopePopular(query) { return query.where('views', '>', 1000); } // Hooks static booted() { super.booted(); this.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); this.deleting(async (post) => { await post.comments().delete(); }); } // Relationships relationComments() { return this.hasMany(Comment, 'post_id'); } relationUser() { return this.belongsTo(User, 'user_id'); } } // Usage const popularPosts = await Post.query() .with('user') .published() .popular() .recent(30) .orderBy('views', 'desc') .limit(10) .get(); ``` ## Conclusion Soft deletes, model events, and query scopes are three features that make Sutando more than just a query builder. They let you encode business logic directly into your models, keeping your controllers clean and your data consistent. Learn more in the [Sutando documentation](https://sutando.org/guide/soft-deletes.html). --- --- url: /ja.md --- --- --- url: /zh_CN.md --- --- --- url: /blog/posts/sutando-orm-the-laravel-eloquent-experience-for-nodejs.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** ## The Familiar Active‑Record You Love, Now for JavaScript > **Sutando ORM** speaks the same language as Laravel’s Eloquent—methods, naming, and conventions—yet runs anywhere in the Node.js ecosystem, without tying you to a full‑stack framework. ## Nearly Drop‑In Compatible with Laravel Eloquent If you’ve written an Eloquent query, you already know Sutando: ```ts // models/User.ts import { Model, Attribute } from 'sutando'; export class User extends Model { table = 'users'; // cast casts = { metadata: 'json', } // // attribute attributeFullName() { return Attribute.make({ get: () => { return this.first_name + ' ' + this.last_name; }, }); } // relation relationPosts() { return this.hasMany(Post); } } const users = await User.query().with('posts').get(); ``` * Identical relationship helpers (`hasMany`, `belongsTo`, `morphTo`, etc.) * Chainable query builder with `where`, `orderBy`, `limit`, `with`, `has`, `whereHas`, and more * Soft deletes, events, global scopes, and eager loading work just like in Laravel * Migrations, factories, and seeders share the same philosophies—moving from PHP feels effortless ## Framework‑Agnostic by Design—A Contrast to AdonisJS Full‑stack solutions such as **AdonisJS** ship with Lucid ORM, which shines when you commit to the entire framework. **Sutando takes the opposite route**: | Feature | Sutando ORM | AdonisJS Lucid | |---------|-------------|----------------| | Framework dependency | None — plug into Express, Fastify, Next.js, Cloudflare Workers, Bun, or anything else | Tightly integrated with AdonisJS | | Migration to existing stack | Drop‑in; no need to rewrite your server | Requires adopting AdonisJS conventions | This decoupled approach lets you adopt Sutando gradually—add it to a legacy Express API, a React server, or even into an AdonisJS project if you prefer its API surface. ## Fluent Query Builder Example ```ts const latestActive = await User.query() .with('posts.comments') .where('status', 'active') .orderBy('created_at', 'desc') .limit(10) .get(); ``` You read it like a sentence, just as in Laravel—no extra boilerplate, no new mental model. ## Powerful Plugins & Lifecycle Hooks Need soft deletes, multi‑tenancy, or automatic timestamps? Write it once and apply everywhere: ```ts User.saving(async (model) => { model.last_updated_at = new Date(); }); ``` ## Quick Start ```bash npm install sutando --save ``` Configure your database, create your first model, and run queries—no framework migration required. Full docs live at **https://sutando.org/guide/getting-started.html**. ```ts import { sutando, Model } from 'sutando'; // Add SQL Connection Info sutando.addConnection({ client: 'mysql2', connection: { host : '127.0.0.1', port : 3306, user : 'root', password : '', database : 'test' }, }); const db = sutando.connection(); // Using The Query Builder const users = await sutando.table('users').where('votes', '>', 100).get(); // or const users = await db.table('users').where('votes', '>', 100).get(); // Using The Schema Builder await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.integer('votes'); table.timestamps(); }); // Using The ORM class User extends Model {} const users = await User.query().where('votes', '>', 100).get(); ``` ## Build Faster, Stay Flexible Sutando ORM delivers the Eloquent developer experience Laravel fans adore, yet keeps your architecture open. Whether you’re upgrading a JavaScript codebase, experimenting with serverless, or simply want an Active‑Record that feels right, Sutando is ready to power your next app—framework‑free. --- --- url: /ja/blog/posts/sutando-orm-the-laravel-eloquent-experience-for-nodejs.md --- Node.js 向けの ORM を探しているなら、Sutando を知っておくべきです。Laravel Eloquent の開発体験を Node.js にもたらす、モダンな Active Record ORM です。 ## Sutando とは? Sutando は Node.js 向けのモダンな ORM で、Laravel Eloquent の API パターンを直接参考にして設計されています。Active Record パターンを採用し、MySQL、PostgreSQL、SQLite、MariaDB、SQL Server などに対応しています。 ### 主な特徴 * **Active Record パターン** — モデルインスタンスが保存・更新・削除を直接実行 * **デコレーター不要** — プレーンな TypeScript クラスでモデルを定義 * **リレーション管理** — `hasMany`、`belongsTo`、`belongsToMany` など * **ソフトデリート** — 組み込みサポート * **モデルイベント** — 作成・更新・削除のライフサイクルにフック * **クエリスコープ** — 再利用可能なクエリ条件 * **タイプキャスト** — 属性の自動型変換 * **マイグレーション** — スキーマビルダー付き ## なぜ Sutando なのか? Node.js エコシステムには多くの ORM がありますが、それぞれ課題があります: * **Prisma** — 優秀だが、Data Mapper パターンでコード生成ステップが必要 * **TypeORM** — デコレーター依存とメンテナンスの停滞 * **Drizzle** — 軽量だが、リレーション管理が手動で煩雑 Sutando は、Laravel Eloquent のシンプルさと開発速度を Node.js にもたらします。 ## クイックスタート ```bash npm install sutando --save ``` データベースを設定し、最初のモデルを作成して、クエリを実行しましょう。完全なドキュメントは **https://sutando.org/ja/guide/getting-started.html** にあります。 ```ts import { sutando, Model } from 'sutando'; // データベース接続情報を追加 sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', port: 3306, user: 'root', password: '', database: 'test' } }); // モデルを定義 class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; relationUser() { return this.belongsTo(User, 'user_id'); } } // クエリを実行 const users = await User.query().with('posts').get(); console.log(users); ``` ## Eloquent との比較 | 機能 | Laravel Eloquent | Sutando | |---------|-----------------|---------| | パターン | Active Record | Active Record | | モデル定義 | PHP クラス | TypeScript クラス | | リレーション | メソッドベース | メソッドベース | | ソフトデリート | トレイト | トレイト | | イベント | 静的メソッド | 静的メソッド | | スコープ | メソッド | メソッド | ## まとめ Sutando は、Laravel Eloquent の開発体験を Node.js でもたらす ORM です。Active Record パターン、デコレーター不要のモデル定義、豊富な機能により、高速な開発が可能になります。 [スタートガイド](https://sutando.org/ja/guide/getting-started.html) を読んで始めましょう。 --- --- url: /zh_CN/blog/posts/sutando-orm-the-laravel-eloquent-experience-for-nodejs.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** ## 你熟悉的 Active Record,现在来到了 JavaScript > **Sutando ORM** 与 Laravel Eloquent 一脉相承——相同的方法、命名和约定——同时可以在 Node.js 生态中的任何地方运行,无需绑定全栈框架。 ## 几乎可以无缝替换 Laravel Eloquent 如果你写过 Eloquent 查询,你就已经会使用 Sutando 了: ```ts // models/User.ts import { Model, Attribute } from 'sutando'; export class User extends Model { table = 'users'; // cast casts = { metadata: 'json', } // // attribute attributeFullName() { return Attribute.make({ get: () => { return this.first_name + ' ' + this.last_name; }, }); } // relation relationPosts() { return this.hasMany(Post); } } const users = await User.query().with('posts').get(); ``` * 相同的关联方法(`hasMany`、`belongsTo`、`morphTo` 等) * 可链式调用的查询构造器,支持 `where`、`orderBy`、`limit`、`with`、`has`、`whereHas` 等 * 软删除、事件、全局作用域和预加载的工作方式与 Laravel 完全一致 * 迁移、工厂和种子遵循相同的理念——从 PHP 迁移轻而易举 ## 框架无关设计——与 AdonisJS 对比 **AdonisJS** 这类全栈方案自带 Lucid ORM,在你全面采用该框架时表现出色。**Sutando 则走了一条不同的路**: | 特性 | Sutando ORM | AdonisJS Lucid | |---------|-------------|----------------| | 框架依赖 | 无——可接入 Express、Fastify、Next.js、Cloudflare Workers、Bun 或任何其他环境 | 与 AdonisJS 紧密集成 | | 迁移到现有项目 | 即插即用;无需重写你的服务器 | 需要采用 AdonisJS 的约定 | 这种解耦设计让你可以逐步引入 Sutando——加到现有的 Express API、React 服务端中,甚至如果你更喜欢它的 API,也可以集成到 AdonisJS 项目里。 ## 流畅的查询构造器示例 ```ts const latestActive = await User.query() .with('posts.comments') .where('status', 'active') .orderBy('created_at', 'desc') .limit(10) .get(); ``` 就像在 Laravel 中一样,这段代码读起来就像一句话——没有多余的样板代码,不需要重新适应新的思维模式。 ## 强大的插件与生命周期钩子 需要软删除、多租户或自动时间戳?写一次,到处适用: ```ts User.saving(async (model) => { model.last_updated_at = new Date(); }); ``` ## 快速开始 ```bash npm install sutando --save ``` 配置你的数据库,创建你的第一个模型,然后运行查询——无需框架迁移。完整文档请访问 **https://sutando.org/zh\_CN/guide/getting-started.html**。 ```ts import { sutando, Model } from 'sutando'; // 添加数据库连接信息 sutando.addConnection({ client: 'mysql2', connection: { host : '127.0.0.1', port : 3306, user : 'root', password : '', database : 'test' }, }); const db = sutando.connection(); // 使用查询构造器 const users = await sutando.table('users').where('votes', '>', 100).get(); // 或 const users = await db.table('users').where('votes', '>', 100).get(); // 使用 Schema 构造器 await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.integer('votes'); table.timestamps(); }); // 使用 ORM class User extends Model {} const users = await User.query().where('votes', '>', 100).get(); ``` ## 更快地开发,保持灵活性 Sutando ORM 带来了 Laravel 爱好者钟爱的 Eloquent 开发体验,同时保持架构的开放性。无论你是在升级 JavaScript 代码库、尝试 Serverless 架构,还是只是想要一个用起来顺手的 Active Record,Sutando 都已准备好为你的下一个应用赋能——无需框架。 --- --- url: /blog/posts/sutando-plugins-extending-your-orm.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Sutando is designed to be extensible. Whether you need custom cast types, global query modifications, or reusable model behaviors, Sutando's plugin system has you covered. ## Custom Casts Casts transform attribute values when reading from or writing to the database. Sutando includes built-in casts for `boolean`, `json`, `array`, `date`, and `datetime`. You can create your own: ```ts import { Model, Cast } from 'sutando'; class EncryptedCast extends Cast { get(model, key, value) { return decrypt(value); // decrypt when reading } set(model, key, value) { return encrypt(value); // encrypt when writing } } class User extends Model { table = 'users'; casts = { ssn: new EncryptedCast(), metadata: 'json', is_admin: 'boolean', }; } ``` ## Custom Traits Traits are reusable pieces of model behavior. Sutando's `SoftDeletes` is a built-in trait. You can create your own: ```ts import { Model } from 'sutando'; const HasUuid = { boot(model) { model.creating(async (instance) => { if (!instance.uuid) { instance.uuid = crypto.randomUUID(); } }); } }; class User extends Model { table = 'users'; use = [HasUuid]; } ``` ## Global Query Modifications If you need to modify all queries for a model (e.g., multi-tenant filtering), use global scopes: ```ts class Post extends Model { table = 'posts'; static booted() { super.booted(); this.addGlobalScope('tenant', (query) => { if (currentTenantId) { query.where('tenant_id', currentTenantId); } }); } } // Skip when needed const allPosts = await Post.query().withoutGlobalScope('tenant').get(); ``` ## Macro Methods You can add custom methods to the query builder: ```ts import { QueryBuilder } from 'sutando'; QueryBuilder.macro('search', function (term) { return this.where('title', 'like', `%${term}%`) .orWhere('content', 'like', `%${term}%`); }); // Usage const results = await Post.query().search('laravel').get(); ``` ## Creating a Plugin Package Package your extensions for reuse: ```ts // my-sutando-plugin/index.ts import { Model, Cast } from 'sutando'; export const TimestampsCast = new Cast({ get: (model, key, value) => value ? new Date(value) : null, set: (model, key, value) => value ? value.toISOString() : null, }); export const HasUuid = { boot(model) { model.creating(async (instance) => { if (!instance.uuid) instance.uuid = crypto.randomUUID(); }); } }; export function setupPaginationPerPage(defaultPerPage = 20) { // Add perPage method to query builder } ``` ```ts // Usage in your app import { TimestampsCast, HasUuid } from 'my-sutando-plugin'; class Event extends Model { table = 'events'; use = [HasUuid]; casts = { start_time: TimestampsCast, end_time: TimestampsCast, }; } ``` ## Conclusion Sutando's extension points — custom casts, traits, global scopes, and macros — let you build reusable functionality that integrates naturally with the ORM. This makes it easy to share patterns across projects and teams. Learn more in the [Sutando documentation](https://sutando.org/guide/getting-started.html). --- --- url: /blog/posts/sutando-vs-drizzle-active-record-vs-sql-first.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Drizzle ORM has become the go-to choice for developers who want SQL-level control with TypeScript type safety. Sutando takes the opposite approach — bringing Active Record elegance to Node.js. Let's compare them head-to-head. ## The Core Difference * **Drizzle** is SQL-first: its query builder mirrors SQL syntax, and schemas are defined in TypeScript that looks like `CREATE TABLE` statements * **Sutando** is Active Record-first: models are classes with methods like `save()`, `delete()`, and relationship methods like `hasMany()` Drizzle says: "If you know SQL, you know Drizzle."\ Sutando says: "If you know Laravel Eloquent, you know Sutando." ## Schema Definition **Drizzle:** ```ts import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), createdAt: timestamp('created_at').defaultNow(), }); export const posts = pgTable('posts', { id: serial('id').primaryKey(), title: text('title').notNull(), userId: integer('user_id').references(() => users.id), createdAt: timestamp('created_at').defaultNow(), }); ``` **Sutando:** ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; relationUser() { return this.belongsTo(User, 'user_id'); } } ``` Drizzle's schema is closer to SQL. Sutando's models are closer to your business domain. ## Query Comparison ### Simple Query **Drizzle:** ```ts const users = await db.select().from(users) .where(eq(users.name, 'Alice')) .limit(10); ``` **Sutando:** ```ts const users = await User.query() .where('name', 'Alice') .limit(10) .get(); ``` ### Join / Relationship Query **Drizzle:** ```ts const result = await db.select({ id: users.id, name: users.name, postTitle: posts.title, }) .from(users) .leftJoin(posts, eq(users.id, posts.userId)) .where(eq(users.id, 1)); ``` **Sutando:** ```ts const user = await User.query().with('posts').find(1); // user.posts is an array of Post models ``` This is the key difference: Drizzle makes you write explicit joins, while Sutando handles relationship loading automatically through eager loading. ### Insert **Drizzle:** ```ts const [user] = await db.insert(users).values({ name: 'Alice', email: 'alice@example.com', }).returning(); ``` **Sutando:** ```ts const user = new User(); user.name = 'Alice'; user.email = 'alice@example.com'; await user.save(); ``` ### Update **Drizzle:** ```ts await db.update(users) .set({ name: 'Bob' }) .where(eq(users.id, 1)); ``` **Sutando:** ```ts const user = await User.find(1); user.name = 'Bob'; await user.save(); ``` ## Feature Comparison | Feature | Sutando | Drizzle | |---------|---------|---------| | Pattern | Active Record | SQL query builder | | Schema | Model classes | TypeScript schema builders | | Relationships | `hasMany`, `belongsTo`, `morphTo` | Manual joins | | Eager loading | `with('posts.comments')` | Manual join configuration | | Migrations | Schema builder + migration files | Drizzle Kit (`generate` / `push`) | | Bundle size | Small | Very small (~7.4kb min+gzip) | | Edge runtime | Yes | Yes | | Raw SQL | Via query builder | First-class `sql` template tag | | Model events | Built-in | No | | Soft deletes | Built-in | No | | Type safety | Class-based | TypeScript inference from schema | | Database support | MySQL, PostgreSQL, SQLite | PostgreSQL, MySQL, SQLite, Turso, Neon | ## Where Sutando Wins ### 1. Relationship Management This is Sutando's biggest advantage. Managing relationships in Drizzle requires explicit joins for every query. Sutando handles this with declarative relationship methods: ```ts // Sutando: one line for nested relationships const users = await User.query() .with('posts.comments.author') .get(); // Drizzle: requires multiple joins const result = await db.select() .from(users) .leftJoin(posts, eq(users.id, posts.userId)) .leftJoin(comments, eq(posts.id, comments.postId)) .leftJoin(authors, eq(comments.authorId, authors.id)); ``` ### 2. Model Lifecycle Sutando's model events let you hook into create/update/delete operations: ```ts User.creating(async (user) => { user.password = await bcrypt.hash(user.password, 10); }); User.deleting(async (user) => { await user.posts().delete(); }); ``` Drizzle has no equivalent — you'd need to wrap every query in custom logic. ### 3. Rapid Development For CRUD applications, Sutando requires significantly less code. You don't need to write joins, manage schema files separately, or configure Drizzle Kit. ### 4. Built-in Soft Deletes ```ts // Sutando: just add deleted_at column and use SoftDeletes const posts = await Post.query().onlyTrashed().get(); await post.delete(); // soft delete await post.forceDelete(); // hard delete ``` Drizzle requires manual filtering on every query. ## Where Drizzle Wins ### 1. SQL Control If you need precise control over generated SQL, Drizzle is unmatched. You see exactly what SQL will be executed: ```ts const result = await db.select() .from(users) .where(sql`${users.age} > ${18}`) .orderBy(sql`RANDOM()`) .limit(1); ``` ### 2. Bundle Size Drizzle is extremely lightweight (~7.4kb min+gzip). For Cloudflare Workers with strict size limits, this matters. ### 3. Performance Drizzle generates SQL directly with minimal overhead. In benchmarks, it consistently outperforms other ORMs. For high-throughput applications, this edge is real. ### 4. Raw SQL with Type Safety Drizzle's `sql` template tag lets you write raw SQL while maintaining type safety on column references — something Sutando doesn't offer. ### 5. Serverless-First Design Drizzle was built for the serverless era. It works seamlessly with Turso, Neon, Supabase, and other modern serverless database providers. ## When to Choose Sutando * Your app has complex relationships * You want Active Record productivity * You need model events, soft deletes, or scopes * You're coming from Laravel or Rails * You prioritize developer speed over SQL control ## When to Choose Drizzle * You want full SQL control * You're building for Cloudflare Workers or edge runtimes * Bundle size is critical * You're comfortable writing joins * You want maximum query performance ## Conclusion Drizzle and Sutando serve different needs. Drizzle is for developers who love SQL and want minimal abstraction. Sutando is for developers who want Active Record elegance and automatic relationship management. If your app is relationship-heavy and you value rapid development, Sutando is the better choice. If you need SQL-level control and minimal overhead, go with Drizzle. Try Sutando: `npm install sutando` — or read the [documentation](https://sutando.org/guide/getting-started.html). --- --- url: /ja/blog/posts/sutando-vs-drizzle-active-record-vs-sql-first.md --- Sutando と Drizzle は、Node.js の ORM として異なるアプローチを代表しています。Sutando は Active Record パターンで開発速度を重視し、Drizzle は SQL ファーストで最大限の制御を提供します。 ## 核心的な違い | 側面 | Sutando | Drizzle | |---------|---------|---------| | パターン | Active Record | SQL ビルダー | | スキーマ | モデルクラス | スキーマ定義ファイル | | クエリ | メソッドチェーン | SQL ライクな API | | リレーション | 宣言的メソッド | 手動 JOIN | | ランタイム | ゼロオーバーヘッド | ゼロオーバーヘッド | | 学習曲線 | 低い(Eloquent ライク) | 中程度(SQL 知識が必要) | ## スキーマ定義 ```ts // Sutando: モデルクラスで定義 class User extends Model { table = 'users'; casts = { is_admin: 'boolean' }; relationPosts() { return this.hasMany(Post, 'user_id'); } } // Drizzle: スキーマファイルで定義 import { pgTable, serial, varchar, boolean } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: serial('id').primaryKey(), name: varchar('name').notNull(), email: varchar('email').notNull().unique(), is_admin: boolean('is_admin').default(false), }); ``` ## クエリの比較 ### 基本的なクエリ ```ts // Sutando const users = await User.query() .where('is_admin', true) .orderBy('created_at', 'desc') .get(); // Drizzle const result = await db.select() .from(users) .where(eq(users.is_admin, true)) .orderBy(desc(users.created_at)); ``` ### リレーション ```ts // Sutando: ネストされたリレーションを1行で const users = await User.query() .with('posts.comments.author') .get(); // Drizzle: 複数の JOIN が必要 const result = await db.select() .from(users) .leftJoin(posts, eq(users.id, posts.userId)) .leftJoin(comments, eq(posts.id, comments.postId)) .leftJoin(authors, eq(comments.authorId, authors.id)); ``` ## Sutando の利点 ### 1. リレーション管理 Sutando の最大の利点です。Drizzle ではすべてのクエリで明示的な JOIN が必要ですが、Sutando は宣言的なリレーションメソッドで処理します。 ### 2. 組み込みのソフトデリート ```ts // Sutando: deleted_at カラムを追加するだけ class Post extends Model { use = [SoftDeletes]; } await post.delete(); // ソフトデリート await post.forceDelete(); // 完全削除 ``` Drizzle ではすべてのクエリで手動フィルタリングが必要です。 ### 3. 開発速度 CRUD アプリケーションにおいて、Sutando は大幅に少ないコードで済みます。 ## Drizzle の利点 ### 1. SQL の完全な制御 複雑なクエリやパフォーマンスが重要な場面で、Drizzle は SQL に近い制御を提供します。 ### 2. エッジ環境でのパフォーマンス Drizzle は Cloudflare Workers や Vercel Edge などのエッジ環境で非常に軽量に動作します。 ### 3. 型安全性 スキーマから完全な型を生成するため、クエリ結果の型推論が強力です。 ## どちらを選ぶべきか? **Sutando を選ぶ場合:** * CRUD 中心のアプリケーション * リレーション管理をシンプルにしたい * Laravel Eloquent の経験がある * 開発速度を重視する **Drizzle を選ぶ場合:** * SQL の完全な制御が必要 * エッジ環境でのパフォーマンスが重要 * 複雑なクエリが多い * チームが SQL に精通している ## 結論 Sutando は開発速度を、Drizzle は制御力を優先します。CRUD アプリなら Sutando、複雑なクエリが中心なら Drizzle が適しています。 Sutando を試す:`npm install sutando` — [ドキュメント](https://sutando.org/ja/guide/getting-started.html) --- --- url: /zh_CN/blog/posts/sutando-vs-drizzle-active-record-vs-sql-first.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Drizzle ORM 已经成为追求 SQL 级别控制和 TypeScript 类型安全的开发者的首选。Sutando 则走相反的路线——将 Active Record 的优雅带入 Node.js。让我们正面对比一下。 ## 核心区别 * **Drizzle** 是 SQL 优先的:查询构造器镜像 SQL 语法,schema 用 TypeScript 定义,看起来像 `CREATE TABLE` * **Sutando** 是 Active Record 优先的:模型是类,有 `save()`、`delete()` 和 `hasMany()` 等方法 Drizzle 说:"如果你懂 SQL,你就懂 Drizzle。" Sutando 说:"如果你懂 Laravel Eloquent,你就懂 Sutando。" ## Schema 定义 **Drizzle:** ```ts import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), createdAt: timestamp('created_at').defaultNow(), }); ``` **Sutando:** ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` Drizzle 的 schema 更接近 SQL,Sutando 的模型更接近业务领域。 ## 查询对比 ### 关联查询 **Drizzle:** ```ts const result = await db.select() .from(users) .leftJoin(posts, eq(users.id, posts.userId)) .where(eq(users.id, 1)); ``` **Sutando:** ```ts const user = await User.query().with('posts').find(1); // user.posts 是 Post 模型数组 ``` 关键区别:Drizzle 要求你手写 join,Sutando 通过预加载自动处理关联。 ## 功能对比 | 功能 | Sutando | Drizzle | |---------|---------|---------| | 模式 | Active Record | SQL 查询构造器 | | 关联 | `hasMany`、`belongsTo`、`morphTo` | 手动 join | | 预加载 | `with('posts.comments')` | 手动配置 join | | 模型事件 | 内置 | 无 | | 软删除 | 内置 | 无 | | 包体积 | 小 | 极小(~7.4kb min+gzip) | | Raw SQL | 通过查询构造器 | 一等公民 `sql` 模板标签 | | 类型安全 | 基于类 | 从 schema 推断 | ## Sutando 的优势 ### 1. 关联管理 这是 Sutando 最大的优势。Drizzle 中管理关联需要每次查询都写 join,Sutando 通过声明式关联方法自动处理: ```ts // Sutando:一行搞定嵌套关联 const users = await User.query() .with('posts.comments.author') .get(); ``` ### 2. 模型生命周期 ```ts User.creating(async (user) => { user.password = await bcrypt.hash(user.password, 10); }); ``` Drizzle 没有类似功能——你需要在每个查询外包裹自定义逻辑。 ### 3. 快速开发 对于 CRUD 应用,Sutando 需要的代码量明显更少。 ### 4. 内置软删除 ```ts const posts = await Post.query().onlyTrashed().get(); await post.delete(); // 软删除 await post.forceDelete(); // 硬删除 ``` ## Drizzle 的优势 ### 1. SQL 控制 如果你需要精确控制生成的 SQL,Drizzle 无可匹敌。 ### 2. 包体积 Drizzle 极其轻量(~7.4kb min+gzip),对 Cloudflare Workers 的严格体积限制来说很重要。 ### 3. 性能 Drizzle 直接生成 SQL,开销极小。在基准测试中始终优于其他 ORM。 ### 4. 类型安全的 Raw SQL Drizzle 的 `sql` 模板标签让你写原生 SQL 的同时保持列引用的类型安全。 ### 5. Serverless 优先 Drizzle 为 Serverless 时代而生,与 Turso、Neon、Supabase 等现代无服务器数据库无缝协作。 ## 何时选择 Sutando * 你的应用有复杂的关联关系 * 你想要 Active Record 的生产力 * 你需要模型事件、软删除或作用域 * 你从 Laravel 或 Rails 迁移过来 ## 何时选择 Drizzle * 你想要完全的 SQL 控制 * 你在为 Cloudflare Workers 或边缘运行时构建 * 包体积至关重要 * 你习惯写 join ## 总结 Drizzle 和 Sutando 服务于不同需求。Drizzle 适合热爱 SQL、想要最小抽象的开发者。Sutando 适合想要 Active Record 优雅和自动关联管理的开发者。如果你的应用关联密集且重视开发速度,Sutando 是更好的选择。如果你需要 SQL 级别的控制和最小开销,选 Drizzle。 试试 Sutando:`npm install sutando`——或阅读[文档](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /blog/posts/sutando-vs-prisma-which-nodejs-orm.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Prisma is the most popular ORM in the Node.js ecosystem. But popularity doesn't mean it's the right choice for every project. In this comparison, we'll look at where Sutando and Prisma differ, and help you decide which one fits your needs. ## Design Philosophy The fundamental difference is the pattern each ORM follows: * **Sutando** uses the **Active Record** pattern — models handle their own persistence * **Prisma** uses a **Data Mapper / schema-first** approach — a generated client handles queries This shapes everything from how you write queries to how you structure your application. ## Query Syntax Comparison ### Creating a Record **Sutando:** ```ts const user = new User(); user.name = 'Alice'; user.email = 'alice@example.com'; await user.save(); ``` **Prisma:** ```ts const user = await prisma.user.create({ data: { name: 'Alice', email: 'alice@example.com' } }); ``` ### Querying with Conditions **Sutando:** ```ts const users = await User.query() .where('active', true) .orderBy('created_at', 'desc') .limit(10) .get(); ``` **Prisma:** ```ts const users = await prisma.user.findMany({ where: { active: true }, orderBy: { created_at: 'desc' }, take: 10, }); ``` ### Loading Relationships **Sutando:** ```ts const users = await User.query().with('posts.comments').get(); ``` **Prisma:** ```ts const users = await prisma.user.findMany({ include: { posts: { include: { comments: true } } } }); ``` ### Updating a Record **Sutando:** ```ts const user = await User.find(1); user.name = 'Bob'; await user.save(); ``` **Prisma:** ```ts const user = await prisma.user.update({ where: { id: 1 }, data: { name: 'Bob' } }); ``` ## Feature Comparison | Feature | Sutando | Prisma | |---------|---------|--------| | Pattern | Active Record | Data Mapper | | Schema definition | TypeScript classes | `.prisma` DSL file | | Code generation | None | `prisma generate` step required | | Query style | Method chaining | Object-based filter API | | Relationships | `hasMany`, `belongsTo`, etc. | `include` / nested writes | | Migrations | Schema builder + migration files | Prisma Migrate (declarative) | | GUI tool | No | Prisma Studio | | Soft deletes | Built-in | Manual (middleware) | | Model events | Built-in (`creating`, `saving`, etc.) | Middleware / extensions | | Query scopes | Built-in | Not available | | Bundle size | Small | ~1.6 MB (Prisma 7) | | Edge runtime | Yes | Yes (Prisma 7+) | | Database support | MySQL, PostgreSQL, SQLite | MySQL, PostgreSQL, SQLite, MongoDB, SQL Server | ## Where Sutando Wins ### 1. Simplicity and Readability Sutando's method chaining reads like natural language. No need to learn a new DSL or object-based filter syntax: ```ts // Sutando: reads like a sentence const posts = await Post.query() .with('author') .where('published', true) .where('views', '>', 1000) .orderBy('created_at', 'desc') .limit(10) .get(); ``` ### 2. No Code Generation Step Prisma requires a `prisma generate` step every time you change your schema. Sutando models are plain TypeScript classes — no build step, no generated files. ### 3. Model Events and Hooks Sutando has built-in lifecycle hooks that Prisma lacks: ```ts // Sutando: automatic slug generation Post.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); // Prisma: requires middleware prisma.$use(async (params, next) => { if (params.model === 'Post' && params.action === 'create') { params.args.data.slug = params.args.data.title .toLowerCase().replace(/\s+/g, '-'); } return next(params); }); ``` ### 4. Soft Deletes Sutando has built-in soft delete support — just add a `deleted_at` column and use the `SoftDeletes` trait. Prisma requires custom middleware. ### 5. Laravel/Rails Background If you're coming from Laravel or Rails, Sutando's API is immediately familiar. Prisma's schema-first approach requires learning a new mental model. ## Where Prisma Wins ### 1. Type Safety Prisma's generated client provides end-to-end type safety. Every query is fully typed, and you get autocomplete for all model fields and relations. Sutando uses class-based typing which is good but not as comprehensive. ### 2. Prisma Studio Prisma Studio is a visual database browser that's genuinely useful for debugging and data inspection. Sutando doesn't have an equivalent tool. ### 3. Database Introspection Prisma can introspect an existing database and generate a schema from it (`prisma db pull`). This is helpful when working with legacy databases. ### 4. Broader Database Support Prisma supports MongoDB and SQL Server out of the box. Sutando currently supports MySQL, PostgreSQL, and SQLite. ### 5. Ecosystem and Community Prisma has a larger community, more tutorials, and more third-party integrations. If you need help, you're more likely to find answers. ## Performance Considerations Prisma 7 replaced the Rust query engine with a TypeScript/WASM implementation, reducing bundle size from ~14 MB to ~1.6 MB and improving cold starts. However, Sutando still has a smaller footprint since it doesn't require any code generation or engine overhead. For most applications, the performance difference is negligible — database query time dominates. The exception is serverless/edge environments where cold starts matter, and both ORMs now perform well there. ## When to Choose Sutando * You want Active Record simplicity * You're coming from Laravel or Rails * You need model events, soft deletes, or query scopes * You want to avoid code generation steps * You're building a CRUD-focused application * You value readable, chainable query syntax ## When to Choose Prisma * You want maximum type safety with generated types * You need MongoDB or SQL Server support * You want a visual database browser (Prisma Studio) * You're working with a legacy database that needs introspection * Your team prefers a schema-first approach * You need a large community for support ## Conclusion Both are excellent ORMs. Prisma excels in type safety, tooling, and ecosystem. Sutando wins on simplicity, developer experience for Active Record fans, and built-in features like model events and soft deletes. If you're a Laravel developer moving to Node.js, Sutando will feel like home. If you want the most type-safe experience with a visual tool, Prisma is the way to go. Try Sutando: `npm install sutando` — or read the [documentation](https://sutando.org/guide/getting-started.html). --- --- url: /ja/blog/posts/sutando-vs-prisma-which-nodejs-orm.md --- Node.js プロジェクトで ORM を選ぶ際、Sutando と Prisma は有力な候補です。この記事では、設計思想、クエリ構文、機能、そしてどのようなケースにどちらが適しているかを比較します。 ## 設計思想の違い | 側面 | Sutando | Prisma | |---------|---------|--------| | パターン | Active Record | Data Mapper | | スキーマ定義 | TypeScript クラス | `.prisma` DSL ファイル | | コード生成 | 不要 | `prisma generate` ステップが必要 | | クエリスタイル | メソッドチェーン | オブジェクトベースのフィルタ API | | リレーション | `hasMany`, `belongsTo` など | `include` / ネストされた書き込み | ## クエリ構文の比較 ### データの作成 ```ts // Sutando const post = await Post.create({ title: '新しい投稿', content: '内容', published: true, }); // Prisma const post = await prisma.post.create({ data: { title: '新しい投稿', content: '内容', published: true, }, }); ``` ### データの読み取り ```ts // Sutando const posts = await Post.query() .where('published', true) .orderBy('created_at', 'desc') .limit(10) .get(); // Prisma const posts = await prisma.post.findMany({ where: { published: true }, orderBy: { created_at: 'desc' }, take: 10, }); ``` ### リレーション ```ts // Sutando const user = await User.query().with('posts.comments').find(1); // Prisma const user = await prisma.user.findUnique({ where: { id: 1 }, include: { posts: { include: { comments: true }, }, }, }); ``` ## Sutando の利点 ### 1. コード生成が不要 Prisma はスキーマを変更するたびに `prisma generate` を実行する必要があります。Sutando のモデルはプレーンな TypeScript クラスで、ビルドステップも生成ファイルも不要です。 ### 2. モデルイベントとフック ```ts // Sutando: シンプルなイベントフック Post.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); // Prisma: ミドルウェアが必要 prisma.$use(async (params, next) => { if (params.model === 'Post' && params.action === 'create') { params.args.data.slug = params.args.data.title .toLowerCase().replace(/\s+/g, '-'); } return next(params); }); ``` ### 3. ソフトデリート Sutando は `deleted_at` カラムを追加して `SoftDeletes` トレイトを使うだけです。Prisma ではカスタムミドルウェアが必要です。 ### 4. Laravel/Rails の経験がある場合 Laravel や Rails からの移行者にとって、Sutando の API はすぐに馴染みます。Prisma のスキーマファーストのアプローチは新しい学習コストがあります。 ## Prisma の利点 ### 1. 型安全性 Prisma はスキーマから完全な型を自動生成するため、クエリ結果の型推論が強力です。 ### 2. マイグレーションツール Prisma Migrate は宣言的なスキーマベースのマイグレーションを提供します。 ### 3. 大規模なエコシステム Prisma はより大きなコミュニティと豊富な統合を持ちます。 ## パフォーマンス Prisma 7 は Rust クエリエンジンを TypeScript/WASM 実装に置き換え、バンドルサイズを約 14MB から約 1.6MB に削減しました。しかし、Sutando はコード生成やエンジンのオーバーヘッドがないため、さらに小さなフットプリントを持ちます。 ほとんどのアプリケーションでは、パフォーマンスの違いは無視できます。データベースクエリの時間が支配的だからです。 ## どちらを選ぶべきか? **Sutando を選ぶ場合:** * Laravel や Rails の経験がある * Active Record パターンを好む * モデルイベント、ソフトデリート、クエリスコープが必要 * コード生成ステップを避けたい * 小さくシンプルな API を好む **Prisma を選ぶ場合:** * 最大限の型安全性が必要 * スキーマファーストのアプローチを好む * 大規模なエコシステムを重視する * Data Mapper パターンを好む ## 結論 どちらの ORM も優れていますが、設計思想が異なります。Sutando は開発者体験(DX)とシンプルさを重視し、Prisma は型安全性とエコシステムを重視します。 Sutando を試す準備ができたら:`npm install sutando` — または [ドキュメント](https://sutando.org/ja/guide/getting-started.html) を読んでください。 --- --- url: /zh_CN/blog/posts/sutando-vs-prisma-which-nodejs-orm.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Prisma 是 Node.js 生态中最流行的 ORM。但流行不代表适合每个项目。在这篇对比中,我们将看看 Sutando 和 Prisma 的差异,帮你做出选择。 ## 设计哲学 根本区别在于各自遵循的模式: * **Sutando** 使用 **Active Record** 模式——模型自己负责持久化 * **Prisma** 使用 **Data Mapper / schema-first** 方式——由生成的客户端处理查询 这决定了一切,从查询写法到应用架构。 ## 查询语法对比 ### 创建记录 **Sutando:** ```ts const user = new User(); user.name = 'Alice'; user.email = 'alice@example.com'; await user.save(); ``` **Prisma:** ```ts const user = await prisma.user.create({ data: { name: 'Alice', email: 'alice@example.com' } }); ``` ### 条件查询 **Sutando:** ```ts const users = await User.query() .where('active', true) .orderBy('created_at', 'desc') .limit(10) .get(); ``` **Prisma:** ```ts const users = await prisma.user.findMany({ where: { active: true }, orderBy: { created_at: 'desc' }, take: 10, }); ``` ### 加载关联 **Sutando:** ```ts const users = await User.query().with('posts.comments').get(); ``` **Prisma:** ```ts const users = await prisma.user.findMany({ include: { posts: { include: { comments: true } } } }); ``` ## 功能对比 | 功能 | Sutando | Prisma | |---------|---------|--------| | 模式 | Active Record | Data Mapper | | Schema 定义 | TypeScript 类 | `.prisma` DSL 文件 | | 代码生成 | 无 | 需要 `prisma generate` | | 查询风格 | 方法链 | 对象式过滤 API | | 关联 | `hasMany`、`belongsTo` 等 | `include` / 嵌套写入 | | 迁移 | Schema 构造器 + 迁移文件 | Prisma Migrate(声明式) | | 可视化工具 | 无 | Prisma Studio | | 软删除 | 内置 | 手动(中间件) | | 模型事件 | 内置(`creating`、`saving` 等) | 中间件 / 扩展 | | 查询作用域 | 内置 | 无 | | 包体积 | 小 | ~1.6 MB(Prisma 7) | | Edge 运行时 | 支持 | 支持(Prisma 7+) | | 数据库支持 | MySQL、PostgreSQL、SQLite | MySQL、PostgreSQL、SQLite、MongoDB、SQL Server | ## Sutando 的优势 ### 1. 简洁和可读性 Sutando 的方法链读起来像自然语言,不需要学新的 DSL: ```ts // Sutando:像读一句话 const posts = await Post.query() .with('author') .where('published', true) .where('views', '>', 1000) .orderBy('created_at', 'desc') .limit(10) .get(); ``` ### 2. 无代码生成步骤 Prisma 每次改 schema 都要跑 `prisma generate`。Sutando 模型就是普通的 TypeScript 类——没有构建步骤,没有生成文件。 ### 3. 模型事件和钩子 Sutando 有内置的生命周期钩子,Prisma 缺少这个: ```ts // Sutando:自动生成 slug Post.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); ``` ### 4. 软删除 Sutando 内置软删除支持——加一个 `deleted_at` 列,使用 `SoftDeletes` trait 即可。Prisma 需要自定义中间件。 ### 5. Laravel/Rails 背景 如果你从 Laravel 或 Rails 迁移过来,Sutando 的 API 立刻就能上手。Prisma 的 schema-first 方式需要适应新的思维模式。 ## Prisma 的优势 ### 1. 类型安全 Prisma 生成的客户端提供端到端类型安全,每个查询都完全类型化。Sutando 使用基于类的类型,也不错但不如 Prisma 全面。 ### 2. Prisma Studio Prisma Studio 是一个可视化数据库浏览器,调试和查看数据非常方便。Sutando 没有类似工具。 ### 3. 数据库反向工程 Prisma 可以从现有数据库反向生成 schema(`prisma db pull`),对操作遗留数据库很有帮助。 ### 4. 更广的数据库支持 Prisma 原生支持 MongoDB 和 SQL Server。Sutando 目前支持 MySQL、PostgreSQL 和 SQLite。 ### 5. 生态和社区 Prisma 社区更大,教程更多,第三方集成更丰富。遇到问题更容易找到答案。 ## 何时选择 Sutando * 你想要 Active Record 的简洁性 * 你从 Laravel 或 Rails 迁移过来 * 你需要模型事件、软删除或查询作用域 * 你想避免代码生成步骤 * 你在构建以 CRUD 为主的应用 * 你重视可读的链式查询语法 ## 何时选择 Prisma * 你想要最大程度的类型安全 * 你需要 MongoDB 或 SQL Server 支持 * 你想要可视化数据库浏览器 * 你在操作遗留数据库需要反向工程 * 你的团队偏好 schema-first 方式 * 你需要大社区的支持 ## 总结 两者都是优秀的 ORM。Prisma 在类型安全、工具链和生态方面出色。Sutando 在简洁性、Active Record 开发体验和内置功能(模型事件、软删除)方面胜出。如果你是 Laravel 开发者转向 Node.js,Sutando 会让你宾至如归。如果你想要最类型安全的体验加可视化工具,Prisma 是更好的选择。 试试 Sutando:`npm install sutando`——或阅读[文档](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /blog/posts/sutando-vs-typeorm-modern-active-record-alternative.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** TypeORM has been a staple in the Node.js ecosystem for years. But as TypeScript has evolved and new ORMs have emerged, TypeORM's limitations have become harder to ignore. In this article, we'll compare TypeORM with Sutando and show why developers are switching. ## TypeORM's Problems Before comparing features, let's address why developers look for TypeORM alternatives: ### 1. Decorator Instability TypeORM relies heavily on TypeScript decorators (`@Entity`, `@Column`, `@OneToMany`). The TC39 decorator proposal changed multiple times, and TypeORM still uses the legacy experimental decorator syntax. This creates ongoing compatibility concerns. ### 2. Maintenance Concerns TypeORM's development pace has slowed significantly. Issues pile up, PRs take months to merge, and the community has expressed frustration over the lack of active maintenance. ### 3. QueryBuilder is String-Based TypeORM's QueryBuilder uses string-based column and table names, meaning typos are caught at runtime, not compile time: ```ts // TypeORM: string-based — typos not caught until runtime const users = await getRepository(User) .createQueryBuilder('user') .where('user.namme = :name', { name: 'Alice' }) // typo: "namme" .getMany(); ``` ### 4. Active Record Mode is Limited TypeORM supports Active Record via `BaseEntity`, but the implementation is less polished than dedicated Active Record ORMs. Features like scopes, model events, and soft deletes require extra configuration. ## Sutando: A Cleaner Alternative Sutando solves these problems while keeping the Active Record pattern that TypeORM users are familiar with. ### No Decorators Required Sutando models use plain class properties instead of decorators: ```ts // Sutando: clean, no decorators class User extends Model { table = 'users'; casts = { is_admin: 'boolean', metadata: 'json', }; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ```ts // TypeORM: decorator-heavy @Entity() class User extends BaseEntity { @PrimaryGeneratedColumn() id: number; @Column() name: string; @Column({ type: 'boolean' }) isAdmin: boolean; @OneToMany(() => Post, post => post.user) posts: Post[]; } ``` ### Query Comparison **Sutando:** ```ts // Clean method chaining const users = await User.query() .where('active', true) .with('posts') .orderBy('created_at', 'desc') .limit(10) .get(); ``` **TypeORM (Active Record):** ```ts const users = await User.createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'posts') .where('user.active = :active', { active: true }) .orderBy('user.created_at', 'DESC') .take(10) .getMany(); ``` ### Relationships **Sutando** defines relationships as methods that return relation objects: ```ts class User extends Model { relationPosts() { return this.hasMany(Post, 'user_id'); } relationProfile() { return this.hasOne(Profile); } relationRoles() { return this.belongsToMany(Role, 'user_roles', 'user_id', 'role_id'); } } // Usage const user = await User.query().with('posts', 'profile', 'roles').find(1); ``` **TypeORM** uses decorators: ```ts @Entity() class User extends BaseEntity { @OneToMany(() => Post, post => post.user) posts: Post[]; @OneToOne(() => Profile, profile => profile.user) profile: Profile; @ManyToMany(() => Role) @JoinTable({ name: 'user_roles' }) roles: Role[]; } // Usage const user = await User.createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'posts') .leftJoinAndSelect('user.profile', 'profile') .leftJoinAndSelect('user.roles', 'roles') .where('user.id = :id', { id: 1 }) .getOne(); ``` ## Feature Comparison | Feature | Sutando | TypeORM | |---------|---------|---------| | Pattern | Active Record | Active Record + Data Mapper | | Decorators | Not required | Required | | Schema definition | Class properties | Decorators (`@Entity`, `@Column`) | | Query builder | Method chaining | String-based QueryBuilder | | Relationships | Method-based (`hasMany`, etc.) | Decorator-based (`@OneToMany`, etc.) | | Eager loading | `with('posts.comments')` | `leftJoinAndSelect` (manual) | | Model events | Built-in | Subscriber / Listener classes | | Soft deletes | Built-in | `@DeleteDateColumn` decorator | | Query scopes | Built-in | Not available | | Migrations | Schema builder | Migration classes | | Bundle size | Small | Large | | Maintenance | Active | Slow | | Database support | MySQL, PostgreSQL, SQLite | MySQL, PostgreSQL, SQLite, SQL Server, MongoDB | ## Migration Guide: TypeORM to Sutando ### Step 1: Replace Entity Definitions ```ts // Before (TypeORM) @Entity() class User extends BaseEntity { @PrimaryGeneratedColumn() id: number; @Column() name: string; @Column({ unique: true }) email: string; @CreateDateColumn() createdAt: Date; @OneToMany(() => Post, post => post.user) posts: Post[]; } // After (Sutando) class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ### Step 2: Replace Queries ```ts // Before (TypeORM) const user = await User.findOne({ where: { id: 1 }, relations: ['posts'] }); const users = await User.find({ where: { active: true }, take: 10 }); // After (Sutando) const user = await User.query().with('posts').find(1); const users = await User.query().where('active', true).limit(10).get(); ``` ### Step 3: Replace Create/Update/Delete ```ts // Before (TypeORM) const user = User.create({ name: 'Alice', email: 'alice@example.com' }); await user.save(); // After (Sutando) const user = new User(); user.name = 'Alice'; user.email = 'alice@example.com'; await user.save(); // or: const user = await User.create({ name: 'Alice', email: 'alice@example.com' }); ``` ## When to Switch from TypeORM to Sutando * You're tired of decorator-related issues * You want cleaner, more readable query syntax * You need built-in query scopes and model events * You want a more actively maintained ORM * You're looking for a Laravel Eloquent-like experience * You want to reduce bundle size ## Conclusion TypeORM served the Node.js community well, but its decorator dependency, string-based queries, and maintenance issues make it hard to recommend for new projects. Sutando offers the same Active Record pattern with a cleaner API, no decorator requirement, and built-in features that TypeORM lacks. If you're starting a new project or considering a TypeORM replacement, give Sutando a try: `npm install sutando` — or read the [documentation](https://sutando.org/guide/getting-started.html). --- --- url: /ja/blog/posts/sutando-vs-typeorm-modern-active-record-alternative.md --- TypeORM は長年 Node.js で最も人気のある ORM の一つでしたが、デコレーター依存やメンテナンス問題により、多くの開発者が代替を探しています。Sutando はその代替となります。 ## TypeORM の問題点 ### 1. デコレーター依存 TypeORM は `@Entity`、`@Column`、`@OneToMany` などのデコレーターに大きく依存しています: ```ts // TypeORM: デコレーターだらけ @Entity() class User { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToMany(() => Post, post => post.user) posts: Post[]; } ``` これにより、`tsconfig` の `emitDecoratorMetadata` と `experimentalDecorators` が必要になり、バンドルサイズも増加します。 ### 2. 文字列ベースのクエリビルダー ```ts // TypeORM: 文字列ベース const posts = await repository .createQueryBuilder('post') .leftJoinAndSelect('post.user', 'user') .where('post.published = :published', { published: true }) .orderBy('post.created_at', 'DESC') .getMany(); ``` ### 3. メンテナンスの停滞 TypeORM のリリースサイクルは遅く、バグ修正に時間がかかることが多いです。 ## Sutando:よりクリーンな代替 ### デコレーター不要 ```ts // Sutando: プレーンなクラスプロパティ class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ### メソッドチェーンのクエリ ```ts // Sutando const posts = await Post.query() .with('user') .where('published', true) .orderBy('created_at', 'desc') .get(); ``` ## 機能比較 | 機能 | Sutando | TypeORM | |---------|---------|---------| | パターン | Active Record | Active Record + Data Mapper | | デコレーター | 不要 | 必須 | | スキーマ定義 | クラスプロパティ | デコレーター(`@Entity`, `@Column`) | | クエリビルダー | メソッドチェーン | 文字列ベース | | リレーション | メソッドベース(`hasMany` など) | デコレーターベース(`@OneToMany` など) | | ソフトデリート | 組み込み | 手動実装 | | モデルイベント | 組み込み | `Subscriber` パターン | | クエリスコープ | 組み込み | サポートなし | ## TypeORM から Sutando への移行 ### モデルの変換 ```ts // Before: TypeORM @Entity() class Post { @PrimaryGeneratedColumn() id: number; @Column() title: string; @Column({ default: false }) published: boolean; @ManyToOne(() => User) user: User; } // After: Sutando class Post extends Model { table = 'posts'; casts = { published: 'boolean' }; relationUser() { return this.belongsTo(User, 'user_id'); } } ``` ### クエリの変換 ```ts // Before: TypeORM const posts = await repository .createQueryBuilder('post') .leftJoinAndSelect('post.user', 'user') .where('post.published = :val', { val: true }) .getMany(); // After: Sutando const posts = await Post.query() .with('user') .where('published', true) .get(); ``` ## 結論 TypeORM は Node.js コミュニティに貢献してきましたが、デコレーター依存、文字列ベースのクエリ、メンテナンスの停滞により、新規プロジェクトには推奨しづらい状況です。Sutando は同じ Active Record パターンを提供しつつ、よりクリーンな API と TypeORM に欠けている機能を備えています。 `npm install sutando` — または [ドキュメント](https://sutando.org/ja/guide/getting-started.html) をお読みください。 --- --- url: /zh_CN/blog/posts/sutando-vs-typeorm-modern-active-record-alternative.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** TypeORM 在 Node.js 生态中存在多年。但随着 TypeScript 的进化和新 ORM 的涌现,TypeORM 的局限性越来越难以忽视。本文将对比 TypeORM 与 Sutando,说明为什么开发者正在切换。 ## TypeORM 的问题 在对比功能之前,先看看开发者为什么寻找 TypeORM 的替代品: ### 1. 装饰器不稳定 TypeORM 严重依赖 TypeScript 装饰器(`@Entity`、`@Column`、`@OneToMany`)。TC39 装饰器提案多次变更,而 TypeORM 仍在使用旧的实验性装饰器语法,这带来了持续的兼容性隐患。 ### 2. 维护缓慢 TypeORM 的开发节奏明显放缓。Issue 不断堆积,PR 需要数月才能合并,社区对缺乏积极维护表示不满。 ### 3. QueryBuilder 基于字符串 TypeORM 的 QueryBuilder 使用字符串形式的列名和表名,拼写错误只能在运行时发现: ```ts // TypeORM:基于字符串——拼写错误运行时才报错 const users = await getRepository(User) .createQueryBuilder('user') .where('user.namme = :name', { name: 'Alice' }) // 拼写错误:"namme" .getMany(); ``` ## Sutando:更干净的替代方案 Sutando 解决了这些问题,同时保留了 TypeORM 用户熟悉的 Active Record 模式。 ### 无需装饰器 ```ts // Sutando:干净,无装饰器 class User extends Model { table = 'users'; casts = { is_admin: 'boolean', metadata: 'json', }; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ```ts // TypeORM:装饰器繁多 @Entity() class User extends BaseEntity { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToMany(() => Post, post => post.user) posts: Post[]; } ``` ### 查询对比 **Sutando:** ```ts const users = await User.query() .where('active', true) .with('posts') .orderBy('created_at', 'desc') .limit(10) .get(); ``` **TypeORM:** ```ts const users = await User.createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'posts') .where('user.active = :active', { active: true }) .orderBy('user.created_at', 'DESC') .take(10) .getMany(); ``` ## 功能对比 | 功能 | Sutando | TypeORM | |---------|---------|---------| | 装饰器 | 不需要 | 必须 | | 查询构造器 | 方法链 | 基于字符串 | | 关联 | 方法(`hasMany` 等) | 装饰器(`@OneToMany` 等) | | 预加载 | `with('posts.comments')` | `leftJoinAndSelect`(手动) | | 模型事件 | 内置 | Subscriber / Listener 类 | | 软删除 | 内置 | `@DeleteDateColumn` 装饰器 | | 查询作用域 | 内置 | 无 | | 维护 | 积极 | 缓慢 | | 包体积 | 小 | 大 | ## 从 TypeORM 迁移到 Sutando ### 第 1 步:替换实体定义 ```ts // 之前(TypeORM) @Entity() class User extends BaseEntity { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToMany(() => Post, post => post.user) posts: Post[]; } // 之后(Sutando) class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ### 第 2 步:替换查询 ```ts // 之前(TypeORM) const user = await User.findOne({ where: { id: 1 }, relations: ['posts'] }); // 之后(Sutando) const user = await User.query().with('posts').find(1); ``` ### 第 3 步:替换增删改 ```ts // 之前(TypeORM) const user = User.create({ name: 'Alice' }); await user.save(); // 之后(Sutando) const user = new User(); user.name = 'Alice'; await user.save(); ``` ## 何时从 TypeORM 切换到 Sutando * 你厌倦了装饰器相关的问题 * 你想要更简洁、可读性更强的查询语法 * 你需要内置的查询作用域和模型事件 * 你想要一个更积极维护的 ORM * 你在寻找 Laravel Eloquent 风格的体验 ## 总结 TypeORM 曾为 Node.js 社区做出了贡献,但它对装饰器的依赖、基于字符串的查询和维护问题使其难以推荐用于新项目。Sutando 提供了同样的 Active Record 模式,但 API 更干净、无需装饰器,且内置了 TypeORM 缺少的功能。 试试 Sutando:`npm install sutando`——或阅读[文档](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /ja/blog/posts/database-migrations-with-sutando-complete-guide.md --- データベーススキーマの管理は、アプリケーション開発において重要な要素です。Sutando のスキーマビルダーとマイグレーション機能を使って、バージョン管理可能なスキーマ管理を実現しましょう。 ## スキーマビルダーの基本 Sutando のスキーマビルダーは、Knex.js の上に構築されており、データベースに依存しないスキーマ操作を提供します。 ### テーブルの作成 ```ts import { sutando } from 'sutando'; await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('email').notNullable().unique(); table.string('password'); table.boolean('is_admin').defaultTo(false); table.timestamps(); }); ``` ### カラムタイプ ```ts await sutando.schema().createTable('posts', table => { table.increments('id').primary(); table.string('title', 200).notNullable(); table.text('content'); table.integer('user_id').unsigned().references('id').inTable('users'); table.boolean('published').defaultTo(false); table.json('metadata'); table.timestamp('published_at').nullable(); table.timestamps(); }); ``` ### テーブルの変更 ```ts await sutando.schema().alterTable('users', table => { table.string('avatar').nullable(); table.dropColumn('password'); table.renameColumn('name', 'display_name'); }); ``` ### テーブルの削除 ```ts await sutando.schema().dropTableIfExists('old_table'); ``` ## マイグレーションファイル Sutando では、マイグレーションをファイルとして管理できます: ```ts // migrations/001_create_users_table.ts import { sutando } from 'sutando'; export async function up() { await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('email').notNullable().unique(); table.timestamps(); }); } export async function down() { await sutando.schema().dropTableIfExists('users'); } ``` ```ts // migrations/002_create_posts_table.ts import { sutando } from 'sutando'; export async function up() { await sutando.schema().createTable('posts', table => { table.increments('id').primary(); table.string('title').notNullable(); table.text('content'); table.integer('user_id').unsigned().references('id').inTable('users'); table.boolean('published').defaultTo(false); table.timestamps(); }); } export async function down() { await sutando.schema().dropTableIfExists('posts'); } ``` ## インデックス ```ts await sutando.schema().alterTable('posts', table => { table.index(['user_id', 'published'], 'posts_user_published_index'); }); ``` ## 外部キー ```ts await sutando.schema().createTable('comments', table => { table.increments('id').primary(); table.text('content').notNullable(); table.integer('post_id').unsigned() .references('id').inTable('posts') .onDelete('CASCADE'); table.integer('user_id').unsigned() .references('id').inTable('users'); table.timestamps(); }); ``` ## まとめ Sutando のスキーマビルダーを使えば、データベースに依存しないマイグレーションを簡単に作成できます。バージョン管理とロールバック機能で、チーム開発でも安全にスキーマを管理できます。 [ドキュメント](https://sutando.org/ja/guide/migrations.html) でさらに詳しく。 --- --- url: /ja/blog/posts/building-rest-api-with-sutando-and-express.md --- Sutando と Express を使って、本格的な REST API を構築する方法を解説します。バリデーション、エラーハンドリング、リレーション、ページネーションまでカバーします。 ## セットアップ ```bash mkdir blog-api && cd blog-api npm init -y npm install sutando mysql2 express npm install zod # バリデーション用 ``` ## データベース設定 ```ts import { sutando } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', user: 'root', password: '', database: 'blog_api' } }); export default sutando; ``` ## モデル定義 ```ts import { Model, SoftDeletes } from 'sutando'; class User extends Model { table = 'users'; hidden = ['password']; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { use = [SoftDeletes]; table = 'posts'; casts = { published: 'boolean' }; relationUser() { return this.belongsTo(User, 'user_id'); } relationComments() { return this.hasMany(Comment); } scopePublished(query) { return query.where('published', true); } } class Comment extends Model { table = 'comments'; relationUser() { return this.belongsTo(User, 'user_id'); } } export { User, Post, Comment }; ``` ## API ルート ```ts import express from 'express'; import { z } from 'zod'; import './database'; import { User, Post, Comment } from './models'; const app = express(); app.use(express.json()); // 記事一覧(ページネーション付き) app.get('/posts', async (req, res) => { const page = Number(req.query.page) || 1; const posts = await Post.query() .with('user') .published() .orderBy('created_at', 'desc') .paginate(15, page); res.json(posts); }); // 記事詳細 app.get('/posts/:id', async (req, res) => { const post = await Post.query() .with('user', 'comments.user') .find(req.params.id); if (!post) return res.status(404).json({ error: '記事が見つかりません' }); res.json(post); }); // 記事作成 const createPostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), user_id: z.number().int().positive(), published: z.boolean().optional(), }); app.post('/posts', async (req, res) => { const parsed = createPostSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ errors: parsed.error.issues }); } const post = await Post.create(parsed.data); res.status(201).json(post); }); // 記事更新 app.put('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: '記事が見つかりません' }); post.fill(req.body); await post.save(); res.json(post); }); // 記事削除(ソフトデリート) app.delete('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: '記事が見つかりません' }); await post.delete(); res.json({ success: true }); }); // コメント作成 app.post('/posts/:id/comments', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: '記事が見つかりません' }); const comment = await post.comments().create({ content: req.body.content, user_id: req.body.userId, }); res.status(201).json(comment); }); app.listen(3000, () => console.log('API server running on port 3000')); ``` ## エラーハンドリング ```ts // グローバルエラーハンドラー app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ error: 'サーバーエラーが発生しました' }); }); ``` ## まとめ Sutando と Express の組み合わせで、シンプルかつ強力な REST API を構築できます。Active Record パターンにより、コード量が少なく、可読性も高いです。 [ドキュメント](https://sutando.org/ja/guide/getting-started.html) を参照してさらに学びましょう。 --- --- url: /ja/blog/posts/using-sutando-with-nextjs-server-side-database-access.md --- Next.js App Router と Sutando を組み合わせて、サーバーコンポーネントからデータベースに直接アクセスする方法を解説します。 ## セットアップ ```bash npx create-next-app@latest my-app --typescript cd my-app npm install sutando mysql2 ``` ## データベース設定 ```ts // lib/database.ts import { sutando } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', user: 'root', password: '', database: 'my_app' } }); export default sutando; ``` ## モデル定義 ```ts // lib/models.ts import { Model } from 'sutando'; import './database'; class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; casts = { published: 'boolean' }; relationUser() { return this.belongsTo(User, 'user_id'); } scopePublished(query) { return query.where('published', true); } } export { User, Post }; ``` ## サーバーコンポーネントでの使用 ```tsx // app/blog/page.tsx import { Post } from '@/lib/models'; export default async function BlogPage() { const posts = await Post.query() .with('user') .published() .orderBy('created_at', 'desc') .limit(10) .get(); return (

ブログ

{posts.map(post => (

{post.title}

著者: {post.user.name}

{post.content}

))}
); } ``` ## 記事詳細ページ ```tsx // app/blog/[slug]/page.tsx import { Post } from '@/lib/models'; import { notFound } from 'next/navigation'; export default async function PostPage({ params }: { params: { slug: string } }) { const post = await Post.query() .with('user', 'comments.user') .where('slug', params.slug) .first(); if (!post) notFound(); return (

{post.title}

著者: {post.user.name}

{post.content}

コメント

{post.comments.map(comment => (

{comment.user.name}

{comment.content}

))}
); } ``` ## Route Handlers での使用 ```ts // app/api/posts/route.ts import { NextRequest, NextResponse } from 'next/server'; import { Post } from '@/lib/models'; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = Number(searchParams.get('page')) || 1; const posts = await Post.query() .with('user') .published() .orderBy('created_at', 'desc') .paginate(15, page); return NextResponse.json(posts); } export async function POST(request: NextRequest) { const body = await request.json(); const post = await Post.create(body); return NextResponse.json(post, { status: 201 }); } ``` ## まとめ Next.js App Router と Sutando の組み合わせで、サーバーコンポーネントから直接データベースにアクセスできます。API ルートを別途用意する必要がなく、シンプルなアーキテクチャが実現できます。 [ドキュメント](https://sutando.org/ja/guide/getting-started.html) でさらに詳しく。 --- --- url: /ja/blog/posts/soft-deletes-hooks-and-scopes-in-sutando.md --- Sutando の強力な機能であるソフトデリート、モデルイベント(フック)、クエリスコープについて解説します。 ## ソフトデリート ソフトデリートは、レコードを実際に削除するのではなく、`deleted_at` カラムに日付を設定して論理削除を行う機能です。 ### 設定 ```ts import { Model, SoftDeletes } from 'sutando'; class Post extends Model { use = [SoftDeletes]; table = 'posts'; } ``` ### 使用例 ```ts // ソフトデリート const post = await Post.find(1); await post.delete(); // deleted_at に日付が設定される // ソフトデリートされたレコードも含めて取得 const allPosts = await Post.query().withTrashed().get(); // ソフトデリートされたレコードのみ取得 const trashedPosts = await Post.query().onlyTrashed().get(); // 復元 const trashedPost = await Post.query().onlyTrashed().find(1); await trashedPost.restore(); // 完全に削除 await post.forceDelete(); ``` ## モデルイベント(フック) モデルのライフサイクルイベントにフックして、カスタムロジックを実行できます。 ### 利用可能なイベント * `creating` — 作成前 * `created` — 作成後 * `updating` — 更新前 * `updated` — 更新後 * `saving` — 保存前(作成・更新共通) * `saved` — 保存後 * `deleting` — 削除前 * `deleted` — 削除後 ### 使用例 ```ts // スラグの自動生成 Post.creating(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); // 更新日時の記録 Post.updating(async (post) => { post.updated_at = new Date(); }); // 削除時のログ記録 Post.deleting(async (post) => { console.log(`記事を削除: ${post.title}`); }); ``` ## クエリスコープ クエリスコープを使うと、再利用可能なクエリ条件を定義できます。 ### ローカルスコープ ```ts class Post extends Model { table = 'posts'; scopePublished(query) { return query.where('published', true); } scopeRecent(query) { return query.orderBy('created_at', 'desc'); } scopeByUser(query, userId) { return query.where('user_id', userId); } } // 使用例 const posts = await Post.query().published().recent().get(); const userPosts = await Post.query().byUser(1).published().get(); ``` ### グローバルスコープ ```ts class Post extends Model { table = 'posts'; static booted = false; static boot() { super.boot(); if (!Post.booted) { Post.addGlobalScope('published', (query) => { query.where('published', true); }); Post.booted = true; } } } // すべてのクエリに published = true が自動的に適用される const posts = await Post.query().get(); ``` ## まとめ ソフトデリート、フック、クエリスコープを組み合わせることで、データ整合性を保ちながら開発効率を大幅に向上できます。これらの機能は Laravel Eloquent から直接インスピレーションを得ており、直感的に使えます。 [ドキュメント](https://sutando.org/ja/guide/hooks.html) でさらに詳しく。 --- --- url: /ja/blog/posts/model-relationships-in-sutando-hasmany-belongsto-and-beyond.md --- モデルリレーションは ORM の最も強力な機能の一つです。Sutando では、Laravel Eloquent ライクな直感的な API でリレーションを定義・操作できます。 ## リレーションタイプ Sutando は以下のリレーションタイプをサポートしています: * **hasOne** — 1対1 * **hasMany** — 1対多 * **belongsTo** — 多対1(逆方向) * **belongsToMany** — 多対多 ## 1対多(hasMany / belongsTo) 最も一般的なリレーションです。ユーザーが複数の投稿を持つ例: ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; relationUser() { return this.belongsTo(User, 'user_id'); } } ``` ### 使用例 ```ts // ユーザーの投稿を取得 const user = await User.find(1); const posts = await user.posts; // リレーション経由で作成 const post = await user.posts().create({ title: '新しい投稿', content: '内容', }); // 投稿からユーザーを取得 const post = await Post.find(1); const author = await post.user; ``` ## 1対1(hasOne) ```ts class User extends Model { table = 'users'; relationProfile() { return this.hasOne(Profile, 'user_id'); } } // 使用例 const user = await User.find(1); const profile = await user.profile; ``` ## 多対多(belongsToMany) ユーザーとロールの例: ```ts class User extends Model { table = 'users'; relationRoles() { return this.belongsToMany(Role, 'role_user', 'user_id', 'role_id'); } } class Role extends Model { table = 'roles'; relationUsers() { return this.belongsToMany(User, 'role_user', 'role_id', 'user_id'); } } ``` ### 使用例 ```ts // ユーザーのロールを取得 const user = await User.find(1); const roles = await user.roles; // ロールを付与 await user.roles().attach([1, 2, 3]); // ロールを解除 await user.roles().detach([2]); // ロールを同期(不要なものは削除) await user.roles().sync([1, 3]); ``` ## 事前読み込み(Eager Loading) N+1 問題を回避するために、事前読み込みを使います: ```ts // N+1 問題(非推奨) const users = await User.query().get(); for (const user of users) { const posts = await user.posts; // 各ユーザーごとにクエリが実行される } // 事前読み込み(推奨) const users = await User.query().with('posts').get(); // 合計2クエリ:ユーザー1回 + 投稿1回 // ネストされた事前読み込み const users = await User.query() .with('posts.comments.user') .get(); ``` ## 遅延読み込み(Lazy Loading) ```ts const user = await User.find(1); const posts = await user.posts; // ここでクエリが実行される ``` ## 条件付きリレーション ```ts // 公開済みの投稿のみを事前読み込み const users = await User.query() .with({ posts: query => query.where('published', true) }) .get(); ``` ## リレーションのカウント ```ts // 投稿数と一緒にユーザーを取得 const users = await User.query() .withCount('posts') .get(); console.log(users[0].posts_count); ``` ## まとめ Sutando のリレーション API は、Laravel Eloquent の直感的な設計を引き継いでいます。宣言的なメソッドでリレーションを定義し、事前読み込みでパフォーマンスを最適化できます。 [ドキュメント](https://sutando.org/ja/guide/relationships.html) でさらに詳しく。 --- --- url: /ja/blog/posts/sutando-plugins-extending-your-orm.md --- Sutando はプラグインシステムで機能を拡張できます。独自のプラグインを作成して、モデルに新しいメソッドや機能を追加する方法を解説します。 ## プラグインの基本 Sutando のプラグインは、モデルにミックスインとして機能を追加します。 ### シンプルなプラグイン ```ts import { Model } from 'sutando'; // UUID プラグイン const UUID = (Model) => { return class extends Model { static booted = false; static boot() { super.boot(); if (!this.booted) { this.creating((model) => { if (!model.id) { model.id = crypto.randomUUID(); } }); this.booted = true; } } }; }; // 使用例 class User extends UUID(Model) { table = 'users'; } ``` ## 実践的なプラグイン例 ### スラグ生成プラグイン ```ts const Sluggable = (options = {}) => (Model) => { return class extends Model { static booted = false; static boot() { super.boot(); if (!this.booted) { const source = options.source || 'title'; const target = options.target || 'slug'; this.creating(async (model) => { if (!model[target]) { model[target] = model[source] .toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-'); } }); this.booted = true; } } }; }; // 使用例 class Post extends Sluggable({ source: 'title' })(Model) { table = 'posts'; } ``` ### 監査ログプラグイン ```ts const Auditable = (Model) => { return class extends Model { static booted = false; static boot() { super.boot(); if (!this.booted) { this.creating((model) => { model.created_by = getCurrentUserId(); }); this.updating((model) => { model.updated_by = getCurrentUserId(); }); this.booted = true; } } }; }; ``` ## まとめ Sutando のプラグインシステムを使えば、プロジェクト間で再利用可能な機能を簡単に作成できます。ミックスインパターンにより、柔軟にモデルを拡張できます。 [ドキュメント](https://sutando.org/ja/guide/plugin.html) でさらに詳しく。 --- --- url: /zh_CN/blog/posts/soft-deletes-hooks-and-scopes-in-sutando.md --- Sutando 包含几个超越基础 CRUD 的强大功能:软删除、模型事件(钩子)和查询作用域。 ## 软删除 软删除允许你"删除"记录而不真正从数据库中移除。`deleted_at` 列标记记录为已删除。 ### 设置 ```ts import { Model, SoftDeletes } from 'sutando'; class Post extends Model { table = 'posts'; use = [SoftDeletes]; } ``` ### 使用 ```ts // 软删除——设置 deleted_at 而非删除 await post.delete(); // 普通查询排除已删除记录 const posts = await Post.query().get(); // 包含已删除记录 const allPosts = await Post.query().withTrashed().get(); // 仅已删除记录 const trashed = await Post.query().onlyTrashed().get(); // 恢复 const post = await Post.query().withTrashed().find(1); await post.restore(); // 永久删除 await post.forceDelete(); ``` ## 模型事件(钩子) | 事件 | 时机 | |-------|------| | `creating` | 创建记录前 | | `created` | 创建记录后 | | `updating` | 更新记录前 | | `saving` | 保存前(创建或更新) | | `deleting` | 删除记录前 | | `restoring` | 恢复软删除记录前 | ### 注册钩子 ```ts // 创建用户前加密密码 User.creating(async (user) => { user.password = await bcrypt.hash(user.password, 10); }); // 保存文章前生成 slug Post.saving(async (post) => { post.slug = post.title.toLowerCase().replace(/\s+/g, '-'); }); // 删除文章时级联删除评论 Post.deleting(async (post) => { await post.comments().delete(); }); ``` ### 中止操作 从 `creating` 或 `updating` 钩子返回 `false` 可中止操作: ```ts User.creating(async (user) => { const exists = await User.query().where('email', user.email).exists(); if (exists) return false; }); ``` ## 查询作用域 ### 定义作用域 ```ts class Post extends Model { scopePublished(query) { return query.where('published', true); } scopePopular(query) { return query.where('views', '>', 1000); } scopeRecent(query, days = 7) { return query.where('created_at', '>', new Date(Date.now() - days * 86400000)); } } ``` ### 使用作用域 ```ts // 链式调用多个作用域 const posts = await Post.query() .published() .popular() .recent(30) .orderBy('views', 'desc') .limit(10) .get(); ``` ### 全局作用域 ```ts class Post extends Model { static booted() { super.booted(); this.addGlobalScope('published', (query) => { query.where('published', true); }); } } // 跳过全局作用域 const allPosts = await Post.query().withoutGlobalScope('published').get(); ``` ## 总结 软删除、模型事件和查询作用域让 Sutando 不仅仅是一个查询构造器。它们让你将业务逻辑直接编码到模型中,保持控制器简洁、数据一致。 了解更多请查看 [Sutando 文档](https://sutando.org/zh_CN/guide/soft-deletes.html)。 --- --- url: /ja/blog/posts/getting-started-with-sutando-build-your-first-nodejs-app.md --- Sutando を初めて使う?このチュートリアルでは、インストールから CRUD API の構築まで、すべてを順を追って説明します。 ## 前提条件 * Node.js 18+ がインストール済み * データベース(MySQL、PostgreSQL、または SQLite) * 基本的な JavaScript/TypeScript の知識 ## ステップ 1:インストール ```bash mkdir my-app && cd my-app npm init -y npm install sutando ``` データベースドライバーをインストール: ```bash # MySQL npm install mysql2 # PostgreSQL npm install pg # SQLite npm install better-sqlite3 ``` ## ステップ 2:データベース設定 `database.ts` ファイルを作成して接続を設定: ```ts import { sutando } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', port: 3306, user: 'root', password: '', database: 'my_app' } }); export default sutando; ``` ### 複数接続の使用 ```ts sutando.addConnection({ /* ... */ }, 'primary'); sutando.addConnection({ /* ... */ }, 'replica'); // 特定の接続を使用 const db = sutando.connection('replica'); ``` ## ステップ 3:スキーマビルダーでテーブル作成 Sutando にはスキーマビルダーが組み込まれています: ```ts import { sutando } from './database'; async function setup() { await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('email').notNullable().unique(); table.string('password'); table.timestamps(); }); await sutando.schema().createTable('posts', table => { table.increments('id').primary(); table.string('title').notNullable(); table.text('content'); table.integer('user_id').unsigned().references('id').inTable('users'); table.boolean('published').defaultTo(false); table.timestamps(); }); } setup(); ``` ## ステップ 4:モデルの定義 モデルは Sutando の心臓部です。各モデルはデータベーステーブルに対応します: ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; casts = { is_admin: 'boolean', metadata: 'json', }; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; casts = { published: 'boolean', }; relationUser() { return this.belongsTo(User, 'user_id'); } scopePublished(query) { return query.where('published', true); } } export { User, Post }; ``` ## ステップ 5:基本的な CRUD 操作 ### 作成 ```ts const user = new User(); user.name = 'Alice'; user.email = 'alice@example.com'; user.password = 'hashed_password'; await user.save(); const post = await Post.create({ title: '最初の投稿', content: 'Hello World!', user_id: user.id, published: true, }); ``` ### 読み取り ```ts const posts = await Post.query().get(); const post = await Post.find(1); const published = await Post.query() .where('published', true) .orderBy('created_at', 'desc') .limit(10) .get(); const count = await Post.query().where('published', true).count(); ``` ### 更新 ```ts const post = await Post.find(1); post.title = '更新されたタイトル'; post.published = true; await post.save(); await Post.query().where('user_id', 1).update({ published: true }); ``` ### 削除 ```ts const post = await Post.find(1); await post.delete(); await Post.query().where('published', false).delete(); ``` ## ステップ 6:リレーションの操作 ### 事前読み込み ```ts const user = await User.query().with('posts').find(1); console.log(user.posts); const users = await User.query().with('posts.comments').get(); ``` ### リレーション先のレコード作成 ```ts const user = await User.find(1); const post = await user.posts().create({ title: '新しい投稿', content: 'コンテンツ', }); ``` ## ステップ 7:すべてを組み合わせる ```ts import express from 'express'; import './database'; import { User, Post } from './models'; const app = express(); app.use(express.json()); app.get('/posts', async (req, res) => { const posts = await Post.query() .with('user') .published() .orderBy('created_at', 'desc') .limit(20) .get(); res.json(posts); }); app.get('/posts/:id', async (req, res) => { const post = await Post.query().with('user', 'comments').find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); res.json(post); }); app.post('/posts', async (req, res) => { const post = await Post.create(req.body); res.status(201).json(post); }); app.put('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); post.fill(req.body); await post.save(); res.json(post); }); app.delete('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); await post.delete(); res.json({ success: true }); }); app.listen(3000, () => console.log('Server running on port 3000')); ``` ## 次のステップ * [モデルリレーション](https://sutando.org/ja/guide/relationships.html) * [マイグレーション](https://sutando.org/ja/guide/migrations.html) * [モデルイベント](https://sutando.org/ja/guide/hooks.html) Sutando を使った Node.js アプリが完成しました。完全なドキュメントは [sutando.org](https://sutando.org/ja/guide/getting-started.html) にあります。 --- --- url: /zh_CN/blog/posts/getting-started-with-sutando-build-your-first-nodejs-app.md --- !\[image]\(https://storage.sutando.org/og-1751395044936.jpg *** 刚接触 Sutando?本教程将带你从安装到构建一个可用的 CRUD API,一步步上手 Sutando ORM。 ## 前置条件 * Node.js 18+ 已安装 * 一个数据库(MySQL、PostgreSQL 或 SQLite) * 基本的 JavaScript/TypeScript 知识 ## 第 1 步:安装 创建新项目并安装 Sutando: ```bash mkdir my-app && cd my-app npm init -y npm install sutando ``` 安装数据库驱动: ```bash # MySQL npm install mysql2 # PostgreSQL npm install pg # SQLite npm install better-sqlite3 ``` ## 第 2 步:数据库配置 创建 `database.ts` 文件配置连接: ```ts import { sutando } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', port: 3306, user: 'root', password: '', database: 'my_app' } }); export default sutando; ``` ### 多连接配置 ```ts sutando.addConnection({ /* ... */ }, 'primary'); sutando.addConnection({ /* ... */ }, 'replica'); // 使用指定连接 const db = sutando.connection('replica'); ``` ## 第 3 步:用 Schema 构造器创建表 Sutando 内置 Schema 构造器,可以创建和修改表结构: ```ts import { sutando } from './database'; async function setup() { await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('email').notNullable().unique(); table.string('password'); table.timestamps(); }); await sutando.schema().createTable('posts', table => { table.increments('id').primary(); table.string('title').notNullable(); table.text('content'); table.integer('user_id').unsigned().references('id').inTable('users'); table.boolean('published').defaultTo(false); table.timestamps(); }); } setup(); ``` ## 第 4 步:定义模型 模型是 Sutando 的核心,每个模型对应一张数据库表: ```ts import { Model } from 'sutando'; class User extends Model { table = 'users'; // 类型转换 casts = { is_admin: 'boolean', metadata: 'json', }; // 关联 relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; casts = { published: 'boolean', }; relationUser() { return this.belongsTo(User, 'user_id'); } // 查询作用域 scopePublished(query) { return query.where('published', true); } } export { User, Post }; ``` ## 第 5 步:基础 CRUD 操作 ### 创建 ```ts // 创建新用户 const user = new User(); user.name = 'Alice'; user.email = 'alice@example.com'; user.password = 'hashed_password'; await user.save(); // 或使用 create 方法 const post = await Post.create({ title: '我的第一篇文章', content: 'Hello World!', user_id: user.id, published: true, }); ``` ### 查询 ```ts // 获取所有文章 const posts = await Post.query().get(); // 按 ID 查找 const post = await Post.find(1); // 条件查询 const published = await Post.query() .where('published', true) .orderBy('created_at', 'desc') .limit(10) .get(); // 查找第一条匹配 const first = await Post.query().where('title', '我的第一篇文章').first(); // 计数 const count = await Post.query().where('published', true).count(); ``` ### 更新 ```ts const post = await Post.find(1); post.title = '更新后的标题'; post.published = true; await post.save(); // 批量更新 await Post.query().where('user_id', 1).update({ published: true }); ``` ### 删除 ```ts const post = await Post.find(1); await post.delete(); // 批量删除 await Post.query().where('published', false).delete(); ``` ## 第 6 步:使用关联 ### 预加载 ```ts // 加载用户及其文章(总共 2 次查询) const user = await User.query().with('posts').find(1); console.log(user.posts); // Post 模型数组 // 嵌套预加载 const users = await User.query() .with('posts.comments') .get(); ``` ### 创建关联记录 ```ts const user = await User.find(1); const post = await user.posts().create({ title: '新文章', content: '内容', }); ``` ### 查询关联 ```ts const user = await User.find(1); const publishedPosts = await user.posts().where('published', true).get(); ``` ## 第 7 步:整合到一起 完整的 Express API: ```ts import express from 'express'; import './database'; import { User, Post } from './models'; const app = express(); app.use(express.json()); // 文章列表 app.get('/posts', async (req, res) => { const posts = await Post.query() .with('user') .published() .orderBy('created_at', 'desc') .limit(20) .get(); res.json(posts); }); // 文章详情 app.get('/posts/:id', async (req, res) => { const post = await Post.query() .with('user', 'comments') .find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); res.json(post); }); // 创建文章 app.post('/posts', async (req, res) => { const post = await Post.create(req.body); res.status(201).json(post); }); // 更新文章 app.put('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); post.fill(req.body); await post.save(); res.json(post); }); // 删除文章 app.delete('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: 'Not found' }); await post.delete(); res.json({ success: true }); }); app.listen(3000, () => console.log('服务器运行在 3000 端口')); ``` ## 下一步 * [模型关联](https://sutando.org/zh_CN/guide/relationships.html) — 掌握 `hasMany`、`belongsTo` 等 * [数据库迁移](https://sutando.org/zh_CN/guide/migrations.html) — 版本控制你的表结构 * [模型事件](https://sutando.org/zh_CN/guide/events.html) — 在模型生命周期中插入逻辑 你现在已经有了一个可用的 Sutando Node.js 应用。完整文档请访问 [sutando.org](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /zh_CN/blog/posts/sutando-plugins-extending-your-orm.md --- Sutando 设计为可扩展的。无论你需要自定义类型转换、全局查询修改还是可复用的模型行为,Sutando 的插件系统都能满足。 ## 自定义类型转换 ```ts import { Model, Cast } from 'sutando'; class EncryptedCast extends Cast { get(model, key, value) { return decrypt(value); } set(model, key, value) { return encrypt(value); } } class User extends Model { casts = { ssn: new EncryptedCast() }; } ``` ## 自定义 Trait ```ts const HasUuid = { boot(model) { model.creating(async (instance) => { if (!instance.uuid) instance.uuid = crypto.randomUUID(); }); } }; class User extends Model { use = [HasUuid]; } ``` ## 全局查询修改 ```ts class Post extends Model { static booted() { super.booted(); this.addGlobalScope('tenant', (query) => { if (currentTenantId) query.where('tenant_id', currentTenantId); }); } } // 跳过全局作用域 const allPosts = await Post.query().withoutGlobalScope('tenant').get(); ``` ## 宏方法 ```ts QueryBuilder.macro('search', function (term) { return this.where('title', 'like', `%${term}%`) .orWhere('content', 'like', `%${term}%`); }); const results = await Post.query().search('laravel').get(); ``` ## 打包插件 ```ts // my-sutando-plugin/index.ts export const HasUuid = { /* ... */ }; export const TimestampsCast = new Cast({ /* ... */ }); ``` 了解更多请查看 [Sutando 文档](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /zh_CN/blog/posts/database-migrations-with-sutando-complete-guide.md --- 数据库迁移是表结构的版本控制。Sutando 内置强大的 Schema 构造器,可以用代码创建和修改表。 ## Schema 构造器 ```ts import { sutando } from 'sutando'; // 创建表 await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.string('email').notNullable().unique(); table.timestamps(); }); // 修改表 await sutando.schema().table('users', table => { table.string('avatar').nullable(); }); // 删除表 await sutando.schema().dropTableIfExists('old_table'); ``` ## 列类型 | 方法 | SQL 类型 | |--------|---------| | `increments('id')` | AUTO\_INCREMENT INTEGER | | `string('name')` | VARCHAR(255) | | `text('content')` | TEXT | | `integer('views')` | INTEGER | | `boolean('active')` | BOOLEAN | | `decimal('price', 10, 2)` | DECIMAL(10, 2) | | `json('metadata')` | JSON | | `timestamp('created_at')` | TIMESTAMP | ## 外键 ```ts await sutando.schema().createTable('posts', table => { table.increments('id').primary(); table.integer('user_id').unsigned(); table.foreign('user_id') .references('id') .inTable('users') .onDelete('CASCADE'); }); ``` ## 索引 ```ts table.index('name'); table.index(['status', 'created_at']); ``` ## 组织迁移文件 ``` migrations/ 001_create_users_table.ts 002_create_posts_table.ts 003_add_avatar_to_users.ts ``` ```ts export async function up() { await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name').notNullable(); table.timestamps(); }); } export async function down() { await sutando.schema().dropTableIfExists('users'); } ``` ## 生产环境最佳实践 1. **始终写 `down` 函数**——以便回滚 2. **先在 staging 数据库测试** 3. **生产环境跑迁移前备份数据库** 4. **谨慎添加索引**——加速读取但减慢写入 了解更多请查看 [Sutando 文档](https://sutando.org/zh_CN/guide/migrations.html)。 --- --- url: >- /zh_CN/blog/posts/model-relationships-in-sutando-hasmany-belongsto-and-beyond.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** 关联是任何 ORM 的核心。Sutando 将 Laravel Eloquent 的关联系统带入 Node.js,让定义和查询复杂数据关系变得简单。 ## 定义关联 在 Sutando 中,关联定义为模型上以 `relation` 为前缀的方法: ```ts class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } ``` ## 一对多:hasMany ```ts class User extends Model { relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { relationUser() { return this.belongsTo(User, 'user_id'); } } ``` ### 查询 ```ts // 预加载 const user = await User.query().with('posts').find(1); // 查询关联 const publishedPosts = await user.posts().where('published', true).get(); // 创建关联记录 const post = await user.posts().create({ title: '新文章', content: '内容' }); ``` ## 一对一:hasOne ```ts class User extends Model { relationProfile() { return this.hasOne(Profile, 'user_id'); } } ``` ## 多对多:belongsToMany ```ts class User extends Model { relationRoles() { return this.belongsToMany(Role, 'user_roles', 'user_id', 'role_id'); } } ``` ```ts // 附加/分离 await user.roles().attach([1, 2, 3]); await user.roles().detach([2]); await user.roles().sync([1, 3]); // 同步:分离其他,附加这些 ``` ## 多态关联 ```ts class Tag extends Model { relationTaggable() { return this.morphTo(); } } class Post extends Model { relationTags() { return this.morphMany(Tag, 'taggable'); } } ``` ## 嵌套预加载 ```ts // 加载 user → posts → comments → user const users = await User.query() .with('posts.comments.user') .get(); ``` ## 避免 N+1 查询 ```ts // 不好:N+1(1 + N 次查询) const users = await User.query().get(); for (const user of users) { console.log(await user.posts); // 每个用户一次查询 } // 好:总共 2 次查询 const users = await User.query().with('posts').get(); ``` ## 关联方法总结 | 方法 | 用途 | 示例 | |--------|---------|---------| | `hasMany` | 一对多 | User → Posts | | `belongsTo` | 一对多反向 | Post → User | | `hasOne` | 一对一 | User → Profile | | `belongsToMany` | 多对多 | User ↔ Roles | | `morphMany` | 多态一对多 | Post/Video → Tags | | `morphTo` | 多态反向 | Tag → Post/Video | 了解更多请查看 [Sutando 文档](https://sutando.org/zh_CN/guide/relationships.html)。 --- --- url: /blog/posts/the-best-node.js-orms-to-watch-in-2026.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** The Node.js ecosystem in 2026 is faster and leaner than ever. With the total dominance of **Edge Runtimes** (Cloudflare Workers, Vercel Edge) and the rise of **Bun** as a production-grade runtime, the way we interact with databases has fundamentally shifted. We’ve moved past the era of "one-size-fits-all" ORMs. Today, the choice of an ORM isn't just about syntax—it’s a decision affecting cold starts, bundle size, and long-term maintainability. In this guide, we’ll look at the top contenders for 2026 and why a new (yet familiar) face, **Sutando ORM**, is becoming the secret weapon for high-velocity teams. *** ## The Landscape of 2026: Performance vs. Abstraction For years, developers were forced to choose: do you want the "Magic" of a heavy abstraction (at the cost of performance), or the "Speed" of raw SQL (at the cost of productivity)? In 2026, that gap is closing. ### 1. Prisma 7: The Managed Ecosystem Prisma remains a titan. In its latest **v7 release**, Prisma has moved entirely to a **WASM-based engine**, significantly reducing the cold-start issues that plagued earlier versions on Edge functions. * **Best For:** Large teams who need a strict, schema-first approach and a built-in GUI (Prisma Studio). * **The Catch:** The generated client can still be bulky, and the "Shadow Database" migration flow remains a bit complex for rapid prototyping. ### 2. Drizzle ORM: The SQL-First Standard Drizzle has become the go-to for performance purists. It’s essentially "SQL in TypeScript clothing." With zero runtime overhead, it’s the king of the Edge. * **Best For:** Maximum performance and developers who *love* writing SQL. * **The Catch:** It requires a high level of SQL knowledge. For teams moving fast, the lack of "Active Record" style magic can make managing complex relationships tedious. ### 3. Sutando ORM: The "Eloquent" Revolution for Node.js While others focus on either heavy abstraction or raw SQL, **Sutando ORM** has carved out a massive niche by focusing on **Developer Happiness (DX)** without the bloat. Inspired by **Laravel's Eloquent**, Sutando brings the **Active Record** pattern to the Node.js/TypeScript world in a way that feels modern, lightweight, and incredibly intuitive. #### Why Sutando is Winning in 2026: * **Active Record Simplicity:** Instead of managing separate repositories or complex query builders, you interact with your data directly: `User.query().find(1)`. * **The Familiarity Factor:** If you’ve ever touched Laravel or Rails, you already know Sutando. It brings that same "it just works" feeling to the JavaScript ecosystem. * **Lightweight & Edge-Ready:** Unlike the heavy ORMs of the past, Sutando is built to be modular. It has minimal dependencies, making it perfect for the modern serverless and edge era. * **Elegant Relations:** Handling `hasMany`, `belongsTo`, and polymorphic relations in Sutando is arguably the most readable experience in the entire JS ecosystem. *** ## Quick Comparison: Which should you choose? | Feature | Prisma 7 | Drizzle | **Sutando ORM** | | :--- | :--- | :--- | :--- | | **Pattern** | Data Mapper | SQL-First | **Active Record** | | **DX (Dev Experience)** | High (Tooling) | Medium (SQL-heavy) | **Elite (Intuitive)** | | **Bundle Size** | Medium (WASM) | Tiny | **Small / Optimized** | | **Type Safety** | Generated | Inferred | **Class-based / TS** | | **Ideal Use Case** | Enterprise | Performance Tuning | **Rapid Scaling / Startups** | *** ## A Glimpse of Sutando in Action In 2026, code readability is a feature, not a luxury. Look how clean your logic stays with Sutando: ```typescript // Fetch a user with their posts and comments in one clean sweep const user = await User.query() .with(['posts.comments']) .where('status', 'active') .first(); // Update with Active Record style user.name = 'Gemini'; await user.save(); ``` ## Is it time to switch? In 2026, we are seeing a "re-simplification" of the backend. Developers are tired of fighting their tools. They want an ORM that stays out of the way while providing powerful abstractions for complex relationships. **Sutando ORM** represents this shift. It proves that you don't need to choose between a massive library and writing raw SQL strings. You can have a beautiful, chainable, and powerful API that respects your server's resources. ### Ready to build faster? If you're starting a new project in 2026 or looking to migrate away from a "heavyweight" legacy ORM, give Sutando a try. It’s the ORM that makes interacting with your database enjoyable again. 👉 Check out the [Sutando Documentation](https://sutando.org/guide/getting-started.html) or run `npm install sutando` to get started today. --- --- url: /zh_CN/guide/typescript.md --- # TypeScript 支持 Sutando 提供了 TypeScript 支持,但我们采取了实用主义的方式 - 在易用性和类型安全性之间取得平衡。我们更注重提供直观、易用的 API,而不是追求完全的类型安全。 ## 基本用法 以下是一些基本示例: ```typescript import { Model } from 'sutando' // 定义一个基本的模型 class User extends Model { // 可选:声明模型属性类型 declare id: number declare name: string declare email: string } // 使用模型 const user = new User() user.name = 'John' await user.save() // 查询示例 const users = await User.query() .where('age', '>', 18) .get() // 关联关系示例 class Post extends Model { declare title: string declare content: string declare user_id: number relationUser() { return this.belongsTo(User) } } ``` ## 类型安全说明 虽然 Sutando 提供了 TypeScript 支持,但我们并不追求完全的类型安全。这意味着: 1. 某些动态特性可能无法获得完整的类型推导 2. 查询构建器的某些操作可能返回 `any` 类型 3. 关联关系的类型推导可能不够完善 例如: ```typescript // 这样的动态查询可能无法获得准确的类型推导 const result = await User.query() .select(['name', 'email']) .where('age', '>', 18) .first() // 关联关系查询的类型推导可能不够完善 const userWithPosts = await User.query() .with('posts') .first() ``` ## 使用泛型增强类型安全 为了解决上述类型推导的限制,Sutando 的查询方法支持泛型类型,这让你可以: 1. 扩展模型的类型定义 2. 指定关联数据的类型 3. 添加自定义字段的类型 例如: ```typescript // 基础查询使用泛型 const user = await User.query() .first() // 关联查询使用泛型 const post = await Post.query() .with('user') .first() // 自定义查询结果类型 interface CustomUserResult extends User { total_posts: number; latest_login: Date; } const result = await User.query() .select(['*']) .selectRaw('COUNT(posts.id) as total_posts') .first() // 复杂的关联查询类型 const userWithPosts = await User.query() .with('posts') .first() ``` ## 为什么这样设计? 我们的设计理念是: 1. **优先考虑开发体验**:我们希望 API 保持简单直观,而不是被复杂的类型定义所困扰 2. **实用性优先**:在某些场景下,我们选择牺牲一定的类型安全性来换取更灵活的 API 3. **渐进式类型支持**:你可以根据需要逐步添加更多类型定义 ## 最佳实践 尽管如此,我们仍然建议: 1. 为模型的主要属性声明类型 2. 对关键的业务逻辑代码添加类型注解 3. 在需要类型安全的地方使用类型断言或自定义类型守卫 ```typescript // 为重要的模型属性声明类型 class Product extends Model { declare id: number declare name: string declare price: number declare stock: number // 自定义方法使用明确的类型 async updateStock(quantity: number): Promise { this.stock += quantity await this.save() } } ``` --- --- url: /blog/authors/dylan-yu.md --- --- --- url: /ja/blog.md --- --- --- url: /zh_CN/blog.md --- --- --- url: /blog/posts/using-sutando-with-nextjs-server-side-database-access.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** Next.js App Router makes server-side database access easier than ever. With Sutando, you can query your database from Server Components and Route Handlers using the same elegant Active Record API you'd use in any Node.js app. ## Setup ```bash npx create-next-app@latest my-app cd my-app npm install sutando pg ``` ## Database Configuration Create a singleton connection that avoids creating multiple instances in development: ```ts // lib/db.ts import { sutando, Model } from 'sutando'; const globalForSutando = globalThis as unknown as { sutando?: typeof sutando }; if (!globalForSutando.sutando) { sutando.addConnection({ client: 'pg', connection: { host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, } }); globalForSutando.sutando = sutando; } export { sutando, Model }; ``` ## Define Models ```ts // lib/models.ts import { Model } from './db'; class Post extends Model { table = 'posts'; casts = { published: 'boolean' }; relationAuthor() { return this.belongsTo(User, 'user_id'); } scopePublished(query) { return query.where('published', true); } } class User extends Model { table = 'users'; relationPosts() { return this.hasMany(Post, 'user_id'); } } export { Post, User }; ``` ## Using in Server Components ```tsx // app/blog/page.tsx import { Post } from '@/lib/models'; export default async function BlogPage() { const posts = await Post.query() .with('author') .published() .orderBy('created_at', 'desc') .limit(20) .get(); return (

Blog

{posts.map(post => (

{post.title}

By {post.author.name}

{post.content.substring(0, 200)}...

))}
); } ``` ## Using in Route Handlers (API Routes) ```ts // app/api/posts/route.ts import { NextRequest, NextResponse } from 'next/server'; import { Post } from '@/lib/models'; export async function GET() { const posts = await Post.query() .with('author') .published() .orderBy('created_at', 'desc') .limit(20) .get(); return NextResponse.json(posts); } export async function POST(request: NextRequest) { const body = await request.json(); const post = await Post.create({ ...body, published: false, }); return NextResponse.json(post, { status: 201 }); } ``` ## Dynamic Routes ```tsx // app/blog/[slug]/page.tsx import { Post } from '@/lib/models'; import { notFound } from 'next/navigation'; export default async function PostPage({ params }: { params: { slug: string } }) { const post = await Post.query() .with('author', 'comments') .where('slug', params.slug) .published() .first(); if (!post) notFound(); return (

{post.title}

By {post.author.name}

{post.content}
); } ``` ## Server Actions ```ts // app/actions/posts.ts 'use server'; import { Post } from '@/lib/models'; export async function createPost(formData: FormData) { const post = await Post.create({ title: formData.get('title'), content: formData.get('content'), user_id: 1, // from session }); return post; } ``` ## Edge Runtime Considerations Sutando works in Node.js runtime. For Edge runtime, use a driver adapter compatible with your database (e.g., `@neondatabase/serverless` for Neon PostgreSQL): ```ts // For Neon serverless on Edge sutando.addConnection({ client: 'pg', connection: process.env.DATABASE_URL, pool: { min: 0, max: 1 }, }); ``` ## Conclusion Sutando integrates seamlessly with Next.js App Router. Use it in Server Components for data fetching, Route Handlers for APIs, and Server Actions for mutations — all with the same Active Record API. Learn more at [sutando.org](https://sutando.org/guide/getting-started.html). --- --- url: /zh_CN/guide/transactions.md --- # 事务 您可以使用 Sutando 连接提供的 `transaction` 方法在数据库事务中运行一组操作。 如果在事务闭包中抛出异常,事务将自动回滚并重新抛出异常。 如果闭包成功执行,事务将自动提交。 在使用事务方法时,您无需担心手动回滚或提交: ```js const { sutando } = require('sutando'); const db = sutando.connection(); await db.transaction(async (trx) => { await User.query().transacting(trx).create(/* ... */); await db.table('users').transacting(trx).insert(/* ... */); const user = new User; user.name = 'Sally'; await user.save({ client: trx, }); }); ``` ### 手动执行事务 如果您想手动开始事务并完全控制回滚和提交,您可以使用 `sutando` 提供的 `beginTransaction` 方法: ```js const { sutando } = require('sutando'); const db = sutando.connection(); const trx = await db.beginTransaction(); ``` 您可以通过 `rollback` 方法回滚事务: ```js await trx.rollback(); ``` 最后,您可以通过 `commit` 方法提交事务: ```js await trx.commit(); ``` 下面是一个完整示例: ```js const { sutando } = require('sutando'); const db = sutando.connection(); const trx = await db.beginTransaction(); try { const user = new User; user.name = 'Sally'; await user.save({ client: trx, }); await trx.commit(); } catch (e) { await trx.rollback(); console.log(e.stack); } ``` --- --- url: /zh_CN/blog/posts/migrate-from-laravel-to-nodejs-with-sutando.md --- 很多 Laravel 开发者想尝试 Node.js,但舍不得 Eloquent。好消息是,Sutando 几乎完美复刻了 Eloquent 的 API,让你可以无缝切换。本文将从实际迁移角度,详细对比 Laravel 和 Node.js + Sutando 的开发体验。 ## 为什么要从 Laravel 迁移到 Node.js? * **全栈 TypeScript**:前后端共享类型,减少沟通成本 * **实时能力**:WebSocket、SSE 在 Node.js 事件循环中更自然 * **Serverless/Edge**:部署到 Cloudflare Workers、Vercel Edge * **npm 生态**:在某些领域(前端工具链、AI SDK)npm 比 Composer 更丰富 * **团队技能**:团队更熟悉 JavaScript ## Eloquent vs Sutando 概念映射 | Laravel Eloquent | Sutando | |------------------|---------| | `Model` 基类 | `Model` 基类 | | `protected $table` | `table =` | | `protected $casts` | `casts =` | | `protected $fillable` | `fillable =` | | `protected $hidden` | `hidden =` | | `hasMany` | `hasMany` | | `belongsTo` | `belongsTo` | | `hasOne` | `hasOne` | | `belongsToMany` | `belongsToMany` | | `morphTo` / `morphMany` | `morphTo` / `morphMany` | | `with()` 预加载 | `with()` 预加载 | | `scope` 查询作用域 | `scope` 查询作用域 | | 模型事件 (`creating`, `saving` 等) | 模型事件(相同) | | `SoftDeletes` trait | `SoftDeletes` trait | | `Factory` | `Factory` | | `Seeder` | `Seeder` | ## 代码对比 ### 模型定义 ```php // Laravel class User extends Model { protected $table = 'users'; protected $casts = ['is_admin' => 'boolean', 'metadata' => 'array']; protected $fillable = ['name', 'email', 'password']; public function posts(): HasMany { return $this->hasMany(Post::class); } public function scopeActive($query) { return $query->where('active', true); } } ``` ```ts // Sutando class User extends Model { table = 'users'; casts = { is_admin: 'boolean', metadata: 'json' }; fillable = ['name', 'email', 'password']; relationPosts() { return this.hasMany(Post, 'user_id'); } scopeActive(query) { return query.where('active', true); } } ``` ### 查询 ```php // Laravel $users = User::with('posts.comments') ->where('active', true) ->active() ->orderBy('created_at', 'desc') ->limit(10) ->get(); ``` ```ts // Sutando const users = await User.query() .with('posts.comments') .where('active', true) .active() .orderBy('created_at', 'desc') .limit(10) .get(); ``` ### 创建 ```php // Laravel $user = User::create(['name' => 'Alice', 'email' => 'alice@example.com']); ``` ```ts // Sutando const user = await User.create({ name: 'Alice', email: 'alice@example.com' }); ``` ### 模型事件 ```php // Laravel User::creating(function ($user) { $user->password = bcrypt($user->password); }); ``` ```ts // Sutando User.creating(async (user) => { user.password = await bcrypt.hash(user.password, 10); }); ``` ## 路由迁移 ```php // Laravel Route::get('/posts', [PostController::class, 'index']); Route::post('/posts', [PostController::class, 'store']); ``` ```ts // Express app.get('/posts', async (req, res) => { const posts = await Post.query().with('user').published().get(); res.json(posts); }); app.post('/posts', async (req, res) => { const post = await Post.create(req.body); res.status(201).json(post); }); ``` ## 无法直接映射的部分 | Laravel 功能 | Node.js 替代方案 | |-------------|-----------------| | Blade 模板 | React / Vue / 纯 API | | 认证系统 | JWT / Passport.js | | FormRequest 验证 | Zod / Joi / express-validator | | 队列(Queue) | BullMQ / Cloudflare Queues | | 邮件(Mail) | Nodemailer / Cloudflare Email | | Artisan 命令 | npm scripts / Commander | | Service Container | 手动 DI / 框架内置 | ## 迁移策略 ### 1. 绞杀者模式(推荐) 不要一次性全部重写。用 Nginx 做反向代理,逐路由迁移: ``` /api/posts → Node.js 服务 /api/users → 暂时还是 Laravel ``` ### 2. 共享数据库 Sutando 可以直接操作 Laravel 创建的数据库 schema,无需迁移数据。 ### 3. Session 共享 把 Session 放到 Redis,Laravel 和 Node.js 都能读写,实现渐进式迁移。 ### 4. 从只读 API 开始 先迁移 GET 路由(风险最低),验证通过后再迁移写操作。 ## 实战:迁移一个博客 假设有一个 Laravel 博客,包含 User、Post、Comment 三个模型: ```ts // Node.js + Sutando 等价实现 import { sutando, Model } from 'sutando'; import express from 'express'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', user: 'root', password: '', database: 'blog' } }); class User extends Model { table = 'users'; hidden = ['password']; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; casts = { published: 'boolean' }; relationUser() { return this.belongsTo(User, 'user_id'); } relationComments() { return this.hasMany(Comment, 'post_id'); } scopePublished(query) { return query.where('published', true); } } class Comment extends Model { table = 'comments'; relationUser() { return this.belongsTo(User, 'user_id'); } } const app = express(); app.use(express.json()); app.get('/posts', async (req, res) => { const posts = await Post.query() .with('user', 'comments.user') .published() .orderBy('created_at', 'desc') .limit(20) .get(); res.json(posts); }); app.listen(3000); ``` ## 总结 从 Laravel 迁移到 Node.js 不意味着放弃 Eloquent 的开发体验。Sutando 几乎是 Eloquent 在 Node.js 中的镜像——相同的 API、相同的思维模式、相同的功能集。你可以保持现有数据库,逐路由迁移,团队从第一天就能高效工作。 从 `npm install sutando` 开始,查看[中文文档](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /zh_CN/blog/posts/migrating-from-laravel-to-nodejs-with-sutando.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** 如果你是 Laravel 开发者,考虑转向 Node.js,最大的顾虑通常是离开 Eloquent。有了 Sutando,你不必放弃。本指南将带你逐步迁移 Laravel 应用到 Node.js,将 Eloquent 的每个概念映射到 Sutando。 ## 为什么从 Laravel 迁移到 Node.js? 常见原因: * **实时功能**:WebSocket、SSE、流式传输——Node.js 的事件循环原生支持 * **全栈 TypeScript**:前后端共享类型 * **Serverless/Edge**:部署到 Cloudflare Workers、Vercel Edge 或 AWS Lambda * **团队能力**:团队更熟悉 JavaScript 而非 PHP * **生态**:npm 生态在某些领域比 Composer 更丰富 ## 概念映射:Laravel → Node.js + Sutando | Laravel | Node.js + Sutando | |---------|-------------------| | Eloquent Model | Sutando Model 类 | | Migration | Sutando Schema Builder | | Seeder | Sutando Seeder | | Factory | Sutando Factory | | Route | Express / Fastify 路由 | | Middleware | Express 中间件 | | Blade | React / Vue / 纯 API | | Artisan CLI | 自定义 npm 脚本 | ## Eloquent Model → Sutando Model ### Laravel (PHP) ```php class User extends Model { protected $table = 'users'; protected $casts = ['is_admin' => 'boolean', 'metadata' => 'array']; protected $fillable = ['name', 'email', 'password']; public function posts(): HasMany { return $this->hasMany(Post::class); } public function scopeActive($query) { return $query->where('active', true); } } ``` ### Sutando (TypeScript) ```ts class User extends Model { table = 'users'; casts = { is_admin: 'boolean', metadata: 'json' }; fillable = ['name', 'email', 'password']; relationPosts() { return this.hasMany(Post, 'user_id'); } scopeActive(query) { return query.where('active', true); } } ``` 映射几乎是 1:1 的。主要区别: * `protected $table` → `table =` * `protected $casts` → `casts =` * 关联方法使用 `relation` 前缀 * 作用域使用 `scope` 前缀 ## 查询对比 ### 预加载 ```php // Laravel $users = User::with('posts.comments')->get(); ``` ```ts // Sutando const users = await User.query().with('posts.comments').get(); ``` ### 模型事件 ```php // Laravel User::creating(function ($user) { $user->password = bcrypt($user->password); }); ``` ```ts // Sutando User.creating(async (user) => { user.password = await bcrypt.hash(user.password, 10); }); ``` ## Migration: Laravel → Sutando Schema Builder ```php // Laravel Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->timestamps(); }); ``` ```ts // Sutando await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.string('name'); table.string('email').unique(); table.timestamps(); }); ``` ## 路由:Laravel → Express ```php // Laravel Route::get('/posts', [PostController::class, 'index']); ``` ```ts // Express app.get('/posts', async (req, res) => { const posts = await Post.query().with('user').get(); res.json(posts); }); ``` ## 无法直接映射的部分 一些 Laravel 功能需要在 Node.js 中手动实现: * **认证**:Laravel 内置认证 → 使用 JWT、Passport.js 或自定义中间件 * **验证**:Laravel FormRequest → 使用 Zod、Joi 或 express-validator * **队列/任务**:Laravel Queue → 使用 BullMQ 或 Cloudflare Queues * **邮件**:Laravel Mail → 使用 Nodemailer 或 Cloudflare Email * **Artisan 命令** → 自定义 npm 脚本或 Commander 等 CLI 框架 ## 迁移策略 1. **保持同一个数据库**——Sutando 可以直接操作现有的 MySQL/PostgreSQL schema 2. **逐路由迁移**——用 Nginx 做反向代理分流 3. **从只读路由开始**——风险最低 4. **Session 放 Redis**——PHP 和 Node 都能读取 5. **不要一次性全部重写**——绞杀者模式(strangler fig)是好方法 ## 总结 从 Laravel 迁移到 Node.js 不意味着放弃 Eloquent。Sutando 带来了几乎相同的 Active Record 体验。你可以保持现有数据库 schema,逐路由迁移,团队从第一天就能高效工作。 从 `npm install sutando` 开始,查看[文档](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /zh_CN/guide/pagination.md --- # 分页 Sutando 内置了对基于偏移的分页的支持。 您可以通过链接 `paginate` 方法对查询结果进行分页。 `paginate` 方法接受要页码作为第一个参数,将每页行数作为第二个参数。 在内部,我们执行一个额外的查询来计算总行数。 ## 基本用法 ```js const users = await db.table('users') .where('vote', '>', 1) .paginate(2, 15); // instanceof Paginator const users = await User.query() .where('vote', '>', 1) .paginate(2); // instanceof Paginator const users = await db.table('users') .where('vote', '>', 1) .forPage(2, 15) .get(); // instanceof Array const users = await User.query() .where('vote', '>', 1) .forPage(1, 15) .get(); // instanceof Collection users.map(user => { // }); ``` 如果没有指定,每页行数默认为 15。如果使用模型,也可以通过设置 `perPage` 属性作为模型每页默认数量。 ```js class Post extends Model {} class User extends Model { perPage = 20; } const posts = await Post.query().paginate(); console.log(posts.perPage()); // 15 const users = await User.query().paginate(); console.log(users.perPage()); // 20 ``` `paginate` 方法返回一个 `Paginator` 实例。 它保存分页的元数据,以及获取的行。 ### 可用方法 每个分页器实例通过以下方法提供额外的分页信息: | 方法 | 描述 | | ---- | ---- | | `paginator.count()` | 获取当前页的数据总数 | | `paginator.currentPage()` | 获取当前页码 | | `paginator.hasMorePages()` | 是否有更多的页面可供展示 | | `paginator.items()` | 获取当前页的数据项 | | `paginator.lastPage()` | 获取最后一页的页码 | | `paginator.perPage()` | 获取每一页显示的数量总数 | | `paginator.total()` | 获取结果集中的数据总数 | | `paginator.firstItem()` | 获取结果集中第一个数据的编号 | | `paginator.lastItem()` | 获取结果集中最后一个数据的编号 | ## 序列化为对象/JSON 您还可以通过调用 `toData` 或 `toJson` 方法将分页器结果序列化为 Object/JSON。 它默认返回「蛇形命名」中的键名。 ```JSON { "total": 45, "per_page": 15, "current_page": 1, "last_page": 3, "count": 15, "data": [ { // Record... }, { // Record... } ], } ``` ### 自定义格式 您可以通过调用 `Paginator.setFormatter` 来覆盖默认的格式。 ```js const { Paginator } = require('sutando'); Paginator.setFormatter((paginator) => { return { meta: { total: paginator.total(), per_page: paginator.perPage(), current_page: paginator.currentPage(), last_page: paginator.lastPage(), }, data: paginator.items().toData(), }; }); ``` 分页器在转化为字符串的时候会转成 JSON, 因此可以在应用的路由或控制器中直接。你的 express/Koa 应用会自动序列化为 JSON: ```js const app = require('express')(); app.get('/', async (req, res) => { const users = await User.query().paginate(req.query.page || 1); res.send(users); }); ``` --- --- url: /zh_CN/blog/posts/using-sutando-with-nextjs-server-side-database-access.md --- Next.js App Router 让服务端数据库访问比以往更简单。使用 Sutando,你可以在 Server Components 和 Route Handlers 中使用同样的 Active Record API 查询数据库。 ## 安装 ```bash npx create-next-app@latest my-app cd my-app npm install sutando pg ``` ## 数据库配置 创建单例连接,避免开发环境创建多个实例: ```ts // lib/db.ts import { sutando, Model } from 'sutando'; const globalForSutando = globalThis as unknown as { sutando?: typeof sutando }; if (!globalForSutando.sutando) { sutando.addConnection({ client: 'pg', connection: { /* ... */ } }); globalForSutando.sutando = sutando; } export { sutando, Model }; ``` ## 在 Server Components 中使用 ```tsx // app/blog/page.tsx import { Post } from '@/lib/models'; export default async function BlogPage() { const posts = await Post.query() .with('author') .published() .orderBy('created_at', 'desc') .limit(20) .get(); return (

博客

{posts.map(post => (

{post.title}

作者:{post.author.name}

))}
); } ``` ## 在 API 路由中使用 ```ts // app/api/posts/route.ts export async function GET() { const posts = await Post.query().with('author').published().get(); return NextResponse.json(posts); } export async function POST(request: NextRequest) { const body = await request.json(); const post = await Post.create({ ...body, published: false }); return NextResponse.json(post, { status: 201 }); } ``` ## Server Actions ```ts 'use server'; export async function createPost(formData: FormData) { const post = await Post.create({ title: formData.get('title'), content: formData.get('content'), }); return post; } ``` ## 总结 Sutando 与 Next.js App Router 无缝集成。在 Server Components 中获取数据,在 Route Handlers 中处理 API,在 Server Actions 中处理数据变更——全部使用同一个 Active Record API。 了解更多请访问 [sutando.org](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /zh_CN/guide/installation.md --- # 安装 Sutando 的主要目标环境是 Node.js,你需要安装 sutando 库,然后安装适当的数据库包:`pg` 用于 PostgreSQL、CockroachDB 和 Amazon Redshift,`pg-native` 用于带有原生 C++ 的 PostgreSQL ` libpq` 绑定(需要安装 PostgresSQL 才能链接),`mysql` 用于 MySQL 或 MariaDB,`sqlite3` 用于 SQLite3,或 `tedious` 用于 MSSQL。 ## 安装 Sutando 可通过 npm(或 yarn/pnpm)获得。 ::: code-group ```sh [npm] $ npm install sutando --save ``` ```sh [yarn] $ yarn add sutando ``` ```sh [pnpm] $ pnpm add sutando ``` ::: 你还需要根据要使用的数据库安装以下其中一项: ::: code-group ```sh [npm] $ npm install pg --save $ npm install sqlite3 --save $ npm install better-sqlite3 --save $ npm install mysql --save $ npm install mysql2 --save $ npm install tedious --save ``` ```sh [yarn] $ yarn add pg $ yarn add sqlite3 $ yarn add better-sqlite3 $ yarn add mysql $ yarn add mysql2 $ yarn add tedious ``` ```sh [pnpm] $ pnpm add pg $ pnpm add sqlite3 $ pnpm add better-sqlite3 $ pnpm add mysql $ pnpm add mysql2 $ pnpm add tedious ``` ::: ## 配置 ### MySQL 要连接到数据库,你必须添加一个连接。 `client` 参数是必需的,它决定了哪个客户端适配器将与 Sutando 一起使用。 ```js const { sutando } = require('./sutando'); sutando.addConnection({ client: 'mysql2', connection: { host : '127.0.0.1', port : 3306, user : 'your_database_user', password : 'your_database_password', database : 'myapp_test' }, }); // 你可以添加多个连接,只需指定连接名称即可。 sutando.addConnection({ client: 'mysql2', connection: { host : '127.0.0.1', port : 3306, user : 'another_database_user', password : 'another_database_password', database : 'myapp_another' }, }, 'another_mysql'); const db = sutando.connection('another_mysql'); ``` 连接选项直接传递给适当的数据库客户端以创建连接,并且可以是对象、连接字符串或返回对象的函数: ### SQLite3 or Better-SQLite3 当你使用 `SQLite3` 或 `Better-SQLite3` 适配器时,需要一个文件名,而不是网络连接。 例如: ```js sutando.addConnection({ client: 'sqlite3', // or 'better-sqlite3' connection: { filename: "./mydb.sqlite" } }); ``` 你还可以通过提供 `:memory:` 作为文件名来使用内存数据库运行 `SQLite3` 或 `Better-SQLite3`。 例如: ```js sutando.addConnection({ client: 'sqlite3', // or 'better-sqlite3' connection: { filename: ":memory:" } }); ``` 当你使用 `SQLite3` 适配器时,你可以设置用于打开连接的标志。 例如: ```js sutando.addConnection({ client: 'sqlite3', connection: { filename: "file:memDb1?mode=memory&cache=shared", flags: ['OPEN_URI', 'OPEN_SHAREDCACHE'] } }); ``` ### PostgreSQL 当你使用 PostgreSQL 适配器连接非标准数据库时,可以在 sutando 配置中添加数据库版本。 ```js sutando.addConnection({ client: 'pg', version: '7.2', connection: { host : '127.0.0.1', port : 3306, user : 'your_database_user', password : 'your_database_password', database : 'myapp_test' } }); ``` --- --- url: /zh_CN/guide/mutators.md --- # 属性修改器 访问器、修改器允许您在模型实例上检索或设置 Sutando 属性值时对其进行转换。 ## 访问器 & 修改器 ### 定义一个访问器 若要定义一个访问器,请在模型中创建一个「驼峰式」命名的 `attribute{Attribute}` 方法来表示可访问属性。此方法名称对应到真正的底层模型 `属性/数据库字段` 的表示。 在这个示例中,我们将为 `first_name` 属性定义一个访问器。当 Sutando 尝试获取 `first_name` 属性时,将自动调用此访问器。 ```js const { Model, Attribute } = require('sutando'); class User extends Model { attributeFirstName() { return Attribute.make({ get: value => value.toUpperCase() }) } } ``` 所有访问器方法都返回一个 `Attribute` 实例,该实例定义了如何访问该属性以及如何改变该属性。 在此示例中,我们仅定义如何访问该属性。 为此,我们将 `get` 参数提供给 `Attribute` 类构造函数。 如你所见,字段的原始值被传递到访问器中,允许你对它进行处理并返回结果。如果想获取被修改后的值,你可以在模型实例上访问 `first_name` 属性: ```js const user = await User.query().find(1); const firstName = user.first_name; ``` :::tip 如果要将这些计算值添加到模型的 Object / JSON 中表示,[你需要追加它们](serialization.html#追加-json-值). ::: 当然,你也可以通过已有的属性值,使用访问器返回新的计算值。你的 `get` 闭包可以接受 `attributes` 的第二个参数,该参数将自动提供给闭包,并将包含模型所有当前属性: ```js attributeFullName() { return Attribute.make({ get: (value, attributes) => `${attributes.first_name} ${attributes.last_name}` }) } ``` ### 定义一个修改器 修改器会在设置属性时生效。要定义修改器,可以在定义属性时提供 `set` 参数。让我们为 `first_name` 属性定义一个修改器。这个修改器将会在我们修改 `first_name` 属性的值时自动调用: ```js const { Model, Attribute } = require('sutando'); class User extends Model { attributeFirstName() { return Attribute.make({ get: value => value.toUpperCase(), set: value => value.toLocalLowerCase() }) } } ``` 修改器会获取属性已经被设置的值,并允许你修改并且将其值设置到 Sutando 模型内部的 `attributes` 属性上。 使用修改器,我们只需要设置 Sutando 模型的 `first_name` 属性即可: ```js const user = User.query().find(1); user.first_name = 'Sally'; ``` 在本例中,值 `Sally` 将会触发 `set` 回调。然后,修改器会使用 `toLocalLowerCase` 方法处理姓名,并将结果值设置在模型的 `attributes` 中。 #### 修改多个属性 有时你的修改器可能需要修改底层模型的多个属性。 为此,你的 `set` 闭包可以返回一个对象,对象中的每个键都应该与模型的属性 / 数据库列相对应: ```js attributeFullName() { return Attribute.make({ get: (value, attributes) => `${attributes.first_name} ${attributes.last_name}`, set: (value) => ({ first_name: value.split(' ')[0], last_name: value.split(' ')[1], }), }); } ``` ## 属性转换 属性转换提供了类似于访问器和修改器的功能,且无需在模型上定义任何其他方法。模型中的 `casts` 属性提供了一个便利的方法来将属性转换为常见的数据类型。 `casts` 属性应是一个对象,且键是那些需要被转换的属性名称,值则是你希望转换的数据类型。支持转换的数据类型有: * `integer` `int` * `float` `double` * `string` * `boolean` `bool` * `collection` * `date` * `datetime` * `json` `object` 示例, 让我们把以整数(`0` 或 `1`)形式存储在数据库中的 `is_admin` 属性转成布尔值: ```js const { Model } = require('sutando'); class User extends Model { // 类型转换。 casts = { is_admin: 'boolean', }; } ``` 现在当你访问 `is_admin` 属性时,虽然保存在数据库里的值是一个整数类型,但是返回值总是会被转换成布尔值类型: ```js const user = await User.query().find(1); if (user.is_admin) { // ... } ``` :::tip 值属性将不会被转换。此外,禁止定义与关联同名的类型转换(或属性)。 ::: ### JSON 转换 当你在数据库存储序列化的 `JSON` 的数据时, `json` 类型的转换非常有用。比如:如果你的数据库具有被序列化为 JSON 的 `JSON` 或 `TEXT` 字段类型,并且在 Sutando 模型中加入了 `json` 类型转换,那么当你访问的时候就会自动解析: ```js const { Model } = require('sutando'); class User extends Model { // 类型转换。 casts = { options: 'json', }; } ``` 一旦定义了转换,你访问 `options` 属性时他会自动从 `JSON` 类型反序列化。当你设置了 `options` 属性的值时,给定的数据也会自动序列化为 JSON 类型存储: ```js const { Model } = require('sutando'); const user = await User.query().find(1); const options = user.options; options.key = value; user.options = options; await user.save(); ``` :::tip 直接修改属性本身不能更新模型数据,所以下面的用法是错误的: ```js const user = await User.query().find(1); user.options.key = value; ``` ::: ### Date 转换 默认情况下,Sutando 会将 `created_at` 和 `updated_at` 列转换为 `Date` 的实例。您可以通过在模型的 `casts` 属性中定义其他日期转换来转换其他日期属性。通常,应使用 `datetime` 转换类型来转换日期。 定义 `date` 或 `datetime` 转换时,您还可以指定日期的格式。[当模型序列化为对象或 JSON 时](serialization),将使用这个格式: ```js casts = { created_at: 'datetime:YYYY-MM-DD', }; ``` 通过在模型中定义 `serializeDate` 方法,你可以自定义所有模型日期的默认序列化格式。此方法不会影响日期在数据库中存储的格式: ```js const dayjs = require('dayjs'); class User extends Model { serializeDate(date) { return dayjs(date).format('YYYY-MM-DD'); } } ``` 要指定在数据库中实际存储模型日期时应使用的格式,您应该在模型上定义一个 `dateFormat` 属性: ```js class User extends Model { dateFormat = 'X' } ``` 支持的格式化占位符列表 | 占位符 | 输出 | 详情 | | ---- | ---- | ---- | | `YY` | 18 | 两位数的年份 | | `YYYY` | 2018 | 四位数的年份 | | `M` | 1-12 | 月份,从 1 开始 | | `MM` | 01-12 | 月份,两位数 | | `MMM` | Jan-Dec | 缩写的月份名称 | | `MMMM` | January-December | 完整的月份名称 | | `D` | 1-31 | 月份里的一天 | | `DD` | 01-31 | 月份里的一天,两位数 | | `d` | 0-6 | 一周中的一天,星期天是 0 | | `dd` | Su-Sa | 最简写的星期几 | | `ddd` | Sun-Sat | 简写的星期几 | | `dddd` | Sunday-Saturday | 星期几 | | `H` | 0-23 | 小时 | | `HH` | 00-23 | 小时,两位数 | | `h` | 1-12 | 小时, 12 小时制 | | `hh` | 01-12 | 小时, 12 小时制, 两位数 | | `m` | 0-59 | 分钟 | | `mm` | 00-59 | 分钟,两位数 | | `s` | 0-59 | 秒 | | `ss` | 00-59 | 秒 两位数 | | `SSS` | 000-999 | 毫秒 三位数 | | `Z` | +05:00 | UTC 的偏移量,±HH:mm | | `ZZ` | +0500 | UTC 的偏移量,±HHmm | | `A` | AM PM | | | `a` | am pm | | | `Q` | 1-4 | 季度 | | `Do` | 1st 2nd ... 31st | 带序数词的月份里的一天 | | `k` | 1-24 | 时:由 1 开始 | | `kk` | 01-24 | 时:由 1 开始,两位数 | | `X` | 1360013296 | 秒为单位的 Unix 时间戳 | | `x` | 1360013296123 | 毫秒单位的 Unix 时间戳 | #### 日期转换,序列化,& 时区 默认情况下,`date` 和 `datetime` 会序列化为 UTC ISO-8601 格式的( 2012-12-12T12:25:36.000000Z )字符串,并不会受到应用的时区配置影响。 如果对 `date` 或 `datetime` 属性自定义了格式,例如 `datetime:YYYYY-MM-DD HH:mm:ss`,那么在日期序列化期间将使用 UTC 时区。 ### 自定义类型转换 Sutando 有多种内置的、有用的类型转换; 如果需要自定义强制转换类型。要创建一个类型转换,转换类需要继承 `CastsAttributes` 类,并定义一个 `get` 和 `set` 方法。`get` 方法负责将数据库中的原始值转换为转换值,而 `set` 方法应将转换值转换为可以存储在数据库中的原始值。 作为示例,我们将内置的 `json` 类型转换重新实现为自定义类型: ```js // casts/json.js const { Model, CastsAttributes } = require('sutando'); class Json extends CastsAttributes { // 将取出的数据进行转换。 static get(model, key, value, attributes) { try { return JSON.parse(value); } catch (e) { return null; } } // 转换成将要进行存储的值。 static set(model, key, value, attributes) { return JSON.stringify(value); } } ``` 定义好自定义类型转换后,可以使用其类名称将其附加到模型属性里: ```js const Json = require('./casts/json'); class User extends Model { // 应被强制转换的属性。 casts = { options: Json, }; } ``` --- --- url: /zh_CN/guide/serialization.md --- # 序列化 构建 JSON API 时,经常需要把模型和关联转化为对象或 JSON。针对这些操作,Sutando 提供了一些便捷方法,以及对序列化中的属性控制。 ## 序列化模型 & 集合 ### 序列化为对象 要转化模型及其加载的关联为对象,可以使用 `toData` 方法。这是一个递归的方法,因此所有的属性和关联(包括关联的关联)都将转化成数组: ```js const user = await User.query().with('roles').first(); return user.toData(); ``` `attributesToData` 方法可用于将模型的属性转换为对象,但不会转换其关联: ```js const user = await User.query().first(); return user.attributesToData(); ``` 你还可以通过调用集合实例上的 `toData` 方法,将模型的全部集合转换为对象: ```js const users = await User.query().all(); return users.toData(); ``` ### 序列化为 JSON 方法 `toJson` 可以把模型转化成 JSON。和方法 `toData` 一样, `toJson` 方法也是递归的,因此所有属性和关联都会转化成 JSON, 你还可以指定由 Javascript 支持的 JSON 选项: ```js const user = await User.query().find(1); return user.toJson(); return user.toJson(null, 2); ``` 或者,你也可以将模型或集合转换为字符串,模型或集合上的 `toJson` 方法会自动调用: ```js const user = await User.query().find(1); return String(user); return JSON.stringify(user); ``` 由于模型和集合在转化为字符串的时候会转成 JSON, 因此可以在应用的路由或控制器中直接返回 Sutando 对象。你的 `express`/`Koa` 应用会自动将 Sutando 模型和集合序列化为 JSON: ```js const app = require('express')(); app.get('/', async (req, res) => { const user = await User.query().find(1); res.send(user); }); ``` #### 关联关系 当一个模型被转化为 JSON 的时候,它加载的关联关系也将自动转化为 JSON 对象被包含进来。同时,通过「小驼峰」定义的关联方法,关联的 JSON 属性将会是「蛇形」命名。 ## 隐藏 JSON 属性 有时要将模型对象或 JSON 中的某些属性进行隐藏,比如密码。则可以在模型中添加 `hidden` 属性。模型序列化后,`hidden` 数组中列出的属性将不会被显示: ```js const { Model } = requre('sutando'); class User extends Model { hidden = ['password']; } ``` 此外,也可以使用属性 `visible` 定义一个模型数组和 JSON 可见的「白名单」。转化后的数组或 JSON 不会出现其他的属性: ```js const { Model } = requre('sutando'); class User extends Model { visible = ['first_name', 'last_name']; } ``` #### 临时修改可见属性 如果你想要在一个模型实例中显示隐藏的属性,你可以使用 `makeVisible` 方法。`makeVisible` 方法返回模型实例: ```js user.makeVisible('attribute').toData(); user.makeVisible(['attribute', 'another_attribute']).toData(); ``` 相应地,如果你想要在一个模型实例中隐藏可见的属性,你可以使用 `makeHidden` 方法。 ```js user.makeHidden('attribute').toData(); user.makeHidden(['attribute', 'another_attribute']).toData(); ``` 如果你想临时覆盖所有可见或隐藏的属性,你可以分别使用 `setVisible` 和 `setHidden` 方法: ```js user.setVisible(['id', 'name']).toData(); user.setHidden(['email', 'password', 'remember_token']).toData(); ``` ## 追加 JSON 值 有时,需要在模型转换为对象或 JSON 时添加一些数据库中不存在字段的对应属性。要实现这个功能,首先要定义一个访问器: ```js const { Model, Attribute } = requre('sutando'); class User extends Model { attributeIsAdmin() { return Attribute.make({ get: (value, attributes) => (attributes.admin === 'yes') }); } } ``` 然后,在模型属性 `appends` 中添加该属性名。注意,尽管访问器使用「驼峰命名法」方式定义,但是属性名通常以「蛇形命名法」的方式来引用: ```js const { Model } = requre('sutando'); class User extends Model { appends = ['is_admin']; } ``` 使用 `appends` 方法追加属性后,它将包含在模型的对象和 JSON 中。`appends` 数组中的属性也将遵循模型上配置的 `visible` 和 `hidden` 设置。 #### 运行时追加 你可以在单个模型实例上使用 `append` 方法来追加属性。或者,使用 `setAppends` 方法来重写整个追加属性的数组: ```js user.append('is_admin').toData(); user.setAppends(['is_admin']).toData(); ``` --- --- url: /zh_CN/guide/getting-started.md --- # 开始 ## 什么是 Sutando Sutando (发音类似 stand) 是一个对象关系映射器(ORM),可以让您轻松地与数据库进行交互。使用 Sutando 时,每个数据库表都有一个对应的「模型」,用于与该表进行交互。除了从数据库表中检索记录外,Sutando 模型还允许您从表中插入,更新和删除记录。 Sutando 深受 Laravel 框架的 ORM [Eloquent](https://laravel.com/docs/9.x/eloquent) 启发,使用方式几乎相同。 "sutando" 这个名字来自于日本漫画《JOJO 的奇妙冒险》中的替身(Stand)。就像替身为角色提供力量一样,希望 Sutando 为你的应用程序提供强大的功能和灵活性。 ## 快速开始 安装 Sutando 和 mysql 数据库包 ::: code-group ```sh [npm] $ npm install sutando mysql2 --save ``` ```sh [yarn] $ yarn add sutando mysql2 ``` ```sh [pnpm] $ pnpm add sutando mysql2 ``` ::: 进行 SQL 查询的最简单方法是使用数据库查询构建器。 它允许您使用 JavaScript 方法构造简单和复杂的 SQL 查询。 在以下示例中,我们从用户表中选择数据。 ```js const { sutando, Model } = require('sutando'); // 添加数据库连接信息 sutando.addConnection({ client: 'mysql2', connection: { host : '127.0.0.1', port : 3306, user : 'root', password : '', database : 'test' }, }); const db = sutando.connection(); // 使用查询构建器 const users = await sutando.table('users').where('votes', '>', 100).get(); // or const users = await db.table('users').where('votes', '>', 100).get(); // 使用 Schema Builder await sutando.schema().createTable('users', table => { table.increments('id').primary(); table.integer('votes'); table.timestamps(); }); // 使用 ORM class User extends Model {} const users = await User.query().where('votes', '>', 100).get(); ``` --- --- url: /zh_CN/guide/plugin.md --- # 插件 插件是一些独立的程序,可以给 Sutando 增加新功能和扩展已有功能 您可以加载多个插件来满足各类需求。 ## 插件使用 例如 Sutando 自带了两个插件, `SoftDeletes` 可以让模型支持软删除,`HasUniqueIds` 则是提供字符串作为主键的功能。可以像这样使用插件: ```js const { Model, compose, SoftDeletes, HasUniqueIds } = require('sutando'); class User extends SoftDeletes(Model) {} class Post extends HasUniqueIds(SoftDeletes(Model)) {} ``` 不过我们还是推荐使用 `compose` 助手函数来使用插件: ```js const { Model, compose, SoftDeletes, HasUniqueIds } = require('sutando'); class User extends compose(Model, SoftDeletes) {} class Post extends compose(Model, SoftDeletes, HasUniqueIds) {} ``` ## 编写一个插件 如果可能,Sutando 插件应实现为 class mixin。`mixin` 只是一个以类作为参数并返回子类的函数。 ```js const SomeMixin = (Model) => { return class extends Model { // 你的插件代码 } } ``` 为了更好地理解如何构建 Sutando 插件,我们可以试着写一个简单的插件,功能是为文章模型可以根据标题自动设置 `slug`。 建议在一个单独的文件中创建并导出它,以保证更好地管理逻辑,如下所示: ```js // plugins/sutando-slug.js const _ = require('lodash'); const HasSlug = (Model) => { return class extends Model { static booted() { // 执行父类的 booted Model.booted(); // 设置 creating 钩子 this.creating(model => { // 如果没有设置 slug,那么根据 title 属性自动生成 if (model.slug === undefined) { model.slug = _.kebabCase(model.title); } }); } } } module.exports = HasSlug; ``` 这个例子之中使用了[钩子](hooks),完成之后,可以这样使用插件: ```js const { Model, compose } = require('sutando'); const HasSlug = require('./plugins/sutando-slug'); class Post extends compose( Model, HasSlug ) { // ... } const post = new Post; post.title = 'The First Post Title'; await post.save(); console.log(post.slug); // the-first-post-title ``` 那么有个问题,如果我的数据库字段名称不是 `slug`,而是 `slug_name`,或者其他名称呢?我们只需要调整一下插件,让它能够接受一个字段名参数: ```js{4,13,14,15} // plugins/sutando-slug.js const _ = require('lodash'); const HasSlug = ({ column }) => (Model) => { return class extends Model { static booted() { // 执行父类的 booted Model.booted(); // 设置 creating 钩子 this.creating(model => { // 如果没有设置 slug,那么根据 title 属性自动生成 if (model[column] === undefined) { model[column] = _.kebabCase(model.title); } }); } } } module.exports = HasSlug; ``` 使用方式也会改变: ```js{6} const { Model, compose } = require('sutando'); const HasSlug = require('./plugins/sutando-slug'); class Post extends compose( Model, HasSlug({ column: 'custom_slug' }) ) { // ... } const post = new Post; post.title = 'The First Post Title'; await post.save(); console.log(post.custom_slug); // the-first-post-title ``` ### 添加资源 如果你将插件发布到 npm,而且它包含 [数据库迁移](migrations),你可以将迁移文件放在包的 `migrations` 目录下。\ 你的用户可以执行 `sutando migrate:publish <你的包名>` 将迁移文件复制到用户项目的迁移目录中。 --- --- url: /zh_CN/guide/plugin-list.md --- # 插件列表 ## 自带插件 * [SoftDeletes](models#软删除) 软删除,让模型支持软删除。 * [HasUniqueIds](models#uuid-主键-字符串主键) 提供字符串作为主键的功能。 ## 官方插件 * [@sutando/keeper](https://github.com/sutandojs/keeper) - 轻量级 API 令牌认证插件 ## 第三方插件 暂无 --- --- url: /zh_CN/guide/migrations.md --- # 数据迁移 数据迁移是数据库的版本控制,帮助开发者完成日常工作中的表结构变更与数据迁移。 ## 快速开始 ### 生成迁移 首先,生成一个配置文件。 ```bash $ npx sutando init ``` 或者全局安装 Sutando 的命令行工具 ```bash $ npm install -g sutando $ sutando init ``` 这会在项目目录生成一个 `sutando.config.js` 文件,用来设置数据库连接等信息。 ```js // Update with your config settings. module.exports = { client: 'mysql2', connection: { host: 'localhost', database: 'database', user: 'root', password: 'password' }, // 你可以增加多个连接,只需指定连接名称即可 connections: { pgsql: { client: 'pg', connection: { host: 'localhost', database: 'another_database', user: 'root', password: 'password' } } }, migrations: { table: 'migrations', path: 'migrations' }, models: { path: 'models', } }; ``` 之后你就可以使用 `migrate:make` 命令来生成数据库迁移。新的迁移文件默认放在你的 `migrations` 目录下。每个迁移文件名都包含一个时间戳来使 Sutando 确定迁移的顺序: ```bash $ npx sutando migrate:make create_flights_table ``` Sutando 将使用迁移文件的名称来猜测表名以及迁移是否会创建一个新表。如果 Sutando 能够从迁移文件的名称中确定表的名称,它将在生成的迁移文件中预填入指定的表,或者,你也可以直接在迁移文件中手动指定表名。 如果要为生成的迁移指定自定义路径,你可以在执行 `migrate:make` 命令时使用 `--path` 选项。给定的路径应该相对于执行命令的路径。 ### 迁移结构 迁移类包含两个方法:`up` 和 `down` 。`up` 方法用于向数据库中添加新表、列或索引,而 `down` 方法用于撤销 `up` 方法执行的操作。. 在这两种方法中,可以使用 Schema 构建器来富有表现力地创建和修改表。要了解 Schema 构建器上可用的所有方法,查看其文档。例如,以下迁移会创建一个 `flights` 表: ```js const { Migration } = require('sutando'); module.exports = class extends Migration { /** * Run the migrations. */ async up(schema) { await schema.createTable('flights', (table) => { table.increments('id'); table.string('name'); table.string('airline'); table.timestamps(); }); } /** * Reverse the migrations. */ async down(schema) { await schema.dropTableIfExists('flights'); } }; ``` ### 设置迁移连接 如果你的迁移将与应用程序默认数据库连接以外的数据库连接进行交互,你应该设置迁移的 `connection` 属性: ```js module.exports = class extends Migration { connection = 'pgsql'; /** * Run the migrations. */ async up(schema) { // ... } } ``` ### 执行迁移 执行 `migrate:run` 命令,来运行所有未执行过的迁移: ```bash $ npx sutando migrate:run ``` 如果你想查看目前已经执行了哪些迁移,可以使用 `migrate:status` 命令: ```bash $ npx sutando migrate:status ``` ### 回滚迁移 如果要回滚最后一次迁移操作,可以使用 `migrate:rollback`。该命令会回滚最后「一批」的迁移,这可能包含多个迁移文件: ```bash $ npx sutando migrate:rollback ``` 通过向 `rollback` 命令加上 `step` 参数,可以回滚指定数量的迁移。例如,以下命令将回滚最后五个迁移: ```bash $ npx sutando migrate:rollback --step=5 ``` ## 数据表 ### 创建数据表 接下来我们将使用 `createTable` 方法创建一个新的数据表。`createTable` 接受两个参数:第一个参数是表名,而第二个参数是一个回调函数: ```js const { Migration } = require('sutando'); module.exports = class extends Migration { /** * Run the migrations. */ async up(schema) { await schema.createTable('users', (table) => { table.increments('id'); table.string('name'); table.string('email'); table.timestamps(); }); } /** * Reverse the migrations. */ async down(schema) { await schema.dropTableIfExists('users'); } }; ``` 创建表时,可以使用数据库结构构建器的 列方法 来定义表的列。 #### 检查表 / 列是否存在 你可以使用 `hasTable` 和 `hasColumn` 方法检查表或列是否存在: ```js if (await schema.hasTable('users')) { // 「users」表存在... } if (await schema.hasColumn('users', 'email')) { // 「users」表存在,并且有「email」列... } ``` 此外,还可以使用其他一些属性和方法来定义表创建的其他地方。使用 MySQL 时,可以使用 engine 方法指定表的存储引擎: ```js await schema.createTable('users', (table) => { table.engine('InnoDB'); // ... }); ``` `charset` 和 `collate` 方法可用于在使用 MySQL 时为创建的表指定字符集和排序规则: ```js await schema.createTable('users', (table) => { table.charset('utf8mb4'); table.collate('utf8mb4_unicode_ci'); // ... }); ``` 如果你想给数据库表添加「注释」,你可以在表实例上调用 `comment` 方法。目前只有 MySQL 和 Postgres 支持表注释: ```js await schema.createTable('calculations', (table) => { table.comment('Business calculations'); // ... }); ``` ### 更新数据表 Schema 的 `table` 方法可用于更新现有表。与 `createTable` 方法一样,`table` 方法接受两个参数:表的名称和接收可用于向表添加列或索引的回调函数: ```js await schema.table('users', (table) => { table.integer('votes'); }); ``` ### 重命名 / 删除表 要重命名已存在的数据表,使用 `renameTable` 方法: ```js await schema.renameTable(from, to); ``` 要删除已存在的表,你可以使用 `dropTable` 或 `dropTableIfExists` 方法: ```js await schema.dropTable('users'); await schema.dropTableIfExists('users'); ``` ## 字段 ### 创建字段 Schema 的 `table` 方法可用于更新表。与 `createTable` 方法一样, `table` 方法接受两个参数:表名和一个回调函数,可以使用该实例向表中添加列: ```js await schema.table('users', (table) => { table.integer('votes'); }); ``` ### 可用的字段类型 Schema 构建器提供了多种方法,用来创建表中对应类型的列。下面列出了所有可用的方法: #### bigIncrements `bigIncrements` 方法用于在数据表中创建一个自增的 `UNSIGNED BIGINT` 类型(主键)的列: ```js table.bigIncrements('id'); ``` #### bigInteger `bigInteger` 方法用于在数据表中创建一个 `BIGINT` 类型的列: ```js table.bigInteger('votes'); ``` #### binary `binary` 方法用于在数据表中创建一个 `BLOB` 类型的列: ```js table.binary('photo'); ``` #### boolean `boolean` 方法用于在数据表中创建一个 `BOOLEAN` 类型的列: ```js table.boolean('confirmed'); ``` #### datetime `datetime` 方法用于在数据表中创建一个 `DATETIME` 类型的列,可选参数为精度的总位数: ```js table.datetime('created_at', { precision: 6 }); ``` #### date `date` 方法用于在数据表中创建一个 `DATE` 类型的列: ```js table.date('date'); ``` #### decimal `decimal` 方法用于在数据表中创建一个 `DECIMAL` 类型的列,可选参数分别为有效字数总位数、小数部分总位数: ```js table.decimal('amount'); table.decimal('amount', 8, 2); ``` #### double `double` 方法用于在数据表中创建一个 `DOUBLE` 类型的列,可选参数分别为有效字数总位数、小数部分总位数: ```js table.double('amount', 8, 2); ``` #### enum `enum` 方法用于在数据表中创建一个 `ENUM` 类型的列: ```js table.enum('difficulty', ['easy', 'hard']); ``` #### float `float` 方法用于在数据表中创建一个 `FLOAT` 类型的列,可选参数分别为有效字数总位数、小数部分总位数: ```js table.float('amount', 8, 2); ``` #### geometry `geometry` 方法相当于 `GEOMETRY`: ```js table.geometry('positions'); ``` #### increments `increments` 方法创建一个自动递增相当于 `UNSIGNED INTEGER` 的列作为主键: ```js table.increments('id'); ``` #### integer `integer` 方法用于在数据表中创建一个 `INTEGER` 类型的列: ```js table.integer('votes'); ``` #### json `json` 方法用于在数据表中创建一个 `JSON` 类型的列: ```js table.json('options'); ``` #### jsonb `jsonb` 方法用于在数据表中创建一个 `JSONB` 类型的列: ```js table.jsonb('options'); ``` #### point `point` 方法用于在数据表中创建一个 `POINT` 类型的列: ```js table.point('position'); ``` #### smallint `smallint` 方法用于在数据表中创建一个 `SMALLINT` 类型的列: ```js table.smallint('votes'); ``` #### string `string` 方法创建一个给定长度的 `VARCHAR` 等效列,相当于指定长度的 VARCHAR: ```js table.string('name', 100); ``` #### text `text` 方法用于在数据表中创建一个 `TEXT` 类型的列: ```js table.text('description'); ``` #### time `time` 方法创建一个具有可选精度(总位数)的 `TIME` 等效列: ```js table.time('sunrise', { precision: 6 }); ``` #### timestamp `timestamp` 方法创建一个具有可选精度(总位数)的 `TIMESTAMP` 类型的列: ```js table.timestamp('sunrise', { precision: 6 }); ``` #### timestamps `timestamps` 方法创建 `created_at` 和 `updated_at` `TIMESTAMP`等效列: ```js table.timestamps(); ``` #### tinyint `tinyint` 方法用于在数据表中创建一个 `TINYINT` 类型的列: ```js table.tinyint('votes'); ``` #### uuid `uuid` 方法用于在数据表中创建一个 `UUID` 类型的列: ```js table.uuid('id'); ``` ### 字段修饰符 除了上面列出的列类型外,在向数据库表添加列时还有几个可以使用的「修饰符」。例如,如果要把列设置为要使列为「可空」,你可以使用 `nullable` 方法: ```js await schema.table('users', (table) => { table.string('email').nullable(); }) ``` 下表时所有可用的列修饰符。此列表不包括索引修饰符: | 修饰符 | 说明 | | ---- | ---- | | `.after('column')` | 将该列放在其它字段「之后」(MySQL) | | `.charset('utf8mb4')` | 为该列指定字符集 (MySQL) | | `.collate('utf8_unicode_ci')` | 为该列指定排序规则 (MySQL/PostgreSQL/SQL Server) | | `.comment('my comment')` | 为该列添加注释 (MySQL/PostgreSQL) | | `.defaultTo(value)` | 为该列指定一个「默认值」 | | `.first()` | 将该列放在该表「首位」 (MySQL) | | `.nullable()` | 允许 NULL 值插入到该列 | | `.unsigned()` | 设置 INTEGER 类型的字段为 UNSIGNED (MySQL) | ### 修改字段 `alter` 方法可以将现有的字段类型修改为新的类型或修改属性。比如,你可能想增加 `string` 字段的长度,可以使用 `alter` 方法把 `name` 字段的长度从 25 增加到 50。所以,我们可以简单的更新字段属性然后调用 `alter` 方法: ```js await schema.table('users', (table) => { table.string('name', 50).alter(); }); ``` 当修改一个列时,你必须明确包括所有你想在列定义上保留的修改器 —— 任何缺失的属性都将被丢弃。例如,为了保留 unsigned、default 和 comment 属性,你必须在修改列时明确每个属性的修改。 ```js await schema.table('users', (table) => { table.integer('votes').unsigned().defaultTo(1).comment('my comment').alter(); }); ``` #### 重命名字段 要重命名一个列,你可以使用模式构建器提供的 `renameColumn` 方法: ```js await schema.table('users', (table) => { table.renameColumn('from', 'to'); }); ``` ### 删除字段 要删除一个列,你可以使用 `dropColumn` 方法。 ```js await schema.table('users', (table) => { table.dropColumn('votes'); }); ``` 如果要删除多个列,你可以使用 `dropColumns` 方法。 ```js await schema.table('users', (table) => { table.dropColumns('votes', 'avatar', 'location'); }); ``` ## 索引 ### 创建索引 结构生成器支持多种类型的索引。下面的例子中新建了一个值唯一的 `email` 字段。我们可以将 `unique` 方法链式地添加到字段定义上来创建索引: ```js await schema.table('users', (table) => { table.string('email').unique(); }); ``` 或者,你也可以在定义完字段之后创建索引。为此,你应该调用结构生成器上的 `unique` 方法,此方法应该传入唯一索引的列名称: ```js table.unique('email'); ``` 你甚至可以将数组传递给索引方法来创建一个复合(或合成)索引: ```js table.index(['account_id', 'created_at']); ``` 创建索引时,Sutando 会自动生成一个合理的索引名称,但你也可以传递参数来自定义索引名称: ```js table.index(['name', 'last_name'], 'idx_name_last_name'); table.unique('email', { indexName: 'unique_email' }); ``` #### 可用的索引类型 下面是所有可用的索引方法: | 命令 | 说明 | | ---- | ---- | | `table.primary('id');` | 添加主键 | | `table.primary(['id', 'parent_id']);` | 添加复合主键 | | `table.unique('email');` | 添加唯一索引 | | `table.index('state');` | 添加普通索引 | ### 删除索引 若要删除索引,将字段数组传给 `dropIndex` 方法,会删除根据表名、字段和键类型生成的索引名称,也可以第二个参数指定索引名称: | 命令 | 说明 | | ---- | ---- | | `table.dropPrimary('users', 'users_id_primary');` | 从「users」表中删除主键 | | `table.dropUnique('users', 'users_email_unique');` | 从「users」表中删除 unique 索引 | | `table.dropIndex('geo', 'geo_state_index');` | 从「geo」表中删除基本索引 | ### 外键约束 Sutando 还支持创建用于在数据库层中的强制引用完整性的外键约束。例如,让我们在 `posts` 表上定义一个引用 `users` 表的 `id` 字段的 `user_id` 字段: ```js await schema.createTable('posts', (table) => { table.integer('user_id').unsigned().notNullable(); table.string('title', 30); table.string('content'); table.foreign('user_id').references('id').inTable('users'); }); ``` --- --- url: /zh_CN/guide/query-builder.md --- # 查询构造器 Sutando 的数据库查询构造器为创建和运行数据库查询提供了一个方便的接口。它可以用于支持大部分数据库操作,并与 Sutando 支持的所有数据库系统完美运行。 Sutando 查询构造器允许你编写和执行 SQL 查询。 它建立在 [Knex.js](https://knexjs.org/) 之上,几乎没有改动。 我们将查询构造器分为以下几类 * 标准查询构造器允许您为选择、更新和删除操作构建 SQL 查询。 * 插入查询构造器允许您为插入操作构建 SQL 查询。 * 原始查询构造器允许您从原始 SQL 字符串编写和执行查询。 ## 运行数据库查询 ### 执行原生 SQL 查询 一旦配置好数据库连接,你可以使用 `raw` 方法来执行原生 SQL 语句: ```js const db = sutando.connection(); const response = await db.raw('SET TIME_ZONE = ?', ['UTC']); ``` 响应将是底层 SQL 库(例如 mysql2)通常在正常查询中返回的任何内容,因此您可能需要查看查询正在执行的基础库的文档,以确定如何处理响应。 ### 从表中检索所有行 你可以使用 `table` 方法来开始查询。`table` 方法为给定的表返回一个查询构造器实例,允许你在查询上链式调用更多的约束,最后使用 `get` 方法获取结果: ```js const db = sutando.connection(); const users = await db.table('users').get(); ``` `get` 方法返回一个包含查询结果的数组,其中每个结果都是对象。你可以访问字段作为对象的属性来访问每列的值: ```js const users = await db.table('users').get(); users.map(user => { console.log(user.name); }) ``` ### 从数据表中获取单行或单列 如果你只需要从数据表中获取一行数据,你可以使用 `first` 方法: ```js const user = await db.table('users').where('name', 'John').first(); console.log(user.email); ``` 如果是通过 `id` 字段值获取一行数据,可以使用 `find` 方法: ```js const user = await db.table('users').find(3); ``` ### 获取一列的值 如果你想获取包含单列值的集合,则可以使用 `pluck` 方法。在下面的例子中,我们将获取角色表中标题的集合: ```js const titles = await db.table('users').pluck('title'); titles.map(title => { console.log(title) }); ``` ## 分块结果 如果您需要处理成千上万的数据库记录,请考虑使用 `chunk` 方法。 这个方法一次检索一小块结果,并将每个块反馈到闭包函数中进行处理。 例如,让我们以一次 100 条记录的块为单位检索整个 `users` 表: ```js await db.table('users').orderBy('id').chunk(100, users => { users.map(user => { // do something... }) }); ``` 您可以通过从闭包中返回 `false` 来停止处理其他块: ```js await db.table('users').orderBy('id').chunk(100, users => { // Process the records... return false; }); ``` ## 聚合 查询构造器还提供了各种聚合方法,比如 `count`,`max`,`min`,`avg`,还有 `sum`。你可以在构造查询后调用任何方法: ```js const count = await db.table('users').count(); const price = await db.table('orders').max('price'); ``` 当然,你也可以将这些聚合方法与其他的查询语句相结合: ```js const price = await db.table('orders') .where('finalized', 1) .avg('price'); ``` ### 判断记录是否存在 除了通过 `count` 方法可以确定查询条件的结果是否存在之外,还可以使用 `exists` 方法: ```js const isExists = await table('orders').where('finalized', 1).exists() if (isExists) { // ... } ``` ## Select 说明 ### 指定一个 Select 语句 当然你可能不是总是希望从数据库表中获取所有列。使用 `select` 方法,你可以自定义一个 select 查询语句来查询指定的字段: ```js const users = await db.table('users') .select('name', 'email as user_email') .get(); ``` `distinct` 方法会强制让查询返回的结果不重复: ```js const users = await db.table('users').distinct().get(); ``` ## 原生表达式 有时候你可能需要在查询中使用原生表达式。你可以使用 `raw` 创建一个原生表达式: ```js const users = await db.table('users') .select(db.raw('count(*) as user_count, status')) .where('status', '<>', 1) .groupBy('status') .get(); ``` ### 原生方法 可以使用以下方法代替 `raw`,将原生表达式插入查询的各个部分。 注意,Sutando 无法保证所有使用原生表达式的查询都受到防 SQL 注入漏洞保护。 #### whereRaw `whereRaw` 方法将原生的 where 注入到你的查询中。这两个方法的第二个参数是可选项,值是一个绑定参数的数组: ```js const orders = await db.table('orders') .whereRaw('price > IF(state = "TX", ?, 100)', [200]) .get(); ``` #### havingRaw `havingRaw` 方法可以用于将原生字符串作为 having 语句的值。这两个方法的第二个参数是可选项,值是一个绑定参数的数组: ```js const orders = await db.table('orders') .select('department', db.raw('SUM(price) as total_sales')) .groupBy('department') .havingRaw('SUM(price) > ?', [2500]) .get(); ``` #### orderByRaw `orderByRaw` 方法可用于将原生字符串设置为 order by 语句的值: ```js const orders = await db.table('orders') .orderByRaw('updated_at - created_at DESC') .get(); ``` #### groupByRaw `groupByRaw` 方法可以用于将原生字符串设置为 group by 语句的值: ```js const orders = await db.table('orders') .select('city', 'state') .groupByRaw('city, state') .get(); ``` ## Joins ### Inner Join 语句 查询构造器也可以编写 `join` 方法。若要执行基本的「内链接」,你可以在查询构造器实例上使用 `join` 方法。传递给 `join` 方法的第一个参数是你需要连接的表的名称,而其他参数则使用指定连接的字段约束。你还可以在单个查询中连接多个数据表: ```js const users = await db.table('users') .join('contacts', 'users.id', '=', 'contacts.user_id') .join('orders', 'users.id', '=', 'orders.user_id') .select('users.*', 'contacts.phone', 'orders.price') .get(); ``` ### Left Join / Right Join 语句 如果你想使用 「左连接」或者 「右连接」代替「内连接」 ,可以使用 `leftJoin` 或者 `rightJoin` 方法。这两个方法与 `join` 方法用法相同: ```js const users = await db.table('users') .leftJoin('posts', 'users.id', '=', 'posts.user_id') .get(); const users = await db.table('users') .rightJoin('posts', 'users.id', '=', 'posts.user_id') .get(); ``` ### Cross Join 语句 你可以使用 `crossJoin` 方法和你想要连接的表名做「交叉连接」: ```js const sizes = await db.table('sizes') .crossJoin('colors') .get(); ``` ### 高级 Join 语句 你还可以指定更高级的 join 语句。比如传递一个闭包作为 `join` 方法的第二个参数。 ```js await db.table('users') .join('contacts', () => { this.on('users.id', '=', 'contacts.user_id').orOn(/* ... */); }) .get(); ``` ## Unions 查询构造器还提供了一种简洁的方式将两个或者多个查询联合在一起。例如,你可以先创建一个查询,然后使用 `union` 方法来连接更多的查询: ```js const first = db.table('users') .whereNull('first_name'); const users = await db.table('users') .whereNull('last_name') .union(first) .get(); ``` 查询构造器不仅提供了 `union` 方法,还提供了一个 `unionAll` 方法。当查询结合 `unionAll` 方法使用时,将不会删除重复的结果。`unionAll` 方法的用法和 `union` 方法一样。 ## 基础 Where 语句 ### Where 语句 你可以在 where 语句中使用查询构造器的 `where` 方法。调用 `where` 方法需要三个基本参数。第一个参数是字段的名称。第二个参数是一个操作符,它可以是数据库中支持的任意操作符。第三个参数是与字段比较的值。 例如。在 `users` 表中查询 `votes` 字段等于 100 并且 `age` 字段大于 `35` 的数据: ```js const users = await db.table('users') .where('votes', '=', 100) .where('age', '>', 35) .get(); ``` 为了方便起见。如果你想要比较一个字段的值是否等于给定的值。你可以将这个给定的值作为第二个参数传递给 `where` 方法。那么,Sutando 会默认使用 `=` 操作符 ```js const users = await db.table('users').where('votes', 100).get(); ``` 如上所述,您可以使用数据库支持的任意操作符: ```js const users = await db.table('users') .where('votes', '>=', 100) .get(); const users = await db.table('users') .where('votes', '<>', 100) .get(); const users = await db.table('users') .where('name', 'like', 'T%') .get(); ``` ### Or Where 语句 当链式调用多个 `where` 方法的时候,这些 where 语句将会被看成是 `and` 关系。另外,您也可以在查询语句中使用 `orWhere` 方法来表示 `or` `关系。orWhere` 方法接收的参数和 `where` 方法接收的参数一样: ```js const users = await db.table('users') .where('votes', '>', 100) .orWhere('name', 'John') .get(); ``` 如果您需要在括号内对 `or` 条件进行分组,那么可以传递一个闭包作为 `orWhere` 方法的第一个参数: ```js const users = await db.table('users') .where('votes', '>', 100) .orWhere(query => { query.where('name', 'Abigail') .where('votes', '>', 50); }) .get(); ``` 上面的例子将会生成下面的 SQL: ```SQL select * from users where votes > 100 or (name = 'Abigail' and votes > 50) ``` ### Where Not 语句 `whereNot` 和 `orWhereNot` 方法可用于否定一组给定的查询条件。例如,下面的查询排除了正在清仓甩卖或价格低于 10 的产品: ```js const products = await db.table('products') .whereNot(() => { this.where('clearance', true).orWhere('price', '<', 10); }) .get(); ``` ### 其他 Where 语句 #### whereBetween / orWhereBetween `whereBetween` 方法是用来验证字段的值是否在给定的两个值之间: ```js const users = await db.table('users') .whereBetween('votes', [1, 100]) .get(); ``` #### whereNotBetween / orWhereNotBetween `whereNotBetween` 方法是用来验证字段的值是否不在给定的两个值之间: ```js const users = await db.table('users') .whereNotBetween('votes', [1, 100]) .get(); ``` #### whereIn / whereNotIn / orWhereIn / orWhereNotIn `whereIn` 方法是用来验证一个字段的值是否在给定的数组中: ```js const users = await db.table('users') .whereIn('id', [1, 2, 3]) .get(); ``` `whereNotIn` 方法是用来验证一个字段的值是否不在给定的数组中: ```js const users = await db.table('users') .whereNotIn('id', [1, 2, 3]) .get(); ``` #### whereNull / whereNotNull / orWhereNull / orWhereNotNull `whereNull` 方法是用来验证给定字段的值是否为 `null`: ```js const users = await db.table('users') .whereNull('updated_at') .get(); ``` `whereNotNull` 方法是用来验证给定字段的值是否不为 `null`: ```js const users = await db.table('users') .whereNotNull('updated_at') .get(); ``` ### WhereX 有一种简便的方式来改变这些查询: ```js const users = await User.query().where('approved', 1).get(); const posts = await Post.query().where('views_count', '>', 100).get(); ``` 用下面的替代: ```js const users = await User.query().whereApproved(1).get(); const posts = await Post.query().whereViewsCount('>', 100).get(); ``` ### 逻辑分组 有时您可能需要将括号内的几个 “where” 子句分组,以实现查询所需的逻辑分组。实际上应该将 `orWhere` 方法的调用分组到括号中,以避免不可预料的查询逻辑误差。因此可以传递闭包给 `where` 方法: ```js const users = await db.table('users') .where('name', '=', 'John') .where(() => { this.where('votes', '>', 100).orWhere('title', '=', 'Admin'); }) .get(); ``` 如您所见,将闭包传递到 `where` 方法将指示查询生成器构造一个约束组。闭包将接收一个查询生成器实例,您可以使用该实例设置应包含在括号组中的条件。上面的示例将生成以下 SQL: ```SQL select * from users where name = 'John' and (votes > 100 or title = 'Admin') ``` ## Ordering, Grouping ### Ordering #### `orderBy` 方法 `orderBy` 方法允许你通过给定字段对结果集进行排序。 `orderBy` 的第一个参数应该是你希望排序的字段,第二个参数控制排序的方向,可以是 `asc` 或 `desc`: ```js const users = await db.table('users') .orderBy('name', 'desc') .get(); ``` 如果你需要使用多个字段进行排序,你可以多次引用 `orderBy`: ```js const users = await db.table('users') .orderBy('name', 'desc') .orderBy('email', 'asc') .get(); ``` #### `latest` & `oldest` 方法 `latest` 和 `oldest` 方法让你以一种便捷的方式通过日期进行排序。它们默认使用 `created_at` 列作为排序依据。当然,你也可以传递自定义的列名: ```js const user = await db.table('users') .latest() .first(); ``` #### 删除已经存在的所有排序 `clearOrder` 方法允许你删除已经存在的所有排序,如果你愿意,可以在之后附加一个新的排序。例如,你可以删除所有已存在的排序: ```js const query = db.table('users').orderBy('name'); const unorderedUsers = await query.clearOrder().get(); ``` ### Grouping #### `groupBy` & `having` 方法 如您所料,`groupBy` 和 `having` 方法用于将结果分组。 `having` 方法的使用与 `where` 方法十分相似: ```js const users = await db.table('users') .groupBy('account_id') .having('account_id', '>', 100) .get(); ``` You can use the `havingBetween` method to filter the results within a given range: ```js const report = await db.table('orders') .selectRaw('count(id) as number_of_orders, customer_id') .groupBy('customer_id') .havingBetween('number_of_orders', [5, 15]) .get(); ``` 你可以向 `groupBy` 方法传递多个参数,来对结果使用多个字段进行分组: ```js const users = await db.table('users') .groupBy('first_name', 'status') .having('account_id', '>', 100) .get(); ``` 对于更高级的 `having` 语法,参见 `havingRaw` 方法。 ## Limit & Offset #### `skip` & `take` 方法 要限制结果的返回数量,或跳过指定数量的结果,你可以使用 `skip` 和 `take` 方法: ```js const users = await db.table('users').skip(10).take(5).get(); ``` 或者你也可以使用 `limit` 和 `offset` 方法,这些方法在功能上分别等效于 `take` 和 `skip` 方法: ```js const users = await db.table('users') .offset(10) .limit(5) .get(); ``` ## 插入语句 查询构造器还提供了 `insert` 方法用于插入记录到数据库中。 `insert` 方法接收数组形式的字段名和字段值进行插入操作: ```js await db.table('users').insert({ email: 'kayla@example.com', votes: 0 }); ``` 你甚至可以将数组传递给 `insert` 方法,依次将多个记录插入到表中: ```js await db.table('users').insert([ { email: 'picard@example.com', votes: 0 }, { email: 'janeway@example.com', votes: 0 }, ]); ``` ## 更新语句 当然, 除了插入记录到数据库中,查询构造器也可以通过 `update` 方法更新已有的记录。 `update` 方法和 `insert` 方法一样,接受包含要更新的字段及值的数组。你可以通过 `where` 子句对 `update` 查询进行约束: ```js await db.table('users') .where('id', 1) .update({ votes: 1 }); ``` ## 自增与自减 查询构造器还提供了方便的方法来递增或递减给定列的值。这两个方法都至少接受一个参数:要修改的列。可以提供第二个参数来指定列的递增或递减量: ```js await db.table('users').increment('votes'); await db.table('users').increment('votes', 5); await db.table('users').decrement('votes'); await db.table('users').decrement('votes', 5); ``` ## 删除语句 查询构造器也可以使用 `delete` 方法从表中删除记录。 在使用 `delete` 前,可以添加 `where` 子句来约束 `delete` 语法: ```js const deleted = await db.table('users').delete(); const deleted = await db.table('users').where('votes', '>', 100).delete(); ``` ## 悲观锁 查询构造器也包含了一些能够帮助您在 `select` 语句中实现「悲观锁」的函数。要执行一个含有「共享锁」的语句,您可以在查询中使用 `forShare` 方法。共享锁可防止指定的数据列被篡改,直到事务被提交为止: ```js await db.table('users') .where('votes', '>', 100) .forShare() .get(); ``` 或者,您亦可使用 `forUpdate` 方法。使用「 update 」锁可以避免数据行被其他共享锁修改或选定: ```js await db.table('users') .where('votes', '>', 100) .forUpdate() .get(); ``` --- --- url: /zh_CN/guide/models.md --- # 模型 除了数据库查询构建器,Sutando 还拥有构建在 活动记录模式 (Active Record) 之上的数据模型。 Sutando 的数据模型层使执行 CRUD 操作、管理模型之间的关系和定义生命周期挂钩变得超级容易。 我们建议广泛使用模型,并针对特定用例使用标准查询构建器。 ## 创建你的第一个模型 我们来看一个基本的模型示例,随后开始讨论 Eloquent 的一些关键约定。 ```js const { Model } = require('sutando'); class Flight extends Model { // } ``` ### 数据表名称 看过上面的示例,你可能留意到了我们没有为 Sutando 指明 `Flight` 模型要使用哪张数据表。除非明确指定使用其它数据表,否则将按照约定,使用类的复数形式「蛇形命名」来作为表名。因此,在这种情况下,Sutando 将认为 `Flight` 模型存储的是 `flights` 表中的数据,而 `AirTrafficController` 模型会将记录存储在 `air_traffic_controllers` 表中。 如果模型的相应数据库表不符合此约定,可以通过在模型上定义 `table` 属性并指定模型的表名: ```js const { Model } = require('sutando'); class Flight extends Model { // 该表将与模型关联 table = 'my_flights'; } ``` ### 主键 Sutando 将假设模型有一个默认的主键列,该列为 `id` 。如果有必要,你可以定义一个字段 `primaryKey`,用来指定为模型的主键。 ```js const { Model } = require('sutando'); class Flight extends Model { // 与表关联的主键 primaryKey = 'flight_id'; } ``` 此外,Sutando 默认有一个 int 值的主键,如果你的主键不是自增或者不是数字类型,你可以在你的模型上定义一个属性 `incrementing` ,并将其设置为 `false`: ```js class Flight extends Model { // 指明模型的ID是否自动递增 incrementing = false; } ``` 如果你的模型主键不是 int,应该定义一个 `keyType` 属性在模型上,其值应为 `string`: ```js class Flight extends Model { // ID的数据类型 keyType = 'string'; } ``` ### UUID 主键 / 字符串主键 你可以选择使用字符串,而不是使用自动递增的整数作为模型的主键。例如使用 UUID 作为主键, 通过在模型中定义一个 `newUniqueId` 方法: ::: code-group ```sh [npm] $ npm install uuid --save ``` ```sh [yarn] $ yarn add uuid ``` ```sh [pnpm] $ pnpm add uuid ``` ::: ```js const { Model, compose, HasUniqueIds } = require('sutando'); const uuid = require('uuid'); class Article extends compose(Model, HasUniqueIds) { newUniqueId() { return uuid.v4(); } // ... } const article = await Article.create({ title: 'Traveling to Europe' }); article.id; // "8f8e8478-9035-4d23-b9a7-62f4d2612ce5" ``` ### 时间戳(Timestamps) 默认情况下,Sutando 希望模型相应的数据库表中存在 `created_at` 和 `updated_at` 列。在创建或更新模型时,Sutando 将自动设置这些列的值。如果不希望这些列由 Sutando 自动管理,那么你应该在模型上定义一个 `timestamps` 属性并且值为 `false`: ```js const { Model } = require('sutando'); class Flight extends Model { // 是否主动维护时间戳 timestamps = false; } ``` 如果你需要自定义存储时间戳的字段名,可以在模型中设置 `CREATED_AT` 和 `UPDATED_AT` 常量的值来实现: ```js const { Model } = require('sutando'); class Flight extends Model { static CREATED_AT = 'creation_date'; static UPDATED_AT = 'updated_date'; } ``` ### 数据库连接 默认情况下,Sutando 模型将使用你的应用程序配置的默认数据库连接。如果你将要指定使用特殊的数据库链接在你的模型,你可以设置一个 `connection` 属性在你的模型: ```js const { Model } = require('sutando'); class Flight extends Model { connection = 'sqlite'; } ``` ### 默认属性值 默认情况下,被实例化的模型不会包含任何属性值。如果你想为模型的某些属性定义默认值,可以在模型上定义一个 `attributes` 属性。放在 `attributes` 中的属性值应该是原始的,“可存储的” 格式,就像它们刚刚从数据库中读取一样: ```js const { Model } = require('sutando'); class Flight extends Model { attributes = { options: '[]', delayed: false, }; } ``` ## 模型检索 创建模型和它关联的数据库表后,你就可以从数据库中查询数据了。你可以将每个 Sutando 模型视为一个强大的 [查询构造器](./query-builder),使你能够更快速地查询与该模型关联的数据库表。模型的 `all` 方法将从模型的关联数据库表中检索所有记录: ```js const { Flight } = require('./models'); const flights = await Flight.query().all(); flights.map(flight => { console.log(flight.name) }) ``` #### 附加约束 Sutando 的 `all` 方法会返回模型中所有的结果。由于每个 Sutando 模型都充当一个 [查询构造器](./query-builder) ,所以你也可以添加查询条件,然后使用 `get`/`first`/`find` 方法获取查询结果: ```js const flights = await Flight.query().where('active', 1) .orderBy('name') .take(10) .get(); const flight = await Flight.query().where('active', 1).first(); const flight = await Flight.query().find(5); ``` #### 重新加载模型 你可以使用 `fresh` 和 `refresh` 重新加载从数据库中检索的 Sutando 模型实例。`fresh` 方法会重新从数据库中检索模型。现有的模型实例不受影响: ```js const flight = await Flight.query().where('number', 'FR 900').first(); const freshFlight = await flight.fresh(); ``` `refresh` 方法会使用数据库中的新数据重新赋值现有的模型。此外,已经加载的关系也会被重新加载: ```js const flight = await Flight.query().where('number', 'FR 900').first(); flight.number = 'FR 456'; await flight.refresh(); flight.number; // "FR 900" ``` ### 集合 Sutando 的 `all` 和 `get` 会从数据库中取得多个结果。然而,这些方法返回的不是数组,而是一个 [`Collection`](collections) 实例。 Sutando 的 `Collection` 继承自 [collect.js](https://collect.js.org/),它提供了大量的辅助函数来与数据集交互。例如,`reject` 方法可以根据闭包中的结果从集合中删除模型: ```js const flights = await Flight.query().where('destination', 'Paris').get(); const newFlights = flights.reject(flight => { return flight.cancelled; }); ``` 除了 `collect.js` 提供的函数外,Sutando 集合还提供了一些 [额外的函数](collections#available-methods),这些函数专用于与 Sutando 模型集合进行交互。 由于 Sutando 的集合实现了可迭代接口,因此你可以像循环数组一样循环集合: ```js for (let flight of flights) { console.log(flight.name); } ``` ### 结果分块 如果您想要尝试使用 `all` 或者 `get` 方法来加载成千上万的 Sutando 模型数据,那么您的应用程序可能会耗尽内存。为了避免出现这种情况,可以使用 `chunk` 方法来处理这些模型数据。 `chunk` 方法将会传递模型子集给一个闭包来进行处理。由于每次只获取 Sutando 模型当前块的数据,所以当处理大量模型数据的时候,`chunk` 方法将会明显减少内存的使用量: ```js const { Flight } = require('./models'); await Flight.query().chunk(200, flights => { flights.map(flight => { // }); }); ``` ## 检索单个模型 / 聚合 除了检索与给定查询匹配的所有记录之外,您还可以使用 `find` 或 `first` 方法检索单个记录。 这些方法不返回模型集合,而是返回单个模型实例: ```js const { Flight } = require('./modles'); // 使用主键检索模型... const flight = await Flight.query().find(1); // 检索符合查询条件的第一个模型... const flight = await Flight.query().where('active', 1).first(); ``` ### 未找到异常 有时,如果找不到模型,您可能希望抛出异常。 这在路由或控制器中特别有用。 `findOrFail` 和 `firstOrFail` 方法将检索查询的第一个结果; 但是,如果没有找到结果,将会抛出一个 `ModelNotFoundError`: ```js const { ModelNotFoundError } = requre('sutando'); try { const flight = await Flight.query().findOrFail(1); const flight = await Flight.query().where('legs', '>', 3).firstOrFail(); } catch (e) { e instanceof ModelNotFoundError; } ``` 配合框架捕获到 `ModelNotFoundError`,可以自动将 404 HTTP 响应发送回客户端: ```js const app = require('express')(); require('express-async-errors'); const { ModelNotFoundError } = requre('sutando'); app.get('/users/:id', async (req, res) => { const user = await User.query().findOrFail(req.params.id); res.send(user); }); app.use((err, req, res, next) => { if (err instanceof ModelNotFoundError) { return res.status(404).send(err.message); } next(err); }); ``` ### 检索或创建模型 `firstOrCreate` 方法将尝试使用给定的键值对定位数据库记录。 如果在数据库中找不到模型,则会插入一条记录,其中包含将第一个数组参数与可选的第二个数组参数合并后的属性: `firstOrNew` 方法与 `firstOrCreate` 一样,将尝试在数据库中查找与给定属性匹配的记录。 但是,如果找不到模型,则会返回一个新的模型实例。 请注意,`firstOrNew` 返回的模型尚未持久化到数据库中。 你需要手动调用 `save` 方法来持久化它: ```js const { Flight } = require('./modles'); // 按名称检索航班或在它不存在时创建它... const flight = await Flight.query().firstOrCreate({ name: 'London to Paris' }); // 按名称检索航班或使用名称、延迟和到达时间属性创建它... const flight = await Flight.query().firstOrCreate( { name: 'London to Paris' }, { delayed: 1, arrival_time: '11:30' } ); // 按名称检索航班或实例化新的航班实例... const flight = await Flight.query().firstOrNew({ name: 'London to Paris' }); // 按名称检索航班或使用名称、延迟和到达时间属性实例化... const flight = await Flight.query().firstOrNew( { name: 'Tokyo to Sydney' }, { delayed: 1, arrival_time: '11:30' } ); ``` ### 聚合查询 在与 Sutando 模型交互时,您还可以使用 [查询构建器](./query-builder#聚合) 提供的 `count`、`sum`、`max` 和其他 聚合方法。 正如你所料,这些方法返回一个数值而不是一个 Sutando 模型实例: ```js const count = await Flight.query().where('active', 1).count(); // 100 const max = await Flight.query().where('active', 1).max('price'); // 104 const flight = await Flight.query().find(1); // flight instanceof Flight ``` ## 插入及更新模型 ### 插入 在使用 Sutando 时,我们不仅仅需要从数据库中检索模型。 我们还需要插入新记录。 幸运的是,Sutando 让它变得非常简单。 要将新记录插入数据库,您应该实例化一个新模型实例并在模型上设置属性。 然后,在模型实例上调用 `save` 方法即可: ```js // express const { Flight } = require('./model'); app.post('/flights', async (req, res) => { // 验证请求... const flight = new Flight; flight.name = req.name; await flight.save(); res.send(flight); }); ``` 在此示例中,我们将传入 HTTP 请求中的 name 字段分配给 `Flight` 模型实例的 `name` 属性。 当我们调用 `save` 方法时,一条记录将被插入到数据库中。 模型的 `created_at` 和 `updated_at` 时间戳会在调用 `save` 方法时自动设置,因此无需手动设置。 或者,您可以使用 `create` 方法“保存” 新模型。 `create` 方法将返回新插入的模型实例: ```js const { Flight } = require('./model'); const flight = await Flight.query().create({ name: 'London to Paris', }); ``` ### 更新 `save` 方法也可以用来更新数据库中已经存在的模型。 要更新模型,您应该检索它并设置您希望更新的任何属性。 然后调用模型的 `save` 方法。 同样,`updated_at` 时间戳会自动更新,因此无需手动设置其值: ```js const { Flight } = require('./model'); const flight = await Flight.query().find(1); flight.name = 'Paris to London'; await flight.save(); ``` #### 批量更新 还可以批量更新与给定条件匹配的所有模型。 在此示例中,所有 `active` 且 `destination` 为 `San Diego` 的航班都将被标记为延迟: ```js await Flight.query().where('active', 1) .where('destination', 'San Diego') .update({ delayed: 1, }); ``` `update` 方法需要一个表示应该更新的列的列和值对数组。 `update` 方法返回受影响的行数。 ::: tip 批量更新时,不会触发模型的 `saving`、`saved`、`updating` 和 `updated` 模型事件。 这是因为在批量更新时从未真正检索到模型。 ::: #### 检查属性变更 Sutando 提供了 `isDirty` 方法,以检查模型的内部状态并确定其属性从最初加载时如何变化。 `isDirty` 方法确定自加载模型以来是否已更改任何属性。 您可以传递特定的属性名称来确定特定的属性是否变脏: ```js const { Flight } = require('./model'); const user = await User.query().create({ first_name: 'Taylor', last_name: 'Otwell', title: 'Developer', }); user.title = 'Painter'; user.isDirty(); // true user.isDirty('title'); // true user.isDirty('first_name'); // false user.isDirty(['first_name', 'title']); // true await user.save(); user.isDirty(); // false ``` ### 新增或更新 有时,如果不存在匹配模型,您可能需要更新现有模型或创建新模型。 与 `firstOrCreate` 方法一样,`updateOrCreate` 方法将模型持久化,因此无需手动调用 `save` 方法。 在下面的示例中,如果存在具有 Oakland 的 departure 位置和 San Diego 的 destination 位置的航班,其 `price` 和 `discounted` 列将被更新。 如果不存在这样的航班,将创建一个新航班,该航班具有将第一个参数数组与第二个参数数组合并后的属性: ```js const flight = await Flight.query().updateOrCreate( { departure: 'Oakland', destination: 'San Diego' }, { price: 99, discounted: 1 } ); ``` ## 删除模型 想删除模型,你可以调用模型实例的 `delete` 方法: ```js const { Flight } = require('./models'); const flight = await Flight.query().find(1); await flight.delete(); ``` #### 通过其主键删除现有模型 上面的示例中,我们先检索,再用 `delete` 删除。若知道主键,则用 `destroy` 直接删除。 `destroy` 可以接受一个主键、多个主键、一个主键数组或一个主键集合 collection: ```js await Flight.query().destroy(1); await Flight.query().destroy(1, 2, 3); await Flight.query().destroy([1, 2, 3]); ``` #### 使用查询删除模型 通过 Sutando 查询来删除所有符合查询条件的模型。 例如,我们将删除所有标记为无效的航班。像批量更新一样,批量删除将不会为已删除的模型调度模型事件: ```js const deleted = await Flight.query().where('active', 0).delete(); ``` ### 软删除 除了实际删除记录,还可以 “软删除”。 软删除不会从数据库中删除,而是在 `deleted_at` 属性中设置删除模型的日期和时间。要启用软删除,请在使用 `SoftDeletes` 插件和在相应数据表中添加 `deleted_at` 字段: ```js const { Model, compose, SoftDeletes } = require('sutando'); class Flight extends compose(Model, SoftDeletes) { // ... } ``` 那现在,当你在模型实例上使用 `delete` 方法,当前日期时间会写入 `deleted_at` 字段。同时,查询出来的结果也会自动排除已被软删除的记录。 你可以使用 `trashed` 方法来验证给定的模型实例是否已被软删除: ```js if (flight.trashed()) { // } ``` #### 恢复软删除模型 有时会对软删除模型进行「撤销」,在已软删除的数据上使用 `restore` 方法即可恢复到有效状态。 `restore` 方法会将模型的 `deleted_at` 列设为 `null`: ```js await flight.restore(); ``` 你也可以在查询中使用 `restore` 方法,从而快速恢复多个模型。和其他「批量」操作一样,这个操作不会触发模型的任何事件: ```js await Flight.query().withTrashed() .where('airline_id', 1) .restore(); ``` 类似 `withTrashed` 方法,`restore` 方法也可以用在 [关联](./relationships) 上: ```js await flight.related('history').restore(); ``` #### 永久删除 有时你可能需要从数据库中真正删除模型。要从数据库中永久删除软删除的模型,请使用 `forceDelete` 方法: ```js await flight.forceDelete(); ``` 您也可以在模型关联上调用 `forceDelete` 方法: ```js await flight.related('history').forceDelete(); ``` ### 查询软删除模型 #### 包含已软删除的模型 前面提到,查询结果会自动剔除已被软删除的结果。当然,你可以使用 `withTrashed` 方法来获取包括软删除模型在内的模型: ```js const { Flight } = require('./models'); const flights = await Flight.query().withTrashed() .where('account_id', 1) .get(); ``` `withTrashed` 方法也可以用在 [关联](./relationships) 查询: ```js await flight.related('history').withTrashed().get(); ``` #### 只检索软删除模型 `onlyTrashed` 方法只获取已软删除的模型: ```js const flights = await Flight.query().onlyTrashed() .where('airline_id', 1) .get(); ``` ## 查询作用域 作用域允许定义通用的约束集合以便在应用程序中重复使用。例如,你可能经常需要获取所有「流行」的用户。要定义这样一个范围,只需要在对应的 Sutando 模型方法前添加 `scope` 前缀。 作用域总是返回一个查询构造器实例: ```js const { Model } = require('./models'); class User extends Model { scopePopular(query){ return query.where('votes', '>', 100); } scopeActive(query){ query.where('active', 1); } } ``` ### 使用作用域 一旦定义了作用域,就可以在查询该模型时调用作用域方法。不过,在调用这些方法时不必包含 `scope` 前缀。甚至可以链式调用多个作用域,例如: ```js const { User } = require('./models'); const users = await User.query().popular().active().orderBy('created_at').get(); ``` 通过 `or` 查询运算符组合多个 Sutando 模型作用域可能需要使用闭包来实现正确的逻辑分组: ```js const users = await User.query().popular().orWhere(query => { query.active(); }).get(); ``` ### 动态作用域 有时可能地希望定义一个可以接受参数的作用域。把额外参数传递给作用域就可以达到此目的。作用域参数要放在 `query` 参数之后: ```js const { Model } = require('./models'); class User extends Model { scopeOfType(query, type){ return query.where('type', type); } } ``` 一旦将预期的参数添加到作用域方法的签名中,您就可以在调用作用域时传递参数: ```js const users = await User.query().ofType('admin').get(); ``` ## 模型比较 有时可能需要判断两个模型是否「相同」。`is` 和 `isNot` 方法可以用来快速校验两个模型是否拥有相同的主键、表和数据库连接: ```js if (post.is(anotherPost)) { // } if (post.isNot(anotherPost)) { // } ``` --- --- url: /zh_CN/guide/relationships.md --- # 模型关联 Sutando 关联在 Sutando 模型类中以方法的形式呈现。如同 Sutando 模型本身,关联也可以作为强大的 [查询构造器](./query-builder) 使用,提供了强大的链式调用和查询功能。例如,我们可以在 `posts` 关联的链式调用中附加一个约束条件: ```js await user.related('posts').where('active', 1).get(); ``` 不过在深入使用关联之前,让我们先学习如何定义每种关联类型。 ## 一对一 一对一是最基本的数据库关系。例如,一个 `User` 模型可能与一个 `Phone` 模型相关联。为了定义这个关联关系,我们要在 `User` 模型中写一个 `relationPhone` 方法,在这个方法中调用 `hasOne` 方法并返回其结果。`hasOne` 方法被定义在 `Model` 这个模型基类中: ```js const { Model } = require('sutando'); class Phone extends Model {} class User extends Model { relationPhone() { return this.hasOne(Phone); } } ``` 传递给 `hasOne` 方法的第一个参数是相关模型类的名称。 一旦定义了关系,我们就可以使用 `getRelated` 方法检索相关记录: ```js const user = await User.query().find(1); const phone = await user.getRelated('phone'); ``` Sutando 基于父模型(User)的名称来确定关联模型(Phone)的外键名称。在本例中,会自动假定 `Phone` 模型有一个 `user_id` 的外键。如果你想重写这个约定,可以传递第二个参数给 `hasOne` 方法: ```js return this.hasOne(Phone, 'foreign_key'); ``` 另外,Sutando 假设外键的值是与父模型的主键(Primary Key)相同的。换句话说,Sutando 将会通过 `Phone` 记录的 `user_id` 列中查找与用户表的 `id` 列相匹配的值。如果你希望使用自定义的主键值,而不是使用 `id` 或者模型中的 `primaryKey` 属性,你可以给 `hasOne` 方法传递第三个参数: ```js return this.hasOne(Phone, 'foreign_key', 'local_key'); ``` ### 定义反向关联 我们已经能从 `User` 模型访问到 `Phone` 模型了。接下来,让我们再在 `Phone` 模型上定义一个关联,它能让我们访问到拥有该电话的用户。我们可以使用 `belongsTo` 方法来定义反向关联, `belongsTo` 方法与 `hasOne` 方法相对应: ```js const { Model } = require('sutando'); class User extends Model { relationPhone() { return this.hasOne(Phone); } } class Phone extends Model { relationUser() { return this.belongsTo(User); } } ``` 在调用 `related('user')` 方法时,Sutando 会尝试查找一个 `User` 模型,该 `User` 模型上的 `id` 字段会与 `Phone` 模型上的 `user_id` 字段相匹配。 Sutando 通过关联方法(user)的名称并使用 \_id 作为后缀名来确定外键名称。因此,在本例中,Sutando 会假设 `Phone` 模型有一个 `user_id` 字段。但是,如果 `Phone` 模型的外键不是 `user_id`,这时你可以给 `belongsTo` 方法的第二个参数传递一个自定义键名: ```js relationUser() { return this.belongsTo(User, 'foreign_key'); } ``` 如果父模型不使用 `id` 字段来作为主键,或者您想要使用其他的字段来匹配相关联的模型,那么您可以向 `belongsTo` 方法传递第三个参数,这个参数是在父模型中自己定义的字段: ```js relationUser() { return this.belongsTo(User, 'foreign_key', 'owner_key'); } ``` ## 一对多 当要定义一个模型是其他(一个或者多个)模型的父模型这种关系时,可以使用一对多关联。例如,一篇博客可以有很多条评论。和其他模型关联一样,一对多关联也是在 Sutando 模型文件中用一个方法来定义的: ```js const { Model } = require('sutando'); class Post extends Model { relationComments() { return this.hasMany(Comment); } } ``` 注意,Sutando 将会自动为 `Comment` 模型选择一个合适的外键。通常,这个外键是通过使用父模型的「蛇形命名」方式,然后再加上 `_id` 的方式来命名的。因此,在上面这个例子中,Sutando 将会默认 `Comment` 模型的外键是 `post_id` 字段。 如果关联方法被定义,那么我们就可以通过 `getRelated('comments')` 方法来访问相关的评论 集合: ```js const { Post } = require('./models'); const post = await Post.query().find(1); const comments = await post.getRelated('comments'); comments.map(comment => { // }); ``` 由于所有的关系都可以看成是查询构造器,所以您也可以通过链式调用的方式,在 `related('comments')` 方法中继续添加条件约束: ```js const post = await Post.query().find(1); const comment = await post.related('comments') .where('title', 'foo') .first(); ``` 像 `hasOne` 方法一样,`hasMany` 方法中也可以接受额外的参数,从而来覆盖外键和本地键: ```js return this.hasMany(Comment, 'foreign_key'); return this.hasMany(Comment, 'foreign_key', 'local_key'); ``` ## 一对多 (反向) / Belongs To 目前我们可以访问一篇博客的所有评论,下面我们可以定义一个关联关系,从而让我们可以通过一条评论来获取到它所属的博客。这个关联关系是 `hasMany` 的反向,可以子模型中通过 `belongsTo` 方法来定义这种关联关系: ```js const { Model } = require('sutando'); class Comment extends Model { relationPost() { return this.belongsTo(Post); } } ``` 在上面这个例子中,Sutando 将会尝试寻找 `Post` 模型中的 `id` 字段与 `Comment` 模型中的 `post_id` 字段相匹配。 Sutando 通过检查关联方法的名称,从而在关联方法名称后面加上 `_` ,然后再加上父模型(Post)的主键名称,以此来作为默认的外键名。因此,在上面这个例子中,Sutando 将会默认 `Post` 模型在 `comments` 表中的外键是 `post_id。` 但是,如果您的外键不遵循这种约定的话,那么您可以传递一个自定义的外键名来作为 `belongsTo` 方法的第二个参数: ```js relationPost() { return this.belongsTo(Post, 'foreign_key'); } ``` 如果您的父表(Post 表)不使用 `id` 来作为它的主键的话,或者您希望通过其他列来关联相关模型的话,那么您可以传递一个参数来作为 `belongsTo` 方法的第三个参数,这个参数是父表(Post 表)中想要作为关联关系的字段的名称。 ```js relationPost() { return this.belongsTo(Post, 'foreign_key', 'owner_key'); } ``` #### 默认模型 当 `belongsTo`,`hasOne` 这些关联方法返回 null 的时候,你可以定义一个默认的模型返回。该模式通常被称为 空对象模式,它可以帮你省略代码中的一些条件判断。在下面这个例子中,如果 `Post` 模型中没有用户,那么 `user` 关联关系将会返回一个空的 `User` 实例: ```js reLationUser() { return this.belongsTo(User).withDefault(); } ``` 可以向 `withDefault` 方法传递对象或者闭包来填充默认模型的属性。 ```js reLationUser() { return this.belongsTo(User).withDefault({ name: 'Guest Author' }); } reLationUser() { return this.belongsTo(User).withDefault((user, post) => ({ name: `Post ${post.id} Author` })); } ``` ## 多对多关联 多对多关联比 `hasOne` 和 `hasMany` 关联稍微复杂些。举个例子,一个用户可以拥有多个角色,同时这些角色也可以分配给其他用户。例如,一个用户可是「作者」和「编辑」;当然,这些角色也可以分配给其他用户。所以,一个用户可以拥有多个角色,一个角色可以分配给多个用户。 #### 表结构 要定义这种关联,需要三个数据库表: `users`,`roles` 和 `role_user`。`role_user` 表的命名是由关联的两个模型按照字母顺序来的,并且包含了 `user_id` 和 `role_id` 字段。该表用作链接 users 和 roles 的中间表 特别提醒,由于角色可以属于多个用户,因此我们不能简单地在 `roles` 表上放置 `user_id` 列。如果这样,这意味着角色只能属于一个用户。为了支持将角色分配给多个用户,需要使用 `role_user` 表。我们可以这样定义表结构: ``` users id - integer name - string roles id - integer name - string role_user user_id - integer role_id - integer ``` #### 模型结构 多对多关系是通过编写一个返回 `belongsToMany` 方法结果的方法来定义的。 `belongsToMany` 方法由 Model 基类提供,您的应用程序的所有 Sutando 模型都使用该基类。 例如,让我们在 `User` 模型上定义一个 `relationRoles` 方法。 传递给此方法的第一个参数是相关模型类的名称: ```js const { Model } = require('sutando'); class User extends Model { relationRoles() { return this.belongsToMany(Role); } } ``` 由于所有的关系也可以作为查询构建器,你可以通过调用 `related('roles')` 方法并继续将条件链接到查询上来为关系查询添加更多约束: ```js const user = await User.query().find(1); const roles = await user.related('roles').orderBy('name').get(); ``` 正如前面所提到的,为了确定关联连接表的表名,Sutando 会按照字母顺序连接两个关联模型的名字。当然,你也可以不使用这种约定,传递第二个参数到 `belongsToMany` 方法即可: ```js return this.belongsToMany(Role, 'role_user'); ``` 除了自定义连接表的表名,你还可以通过传递额外的参数到 `belongsToMany` 方法来定义该表中字段的键名。第三个参数是定义此关联的模型在连接表里的外键名,第四个参数是另一个模型在连接表里的外键名: ```js return this.belongsToMany(Role, 'role_user', 'user_id', 'role_id'); ``` #### 定义反向关联 要定义多对多关系的反向关联,您应该在相关模型上定义一个方法,该方法也返回 `belongsToMany` 方法的结果。 为了完成我们的 `user` / `role` 示例,让我们在 `Role` 模型上定义 `relationUsers` 方法: ```js const { Model } = require('sutando'); class Role extends Model { relationUsers() { return this.belongsToMany(User); } } ``` 如你所见,除了引入模型 `User` 外,其它与在 `User` 模型中定义的完全一样。由于我们重用了 `belongsToMany` 方法,自定义连接表表名和自定义连接表里的键的字段名称在这里同样适用。 ### 获取中间表字段 正如你刚才所了解的一样,多对多的关联关系需要一个中间表来提供支持, Sutando 提供了一些有用的方法来和这张表进行交互。例如,假设我们的 `User` 对象关联了多个 `Role` 对象。在获得这些关联对象后,可以使用模型的 `pivot` 属性访问中间表的属性: ```js const { User } = require('./models'); const user = await User.query().find(1); const roles = await user.getRelated('roles'); roles.map(role => { console.log(role.pivot.created_at); }); ``` 需要注意的是,我们获取的每个 `Role` 模型对象,都会被自动赋予 `pivot` 属性,它代表中间表的一个模型对象,并且可以像其他的 Sutando 模型一样使用。 默认情况下,`pivot` 对象只包含两个关联模型的主键,如果你的中间表里还有其他额外字段,你必须在定义关联时明确指出: ```js return this.belongsToMany(Role).withPivot('active', 'created_by'); ``` 如果你想让中间表自动维护 `created_at` 和 `updated_at` 时间戳,那么在定义关联时附加上 `withTimestamps` 方法即可: ```js return this.belongsToMany(Role).withTimestamps(); ``` #### 自定义 `pivot` 属性名称 如前所述,可以通过 `pivot` 属性在模型上访问中间表中的属性。 但是,你可以随意自定义此属性的名称,以更好地反映其在应用程序中的用途。 例如,如果你的应用程序包含可能订阅播客的用户,则用户和播客之间可能存在多对多关系。 如果是这种情况,你可能希望将中间表属性重命名为 `subscription` 而不是 `pivot`。 这可以在定义关系时使用 `as` 方法来完成: ```js return this.belongsToMany(Podcast) .as('subscription') .withTimestamps(); ``` ### 通过中间表过滤查询 您还可以在定义关系时使用 `wherePivot`、`wherePivotIn`、`wherePivotNotIn`、`wherePivotBetween`、`wherePivotNotBetween`、`wherePivotNull` 和 `wherePivotNotNull` 方法过滤由 `belongsToMany` 关系查询返回的结果: ```js return this.belongsToMany(Role) .wherePivot('approved', 1); return this.belongsToMany(Role) .wherePivotIn('priority', [1, 2]); return this.belongsToMany(Role) .wherePivotNotIn('priority', [1, 2]); return this.belongsToMany(Podcast) .as('subscriptions') .wherePivotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']); return this.belongsToMany(Podcast) .as('subscriptions') .wherePivotNotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']); return this.belongsToMany(Podcast) .as('subscriptions') .wherePivotNull('expired_at'); return this.belongsToMany(Podcast) .as('subscriptions') .wherePivotNotNull('expired_at'); ``` ### 通过中间表列对查询排序 您可以使用 `orderByPivot` 方法对 `belongsToMany` 关系查询返回的结果进行排序。 在以下示例中,我们将检索用户的所有最新徽章: ```js return this.belongsToMany(Badge) .where('rank', 'gold') .orderByPivot('created_at', 'desc'); ``` ## 查询关联 因为所有的 Sutando 关联都是通过方法定义的,你可以调用这些方法来获取关联的实例,而无需真实执行查询来获取相关的模型。此外,所有的 Sutando 关联也可以用作查询生成器,允许你在最终对数据库执行 SQL 查询之前,继续通过链式调用添加约束条件。 例如,假设有一个博客系统,它的 `User` 模型有许多关联的 `Post` 模型: ```js const { Model } = require('sutando'); class User extends Model { relationPosts() { return this.hasMany(Post); } } ``` 你可以查询 `posts` 关联,并给它添加额外的约束条件,如下例所示: ```js const { User } = require('./models'); const user = await User.query().find(1); await user.related('posts').where('active', 1).get(); ``` 你可以在关联上使用任意的 [查询构造器](./query-builder) 方法,所以一定要阅读查询构造器的文档,了解它的所有方法,这会对你非常有用。 #### 在关联之后链式添加 `orWhere` 子句 如上例所示,你可以在查询关联时,自由的给关联添加额外的约束条件。但是,在将 `orWhere` 子句链接到关联上时,一定要小心,因为 `orWhere` 子句将在逻辑上与关联约束处于同一级别: ```js await user.related('posts') .where('active', 1) .orWhere('votes', '>=', 100) .get(); ``` 上面的例子将生成以下 SQL。像你看到的那样, 这个 `or` 子句的查询指令,将返回大于 100 票的任一用户,查询不再限于特定的用户: ```SQL select * from posts where user_id = ? and active = 1 or votes >= 100 ``` 在大多数情况下,你应该使用逻辑组在括号中对条件检查进行分组: ```js await user.related('posts') .where(query => { return query.where('active', 1).orWhere('votes', '>=', 100); }) .get(); ``` 上面的示例将生成以下 SQL。 请注意,逻辑分组已对约束进行了正确分组,并且查询仍然限定于特定用户: ```SQL select * from posts where user_id = ? and (active = 1 or votes >= 100) ``` ### 查询已存在的关联 检索模型记录时,您可能希望根据关系的存在限制结果。 例如,假设您要检索至少有一条评论的所有博客文章。 为此,您可以将关系的名称传递给 `has` 和 `orHas` 方法: ```js const { Post } = require('./models'); // 查出至少有一条评论的文章... const posts = await Post.query().has('comments').get(); ``` 也可以指定运算符和数量来进一步自定义查询: ```js // 查出至少有三条评论的文章... const posts = await Post.query().has('comments', '>=', 3).get(); ``` 也可以用「点」语法构造嵌套的 `has` 语句。例如,查出至少有一条评论和图片的文章: ```js // 查出至少有一条带图片的评论的文章... const posts = await Post.query().has('comments.images').get(); ``` 如果需要更多功能,可以使用 `whereHas` 和 `orWhereHas` 方法将「where」条件放到 `has` 查询上。这些方法允许你向关联加入自定义约束,比如检查评论内容: ```js // 获取至少带有一条评论内容包含 code% 关键词的文章... const posts = await Post.query().whereHas('comments', query => { query.where('content', 'like', 'code%'); }).get(); // 获取至少带有十条评论内容包含 code% 关键词的文章... const posts = await Post.query().whereHas('comments', query => { query.where('content', 'like', 'code%'); }, '>=', 10).get(); ``` ## 聚合关联模型 ### 关联模型计数 有时您可能需要计算给定关系的相关模型的数量,而不实际加载模型。 为此,您可以使用 `withCount` 方法。 `withCount` 方法将在生成的模型上放置 `{relation}_count` 属性: ```js const { Post } = require('./models'); const posts = await Post.query().withCount('comments').get(); posts.map(post => { console.log(post.comments_count); }); ``` 通过将数组传递到 `withCount` 方法,可以为多个关系添加「计数」,并向查询添加附加约束: ```js const posts = await Post.query().withCount({ comments: query => query.where('content', 'like', 'code%'); }).get(); console.log(posts.get(0).comments_count); ``` ### 延迟加载计数 使用 `loadCount` 可以在模型获取后加载关联关系的数量。 ```js const book = await Book.query().first(); await book.loadCount('genres'); ``` 如果你需要在统计时设置额外查询条件,可以通过传递键为关联关系名、值为查询闭包的数组来实现: ```js await book.loadCount({ reviews: query => query.where('rating', 5); }) ``` ### 关联关系计数与自定义获取字段 如果你的查询同时包含 `withCount` 和 `select`,请确保 `withCount` 一定在 `select` 之后调用: ```js const posts = await Post.query().select(['title', 'body']) .withCount('comments') .get(); ``` ### 其他聚合函数 除了 `withCount` 方法外,Sutando 还提供了 `withMin`, `withMax`, `withAvg`, `withSum` 和 `withExists` 等聚合方法。 这些方法会通过 `{relation}_{function}_{column}` 的命名方式将聚合结果添加到获取到的模型属性中: ```js const { Post } = require('./models'); const posts = await Post.query().withSum('comments', 'votes').get(); posts.map(post => { console.log(post.comments_sum_votes); }); ``` 与 `loadCount` 方法类似,这些方法也有延迟调用的方法。这些延迟方法可在已获取到的 Sutando 模型上调用: ```js const post = await Post.query().first(); await post.loadSum('comments', 'votes'); ``` 如果您将这些聚合方法与 `select` 语句结合使用,请确保在 `select` 方法之后调用聚合方法: ```js const posts = await Post.query().select(['title', 'body']) .withExists('comments') .get(); ``` ## 预加载 当将 Sutando 关系作为属性访问时,相关模型是延迟加载的。 这意味着在您第一次访问该属性之前不会实际加载关联数据。 但是,Sutando 可以在您查询父模型时主动加载关联关系。 预加载减轻了 `N + 1` 查询问题。 为了说明 `N + 1` 查询问题,请参考属于 `Author` 模型的 `Book` 模型: ```js const { Model } = require('sutando'); class Book extends Model { relationAuthor() { return this.belongsTo(Author); } } ``` 我们检索所有书籍及其作者: ```js const { Book } = require('./models'); const books = await Book.query().all(); books.map(async book => { const author = await book.getRelated('author'); console.log(author.name); }); ``` 该循环将执行一个查询以检索数据库表中的所有书籍,然后对每本书执行另一个查询以检索该书的作者。 因此,如果我们有 25 本书,上面的代码将运行 26 个查询:一个查询原本的书籍信息,另外 25 个查询来检索每本书的作者。 值得庆幸的是,我们可以使用预加载将这个操作减少到两个查询。 在构建查询时,您可以使用 1 方法指定应该预加载哪些关系: ```js const books = await Book.query().with('author').get(); books.map(book => { console.log(book.author.name); }); ``` 对于此操作,将只执行两个查询 - 一个查询检索所有书籍,一个查询检索所有书籍的所有作者: ```SQL select * from books select * from authors where id in (1, 2, 3, 4, 5, ...) ``` #### 预加载多个关联 有时,你可能需要在单一操作中预加载几个不同的关联。要达成此目的,只要向 `with` 方法传递多个关联名称构成的数组参数: ```js const books = await Book.query().with(['author', 'publisher']).get(); ``` #### 嵌套预加载 可以使用 「点」 语法预加载嵌套关联。比如在一个 Sutando 语句中预加载所有书籍作者及其联系方式: ```js const books = await Book.query().with('author.contacts').get(); ``` #### 预加载指定列 并不是总需要获取关系的每一列。在这种情况下,Sutando 允许你为关联指定想要获取的列: ```js const books = await Book.query().with('author:id,name,book_id').get(); ``` ### 约束预加载 有时您可能希望预先加载关系,但也希望为预先加载查询指定额外的查询条件。 您可以通过将关系数组传递给 `with` 方法来完成此操作,其中对象键是关系名称,对象值是向急切加载查询添加额外约束的闭包: ```js const users = await User.query().with({ posts: query => query.where('title', 'like', '%code%') }).get(); // or const users = await User.query().with('posts', query => { query.where('title', 'like', '%code%'); }).get(); ``` 在此示例中,Sutando 只会预加载帖子的“标题”列包含单词代码的帖子。 您可以调用其他查询构建器方法来进一步自定义预加载操作: ```js const users = await User.query().with({ posts: query => query.orderBy('created_at', 'desc') }).get(); ``` ### 延迟预加载 有可能你还希望在模型加载完成后在进行渴求式加载。举例来说,如果你想要根据某个条件动态决定是否加载关联数据,那么 load 方法对你来说会非常有用: ```js const { Book } = require('./models'); const books = await Book.query().all(); if (someCondition) { await books.load('author', 'publisher'); } ``` 如果你想要在渴求式加载的查询语句中进行条件约束,你可以通过数组的形式去加载,键为对应的关联关系,值为闭包函数,该闭包的参数为一个查询实例: ```js await author.load({ books: query => query.orderBy('published_date', 'asc') }); ``` ## 插入 & 更新关联模型 ### `save` 方法 Sutando 提供了向关系中添加新模型的便捷方法。例如,你可能需要向一篇文章(Post 模型)添加一条新的评论(Comment 模型),你不用手动设置 `Comment` 模型上的 `post_id` 属性,你可以直接使用关联模型中的 `save` 方法: ```js const { Post, Comment } = require('./models'); const comment = new Comment({ message: 'A new comment.' }); const post = await Post.query().find(1); await post.related('comments').save(comment); ``` 注意,我们没有将 `comments` 关联作为动态属性访问,相反,我们调用了 `related('comments')` 方法来来获得关联实例, `save` 方法会自动添加适当的 `post_id` 值到新的 `Comment` 模型中。 如果需要保存多个关联模型,你可以使用 `saveMany` 方法: ```js await post.related('comments').saveMany([ new Comment({ message: 'A new comment.' }), new Comment({ message: 'Another new comment.' }), ]); ``` `save` 和 `saveMany` 方法不会将新模型(Comment)加载到父模型(Post) 上, 如果你计划在使用 `save` 或 `saveMany` 方法后访问该关联模型(Comment),你需要使用 `refresh` 方法重新加载模型及其关联,这样你就可以访问到所有评论,包括新保存的评论了: ```js await post.related('comments').save(comment); await post.refresh(); // 所有评论,包括新保存的评论... post.comments; ``` #### 递归保存模型和关联数据 如果你想 save 你的模型及其所有关联数据,你可以使用 `push` 方法,在此示例中,将保存 `Post` 模型及其评论和评论作者: ```js post.comments.get(0).message = 'Message'; post.comments.get(0).author.name = 'Author Name'; await post.push(); ``` ### `create` 方法 除了 `save` 和 `saveMany` 方法外,你还可以使用 `create` 方法。它接受一个属性对象,同时会创建模型并插入到数据库中。 还有, `save` 方法和 `create` 方法的不同之处在于, `save` 方法接受一个完整的 Sutando 模型实例,而 `create` 则接受普通的对象: ```js const { Post } = require('./models'); const post = await Post.query().find(1); const comment = await post.related('comments').create({ message: 'A new comment.', }); ``` 你还可以使用 `createMany` 方法去创建多个关联模型: ```js await post.related('comments').createMany([ { message: 'A new comment.' }, { message: 'Another new comment.' }, ]); ``` 你还可以使用 `findOrNew`、`firstOrNew`、`firstOrCreate` 和 `updateOrCreate` 方法来 创建和更新关系模型。 ### 更新 `belongsTo` 关联 当更新 `belongsTo` 关联时,可以使用 `associate` 方法。此方法将会在子模型中设置外键。在这个例子中,`User` 模型定义了一个与 `Account` 模型的 `belongsTo` 关系。 这个 `associate` 方法将在子模型上设置外键: ```js const { Account } = require('./models'); const account = await Account.query().find(10); user.related('account').associate(account); await user.save(); ``` 当移除 `belongsTo` 关联时,可以使用 `dissociate` 方法。此方法会将关联外键设置为 null : ```js user.related('account').dissociate(); await user->save(); ``` ### 多对多关联 #### 附加 / 分离 Sutando 也提供了一些额外的辅助方法,使相关模型的使用更加方便。例如,我们假设一个用户可以拥有多个角色,并且每个角色都可以被多个用户共享。给某个用户附加一个角色是通过向中间表插入一条记录实现的,可以使用 `attach` 方法完成该操作: ```js const { User } = require('./models'); const user = await User.query().find(1); await user.related('roles').attach(roleId); ``` 在将关系附加到模型时,还可以传递一组要插入到中间表中的附加数据: ```js await user.related('roles').attach(roleId, { expires: expires, }); ``` 当然,有时也需要移除用户的角色。可以使用 `detach` 移除多对多关联记录。`detach` 方法将会移除中间表对应的记录;但是这两个模型都将会保留在数据库中: ```js // 移除用户的一个角色... await user.related('roles').detach(roleId); // 移除用户的所有角色... await user.related('roles').detach(); ``` 为了方便起见,`attach` 和 `detach` 也允许传递一个 ID 数组: ```js const user = await User.query().find(1); await user.related('roles').detach([1, 2, 3]); await user.related('roles').attach([1, 2]); ``` #### 同步关联 你也可以使用 `sync` 方法构建多对多关联。`sync` 方法接收一个 ID 数组以替换中间表的记录。中间表记录中,所有未在 ID 数组中的记录都将会被移除。所以该操作结束后,只有给出数组的 ID 会被保留在中间表中: ```js await user.related('roles').sync([1, 2, 3]); ``` 你也可以通过 ID 传递额外的附加数据到中间表: ```js await user.related('roles').sync({ 1: { expires: true }, 2: {}, 3: {} }); ``` 如果您想为每个同步的模型 ID 插入相同的中间表值,您可以使用 `syncWithPivotValues` 方法: ```js await user.related('roles').syncWithPivotValues([1, 2, 3], { active: true }); ``` 如果你不想移除现有的 ID,可以使用 `syncWithoutDetaching` 方法: ```js await user.related('roles').syncWithoutDetaching([1, 2, 3]); ``` #### 更新中间表上的记录 如果你需要在中间表中更新一条已存在的记录,可以使用 `updateExistingPivot` 。此方法接收中间表的外键与要更新的数据对象进行更新: ```js await user.related('roles').updateExistingPivot(roleId, { active: false, }); ``` --- --- url: /zh_CN/guide/browser.md --- # 浏览器支持 Sutando 不仅可以在服务端运行,现在也支持在浏览器环境中使用。浏览器版本支持模型(Model)、属性(Attribute)、关联(Relation)等核心功能,但不支持查询构建器(Query Builder)等与数据库连接相关的功能。 ## 在全栈框架中使用 如果你正在使用 Next.js、Nuxt.js 等全栈框架,你可以在项目中只定义一次模型,然后在前端和后端共享使用。这样可以确保代码的一致性,避免重复定义。 不过需要注意的是,如果你的模型中需要使用一些仅在 Node.js 环境下才能使用的特性(如文件系统操作),建议采用以下方式组织代码: 1. 创建一个基础模型类,包含前后端共用的逻辑 2. 创建一个继承自基础模型的服务端模型类,在这里添加仅供服务端使用的功能 ```javascript // models/base/user.js - 前后端共用的基础模型 export class BaseUser extends Model { // 共用的属性和方法 } // models/server/user.js - 仅服务端使用的模型 export class User extends BaseUser { // Node.js 特定的功能 } ``` ## 主要功能 ### make 函数 `make` 函数用于将 API 返回的数据转换为模型实例。这样你就可以使用模型的所有功能,包括访问器(Accessor)和修改器(Mutator)等。 ```javascript const { make } = require('sutando'); const user = make(User, data); ``` ### makeCollection 函数 `makeCollection` 函数用于将 API 返回的数组数据转换为模型集合。 ```javascript const { makeCollection } = require('sutando'); const users = makeCollection(User, data); ``` ### makePaginator 函数 `makePaginator` 函数用于将通过 API 获取的分页数据转换为 Paginator 实例。 ```javascript const { makePaginator } = require('sutando'); const pageData = makePaginator(User, data); ``` ## 使用示例 ```javascript // 从 API 获取用户数据后转换为模型实例 const response = await fetch('/api/users/1'); const data = await response.json(); const user = make(User, data); // 使用模型的访问器和其他功能 console.log(user.full_name); // 假设有一个 fullName 访问器 // 处理列表数据 const usersResponse = await fetch('/api/users'); const usersData = await usersResponse.json(); const users = makeCollection(User, usersData); // 处理分页数据 const pageResponse = await fetch('/api/users?page=1'); const pageData = await response.json(); const paginator = makePaginator(User, pageData); ``` --- --- url: /zh_CN/blog/posts/building-rest-api-with-sutando-and-express.md --- ![image](https://storage.sutando.org/og-1751395044936.jpg) *** 用 Sutando 构建 REST API 非常简单。本教程将构建一个完整的博客 API,包含验证和错误处理。 ## 项目搭建 ```bash mkdir blog-api && cd blog-api npm init -y npm install sutando mysql2 express zod ``` ## 数据库和模型 ```ts import { sutando, Model } from 'sutando'; sutando.addConnection({ client: 'mysql2', connection: { host: '127.0.0.1', user: 'root', password: '', database: 'blog' } }); class User extends Model { table = 'users'; hidden = ['password']; relationPosts() { return this.hasMany(Post, 'user_id'); } } class Post extends Model { table = 'posts'; casts = { published: 'boolean' }; relationUser() { return this.belongsTo(User, 'user_id'); } relationComments() { return this.hasMany(Comment, 'post_id'); } scopePublished(query) { return query.where('published', true); } } class Comment extends Model { table = 'comments'; relationUser() { return this.belongsTo(User, 'user_id'); } } ``` ## API 路由 ```ts // 文章列表(分页 + 预加载) app.get('/posts', async (req, res) => { const page = Number(req.query.page) || 1; const limit = Number(req.query.limit) || 20; const posts = await Post.query() .with('user', 'comments.user') .published() .orderBy('created_at', 'desc') .page(page, limit); res.json(posts); }); // 创建文章(带验证) app.post('/posts', async (req, res) => { const data = createPostSchema.parse(req.body); const post = await Post.create(data); res.status(201).json(post); }); // 更新文章 app.put('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: '文章不存在' }); post.fill(updatePostSchema.parse(req.body)); await post.save(); res.json(post); }); // 删除文章 app.delete('/posts/:id', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: '文章不存在' }); await post.delete(); res.json({ success: true }); }); // 添加评论 app.post('/posts/:id/comments', async (req, res) => { const post = await Post.find(req.params.id); if (!post) return res.status(404).json({ error: '文章不存在' }); const comment = await Comment.create({ ...req.body, post_id: post.id }); res.status(201).json(comment); }); ``` ## 展示的关键功能 * **分页**:`.page(page, limit)` * **预加载**:`.with('user', 'comments.user')` * **查询作用域**:`.published()` * **验证**:用 Zod schema * **错误处理**:async 包装器 + 错误中间件 * **隐藏字段**:模型上的 `hidden` 数组隐藏密码 完整文档请访问 [sutando.org](https://sutando.org/zh_CN/guide/getting-started.html)。 --- --- url: /zh_CN/guide/hooks.md --- # 钩子 Sutando 模型触发几个事件,允许你挂接到模型生命周期的如下节点: `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`, `restoring`, `restored`, `trashed`, `forceDeleting`, `forceDeleted`. 以 `-ing` 结尾的事件名称在模型的任何更改被持久化之前被调度,而以 `-ed` 结尾的事件在对模型的更改被持久化之后被调度。 ## 可用的钩子 | 钩子 | 介绍 | | ---- | ---- | | `creating`, `created` | 第一次保存新模型时 | | `updating`, `updated` | 当修改现有模型并调用 `save` 方法时 | | `saving`, `saved` | 当创建或更新模型时 - 即使模型的属性没有改变 | | `deleting`, `deleted` | 删除模型时,包括软删除 | | `restoring`, `restored` | 恢复模型时 | | `trashed` | 软删除后 | | `forceDeleteing`, `forceDeleted` | 物理删除后 | :::tip 在使用 Sutando 进行批量更新或删除查询时,受影响的模型不会触发 `saved`、`updated`、`deleting` 和 `deleted` 等事件。这是因为在执行批量更新或删除操作时,实际上没有检索到这些模型,所以也就不会触发这些事件。 ::: ## 声明钩子 目前有两种方式添加钩子: ```js class User extends Model {} User.creating(user => { // }); ``` ```js class User { static booted() { this.creating(user => { // }); this.created(user => { // }); } } ``` ## 钩子与事务 ```js User.deleted(async (user, { client }) => { const query = user.related('posts'); if (client) { query.transacting(client); } await query.delete(); }); const trx = await sutando.beginTransaction(); await user.delete({ client: trx }); await trx.commit(); ``` --- --- url: /zh_CN/guide/collections.md --- # 集合 Sutando 模型返回的所有结果集都是 `Collection` 对象的实例,包括通过 `get` 方法检索或通过访问关联关系获取到的结果。 Sutando 的集合对象继承了 [collect.js](https://collect.js.org/), 因此它自然也继承了数十种能优雅地处理 Sutando 模型底层数组的方法。 而且,所有的集合都可以作为迭代器,你可以像遍历简单的数组一样来遍历它们: ```js const { User } = require('./models'); const users = await User.query().where('active', 1).get(); users.map(user => { console.log(user.name); }); for (let user of users) { console.log(user.name); } ``` 不过,集合比数组更加强大,它通过更加直观的接口暴露出可链式调用的 map /reduce 等操作。例如,让我们移除所有未激活的用户并收集剩余用户的名字: ```js const names = (await User.query().all()).reject(user => { return user.active === false; }).map(user => { return user.name; }); ``` ## 可用的方法 所有 Sutando 的集合都继承了 `collect.js` 对象;因此, 他们也继承了所有集合基类提供的强大的方法。 另外, `Collection` 类提供了一套上层的方法来帮你管理你的模型集合。大多数方法返回 `Collection` 实例;然而,也会有一些方法, 例如 `modelKeys`, 它们会返回一个数组。 * [contains](#contains-key-operator-null-value-null) * [diff](#diff-items) * [except](#except-keys) * [find](#find-key) * [fresh](#fresh-with) * [intersect](#intersect-items) * [load](#load-relations) * [loadCount / loadMax / loadMin / loadSum / loadAvg](#loadcount-loadmax-loadmin-loadsum-loadavg) * [modelKeys](#modelkeys) * [makeVisible](#makevisible-attributes) * [makeHidden](#makehidden-attributes) * [only](#only-keys) * [toQuery](#toquery) * [unique](#unique-key-null-strict-false) * [toData](#todata) * [toJson](#tojson) #### contains(key, operator = null, value = null) `contains` 方法可用于判断集合中是否包含指定的模型实例。这个方法接收一个主键或者模型实例: ```js users.contains(1); const user = await User.query().find(1); users.contains(user); ``` #### diff(items) `diff` 方法返回不在给定集合中的所有模型: ```js const otherUsers = await User.query().whereIn('id', [1, 2, 3]).get() const diffUsers = users.diff(otherUsers); ``` #### except(keys) `except` 方法返回给定主键外的所有模型: ```js const exceptUsers = users.except([1, 2, 3]); ``` #### find(key) `find` 方法查找给定主键的模型。如果 `key` 是一个模型实例, `find` 将会尝试返回与主键匹配的模型。 如果 `key` 是一个关联数组, `find` 将返回所有数组主键匹配的模型: ```js const users = await User.query().all(); const user = users.find(1); ``` #### fresh(with = \[]) `fresh` 方法用于从数据库中检索集合中每个模型的新实例。此外,还将加载任何指定的关联关系: ```js const newUsers = await users.fresh(); const newUsers = await users.fresh('comments'); ``` #### intersect(items) `intersect` 方法返回给定集合与当前模型的交集: ```js const otherUsers = await User.query().whereIn('id', [1, 2, 3]).get(); const newUsers = users.intersect(otherUsers); ``` #### load(relations) `load` 方法为集合中的所有模型加载给定关联关系: ```js await users.load(['comments', 'posts']); await users.load('comments.author'); ``` #### loadCount / loadMax / loadMin / loadSum / loadAvg ```js await users.loadCount(['comments', 'posts']); await users.loadMax('posts', 'vote'); await users.loadMin('posts', 'vote'); await users.loadSum('posts', 'vote'); await users.loadAvg('posts', 'vote'); ``` #### modelKeys() `modelKeys` 方法返回集合中所有模型的主键: ```js users.modelKeys(); // [1, 2, 3, 4, 5] ``` #### makeVisible(attributes) `makeVisible` 方法使模型上的隐藏属性可见: ```js const newUsers = users.makeVisible(['address', 'phone_number']); ``` #### makeHidden(attributes) `makeHidden` 方法隐藏模型属性: ```js const newUsers = users.makeHidden(['address', 'phone_number']); ``` #### only(keys) `only` 方法返回具有给定主键的所有模型: ```js const newUsers = users.only([1, 2, 3]); ``` #### toQuery() `toQuery` 方法返回一个查询生成器实例,该实例包含集合模型主键上的 whereIn 约束: ```js const { User } = require('./models'); const users = await User.query().where('status', 'VIP').get(); await users.toQuery().update([ 'status' => 'Administrator', ]); ``` #### unique(key = null, strict = false) `unique` 方法返回集合中所有不重复的模型,若模型在集合中存在相同类型且相同主键的另一模型,该模型将被删除。 ```js const newUsers = users.unique(); ``` #### toData() ```js const users = await User.query().all(); return users.toData(); ``` #### toJson() ```js const users = await User.query().all(); return users.toJson(); ```