Scala-ActiveRecordType-safe Active Record model for Scala@teppei_tosa_en
Who am I鉄平 土佐TEPPEI TOSAiron peace place name
The first official conference in Japan.http://scalaconf.jp/en/
Typesafe members came toJapan and gave speeches.
Talked about the case example of buildingour original BRMS “BIWARD” in Scala.
Japanese engineers talked aboutScala tips or their libraries.• “Stackable-controller” by @gakuzzzzhttps://github.com/t2v/s...
Scala-ActiveRecordType-safe Active Record model for Scala• https://github.com/aselab/scala-activerecord• Latest version : ...
Features• Squeryl wrapper• type-safe (most part)• Rails ActiveRecord-like operability• Auto transaction control• validatio...
Most of the otherORM LibrariesWrap SQLval selectCountries = SQL(“Select * from Countries”)Have to define mappings from the ...
The motivation ofScala-ActiveRecord• Want to define the model mapping moreeasily.• Want not to define the find methods ineach...
Other libraries example1. Anorm2. Slick ( ScalaQuery )3. Squeryl
1.Anorm• Anorm doesn’t have the Model layer.• Have to define similar methods in eachClass.case class Person(id:Pk[Long], na...
2. Slick ( ScalaQuery )• Query Interface is good.• Definding tables syntax is redundant.• Have to define the mapping between...
3. Squeryl• The best one in these libraries.• Scala-ActiveRecord wraps this with someimprovement.1. Optimize the generated...
When queries are combinedSqueryl generates sub-query SQL.Squerylval query = from(table)(t => where(t.id.~ > 20) select(t))...
When queries are combinedSqueryl generates sub-query SQL.Scala-ActiveRecordval query = Table.where(_.id.~ > 20)query.where...
Automatethe transaction controlSquerylScala-ActiveRecord• Call “inTransaction” automatically at accessingIterable#iterator...
Use CoC approach tobuild relationship.Squerylobject Schema extends Schema{! val foo = table[Foo]! val bar = table[Bar]! va...
Use CoC approach tobuild relationship.Scala-ActiveRecordobject Table extends ActiveRecordTabels {! val foo = table[Foo]! v...
Getting Started
Define the dependencyin SBT project definitionAdd the following settings in build.sbt or project/Build.scala.libraryDependen...
Using Scala ActiveRecordPlay2.1 PluginAdd the following settings in project/Build.scalaval appDependencies = Seq("com.gith...
Database SupportH2 databaseMySQLPostgrSQLDerbyOracle
Defining Schema
Model implementationcase class Person(var name:String, var age:Int)! extends ActiveRecordobject Person! extends ActiveReco...
CRUD
Createval person = Person("person1", 25)person.save // return trueval person = Preson("person1", 25).create// return Perso...
ReadPerson.find(1)// Some(Person("person1"))Person.toList// List(person("person1”), ...)Person.findBy("name", "john")// So...
UpdatePerson.find(1).foreach { p =>! p.name = "Ichiro"! p.age = 37! p.save}Person.forceUpdate( _.id === 1)(! _.name := "ic...
DeletePerson.where(_.name === "john").foreach(_.delete)Person.find(1) match {! case Some(person) => person.delete! case _ ...
Query Interface
Find single objectval client = Client.find(10)// Some(Client) or Noneval John = Client.findBy("name", "john")// Some(Clien...
Get the search result as ListScalaClients.where(c =>! c.name === "john" and c.age.~ > 25).toListClients! .where(_.name == ...
Using iterable methodsval client = Client.head// First Client or RecordNotFoundExceptionval client = Client.lastOption// S...
OrderingClient.orderBy(_.name)Client.orderBy(_.name asc)Client.orderBy(_.name asc, _.age desc)
LimitClient.limit(10)OffsetClient.page(2, 5)ExistenceClient.exists(_.name like “john%”)// true or false
Specify selected fieldsClient.select(_.name).toList// List[String]Client.select(c => (c.name, c.age)).toList// List[(String...
Combine QueriesScalaClients.where(_.name like "john%")! .orderBy(_.age desc)! .where(_.age.~ < 25)! .page(2, 5)! .toListge...
Cache Controlval orders = Order.where(_.age.~ > 20)//execute this SQL query and cheche the queryorders.toList//dont execut...
Validations
Annotation-basedValidationcase class User(! @Required name:String,! @Length(max=20) profile:String,! @Range(min=0, max=150...
Exampleval user = user("", "Profile", 25).createuser.isValid // falseuser.hasErrors // trueuser.errors.messages // Seq("Na...
Callbacks
Available hooks•beforeValidation•beforeCreate•afterCreate•beforeUpdate•afterUpdate•beforeSave•afterSave•beforeDelete•after...
Examplecase class User(login:String) extends ActiveRecord {! @Transient! var password:String = _! var hashedPassword:Strin...
Relationship
One-to-Manycase class User(name:String) extends ActiveRecord {! val groupId:Option[Long] = None! lazy val group = belongsT...
One-to-Manyval user1 = User("user1").createval group1 = Group("group1").creategroup1.users << user1group1.users.toList// L...
Generated SQL sampleScalagroup1.users.where(_.name like "user%")! .orderBy(_.id desc)! .limit(5)! .toListgenerated SQLSele...
Many-to-Many (HABTM)case class User(name:String) extends ActiveRecord {! lazy val groups = hasAndBelongsToMany[Group]}case...
val user1 = User("user1").createval group1 = Group("group1").createval group2 = Group("group2").createuser1.groups := List...
Many-to-Many(hasManyThrough)groupsidnamemembershipsiduser_idgroup_idisAdminusersidname
Many-to-Many(hasManyThrough)case class Membership(! userId:Long, projectid:Long, isAdmin:Boolean = false) extends ActiveRe...
Conditions Optionscase class Group(name:String) extends ActiveRecord {! lazy val adminUsers =! ! hasMany[User](conditions ...
ForeignKey optioncase class Comment(name:String) extends ActiveRecord {! val authorId:Long! lazy val author= belongsTo[Use...
Join TablesScalaClient.joins[Order](! (client, order) => client.id === order.clientId).where(! (client, order) => client.a...
Eager loading associationsThe solution for N+1 problem.ScalaOrder.includes(_.client).limit(10).map {! order => order.clien...
Logging and Debugging
See the generated SQLsUse the toSql methodprintln(User.where(_.name like "john%").orderBy(_.age desc).toSql)Set logging le...
In SBT consolebuild.sbt or project/Build.scalainitialCommands in console := """import com.github.aselab.activerecord._impo...
Testing
Setting for testbuild.sbt or project/Build.scalalibraryDependencies ++= Seq("com.github.aselab" %% "scala-activerecord" % ...
Test Exampleimport com.github.aselab.activerecord._object SomeModelSpecs extends ActiveRecordSpecification {override val c...
Performance
ActiveRecord Overhead• How Sql statements are generated.• The time to create active-record objectfrom the results of query.
ORM Race•Anorm•Slick•Squerl•Scala-ActiveRecord
Race Condition• Create the “User” table which has only 3 columns• Insert 1000 records into the “User” table• Select all fr...
The RacersAnorm SquerylSlick Scala-ActiveRecordSQL("SELECT * FROM USER").as(User.simple *)Query(Users).listfrom(AppDB.user...
The Race Results39.8ms116.8ms177.2ms258.8msSquerylAnormScala-ActiveRecordSlick
Future
Validation at compiling(with “Macro”)• The findBy method and conditions ofassociation will be type-safe.• Validate whether ...
Support SerializationForm Model XMLJSONMessagePackValidationViewBindViewHelper
Support Web framework• CRUD controller• Form Helper for Play and Scalatra• Code generator as SBT plugin
Secret
DEMO withYATTER(Yet Another twiTTER )
YATTER’s tablesFollowsiduseridfollows_usersiduser_idfollow_idUsersidnameTweetsiduserIdtextManyToManyOneToManyOneToOne(But ...
https://github.com/ironpeace/yatter
#scalajpMt.FUJI
Tokyo Station
Japanese Castle
Sushi
Okonomiyaki
@teppei_tosa_enhttps://github.com/aselab/scala-activerecordhttps://github.com/ironpeace/yatterThank you
Scala active record
Upcoming SlideShare
Loading in...5
×

Scala active record

4,444

Published on

Published in: Technology, Education
2 Comments
8 Likes
Statistics
Notes
No Downloads
Views
Total Views
4,444
On Slideshare
0
From Embeds
0
Number of Embeds
6
Actions
Shares
0
Downloads
34
Comments
2
Likes
8
Embeds 0
No embeds

No notes for slide

Scala active record

  1. 1. Scala-ActiveRecordType-safe Active Record model for Scala@teppei_tosa_en
  2. 2. Who am I鉄平 土佐TEPPEI TOSAiron peace place name
  3. 3. The first official conference in Japan.http://scalaconf.jp/en/
  4. 4. Typesafe members came toJapan and gave speeches.
  5. 5. Talked about the case example of buildingour original BRMS “BIWARD” in Scala.
  6. 6. Japanese engineers talked aboutScala tips or their libraries.• “Stackable-controller” by @gakuzzzzhttps://github.com/t2v/stackable-controller• “How we write and use Scala libraries notto cry” by @tototoshihttp://tototoshi.github.io/slides/how-we-write-and-use-scala-libraries-scalaconfjp2013/#1For example,http://scalaconf.jp/en/
  7. 7. Scala-ActiveRecordType-safe Active Record model for Scala• https://github.com/aselab/scala-activerecord• Latest version : 0.2.2• Licence : MIT
  8. 8. Features• Squeryl wrapper• type-safe (most part)• Rails ActiveRecord-like operability• Auto transaction control• validations• Associations• Testing support
  9. 9. Most of the otherORM LibrariesWrap SQLval selectCountries = SQL(“Select * from Countries”)Have to define mappings from the results of SQL tothe models.val countries = selectCountries().map(row =>row[String](“code”) -> row[String](“name”)).toList
  10. 10. The motivation ofScala-ActiveRecord• Want to define the model mapping moreeasily.• Want not to define the find methods ineach model classes.• Want not to write SQLs.
  11. 11. Other libraries example1. Anorm2. Slick ( ScalaQuery )3. Squeryl
  12. 12. 1.Anorm• Anorm doesn’t have the Model layer.• Have to define similar methods in eachClass.case class Person(id:Pk[Long], name:String)object Person {! def create(person:Person):Unit = {! ! DB.withConnection { implicit connection =>! ! ! SQL("insert int person(name) values ({name}")! ! ! ! .on(name -> person.name)! ! ! ! .executeUpdate()! ! }! }! ...}
  13. 13. 2. Slick ( ScalaQuery )• Query Interface is good.• Definding tables syntax is redundant.• Have to define the mapping between tableand model in each table.case class Member(id:Int, name:String, email:Option[String])object Members extends Table[Member]("MEMBERS") {! def id = column[Int]("ID", O.PrimaryKey, O.AutoInc)! def name = column[String]("Name")! def email = column[Option[String]]("EMAIL")! def * = id.? ~ name ~ email <> (Member, Member.unapply _)}
  14. 14. 3. Squeryl• The best one in these libraries.• Scala-ActiveRecord wraps this with someimprovement.1. Optimize the generated SQLs2. Automate the transaction control3. Use CoC approach to build relationship
  15. 15. When queries are combinedSqueryl generates sub-query SQL.Squerylval query = from(table)(t => where(t.id.~ > 20) select(t))from(query)(t => where(t.name like "%test%) select(t))Scalaselect * from! (Select * from table where table.id > 20) q1where q1.name like "test"SQLIt negatively affect performance.
  16. 16. When queries are combinedSqueryl generates sub-query SQL.Scala-ActiveRecordval query = Table.where(_.id.~ > 20)query.where(_.name like "%test%").toListScalaselect * from tablewhere table.id > 20 and table.name like "test"SQLIt can generate more simple SQL statement.
  17. 17. Automatethe transaction controlSquerylScala-ActiveRecord• Call “inTransaction” automatically at accessingIterable#iterator.• When the save or delete method is called,“inTransaction” is executed by default.• Off course, you can call “inTransaction” expressly.inTransaction {books.insert(new Author(1, "Michel","Folco"))! !val a = from(authors)(a=> where(a.lastName === "Folco") select(a))}
  18. 18. Use CoC approach tobuild relationship.Squerylobject Schema extends Schema{! val foo = table[Foo]! val bar = table[Bar]! val fooToBar = oneToManyRelation(Foo, Bar).via(! ! (f,b) => f.barId === b.id! )}class Foo(var barId:Long) extends SomeEntity {! lazy val bar:ManyToOne[Bar] = schema.fooToBar.right(this)}class Bar(var bar:String) extends SomeEntity {! lazy val foos:OneToMany[Foo] = schema.fooToBar.left(this)}
  19. 19. Use CoC approach tobuild relationship.Scala-ActiveRecordobject Table extends ActiveRecordTabels {! val foo = table[Foo]! val bar = table[Bar]}class Foo(var barId:Long) extends ActiveRecord {! lazy val bar = belongsTo[Bar]}class Bar(var bar:String) extends ActiveRecord {! lazy val foos = hasMany[Foo]}
  20. 20. Getting Started
  21. 21. Define the dependencyin SBT project definitionAdd the following settings in build.sbt or project/Build.scala.libraryDependencies ++= Seq("com.github.aselab" %% "scala-activerecord" % "0.2.2","org.slf4j" % "slf4j-nop" % "1.7.2", // optional"com.h2database" % "h2" % "1.3.170" // optional)resolvers += Resolver.sonatypeRepo("releases")
  22. 22. Using Scala ActiveRecordPlay2.1 PluginAdd the following settings in project/Build.scalaval appDependencies = Seq("com.github.aselab" %% "scala-activerecord" % "0.2.2","com.github.aselab" %% "scala-activerecord-play2" % "0.2.2",jdbc,"com.h2database" % "h2" % "1.3.170")val main = play.Project(appName, appVersion, appDependencies).settings(resolvers ++= Seq(Resolver.sonatypeRepo("releases")))Add the following settings in conf/play.plugins9999:com.github.aselab.activerecord.ActiveRecordPlugin
  23. 23. Database SupportH2 databaseMySQLPostgrSQLDerbyOracle
  24. 24. Defining Schema
  25. 25. Model implementationcase class Person(var name:String, var age:Int)! extends ActiveRecordobject Person! extends ActiveRecordCompanion[Person]Schema definitionobject Tables extends ActiveRecordTable {! val people = table[Person]}
  26. 26. CRUD
  27. 27. Createval person = Person("person1", 25)person.save // return trueval person = Preson("person1", 25).create// return Person("person1", 25)
  28. 28. ReadPerson.find(1)// Some(Person("person1"))Person.toList// List(person("person1”), ...)Person.findBy("name", "john")// Some(Person("John"))Person.where(_.name === "john").headOption// Some(Person("john"))
  29. 29. UpdatePerson.find(1).foreach { p =>! p.name = "Ichiro"! p.age = 37! p.save}Person.forceUpdate( _.id === 1)(! _.name := "ichiro", _.age := 37)
  30. 30. DeletePerson.where(_.name === "john").foreach(_.delete)Person.find(1) match {! case Some(person) => person.delete! case _ =>}Person.delete(1)
  31. 31. Query Interface
  32. 32. Find single objectval client = Client.find(10)// Some(Client) or Noneval John = Client.findBy("name", "john")// Some(Client("john")) or Noneval john = Client.findBy(("name", "john"), ("age",25))// Some(Client("john",25)) or None
  33. 33. Get the search result as ListScalaClients.where(c =>! c.name === "john" and c.age.~ > 25).toListClients! .where(_.name == "john")! .where(_.age.~ > 25)! .toListgenerated SQLselect clients.name, clients.age, clients.idfrom clientswhere clients.name = "john" and clients.age > 25
  34. 34. Using iterable methodsval client = Client.head// First Client or RecordNotFoundExceptionval client = Client.lastOption// Some(Last Client) or Noneval (adults, children) = Client.partition(_.age >= 20)// Parts of clients
  35. 35. OrderingClient.orderBy(_.name)Client.orderBy(_.name asc)Client.orderBy(_.name asc, _.age desc)
  36. 36. LimitClient.limit(10)OffsetClient.page(2, 5)ExistenceClient.exists(_.name like “john%”)// true or false
  37. 37. Specify selected fieldsClient.select(_.name).toList// List[String]Client.select(c => (c.name, c.age)).toList// List[(String, Int)]
  38. 38. Combine QueriesScalaClients.where(_.name like "john%")! .orderBy(_.age desc)! .where(_.age.~ < 25)! .page(2, 5)! .toListgenerated SQLselect clients.name, clients.age, clients.idfrom clientswhere ((clients.name like "john%") and (clients.age < 25))order by clients.age desclimit 5 offset 2
  39. 39. Cache Controlval orders = Order.where(_.age.~ > 20)//execute this SQL query and cheche the queryorders.toList//dont execute the SQL queryorders.toListWhen the query is implicitly converted, the query is cached.
  40. 40. Validations
  41. 41. Annotation-basedValidationcase class User(! @Required name:String,! @Length(max=20) profile:String,! @Range(min=0, max=150) age:Int) extends ActiveRecordObject User extends ActiveRecordCompanion[User]
  42. 42. Exampleval user = user("", "Profile", 25).createuser.isValid // falseuser.hasErrors // trueuser.errors.messages // Seq("Name is required")user.hasError("name") // trueUser("", "profile", 15).saveEither match {case Right(user) => println(user.name)case Left(errors) => println(errors.message)}// "Name is required"
  43. 43. Callbacks
  44. 44. Available hooks•beforeValidation•beforeCreate•afterCreate•beforeUpdate•afterUpdate•beforeSave•afterSave•beforeDelete•afterDelete
  45. 45. Examplecase class User(login:String) extends ActiveRecord {! @Transient! var password:String = _! var hashedPassword:String = _! override def beforeSave() {! ! hashedPassword = SomeLibrary.encrypt(password)! }}val user = User("john")user.password = "raw_password"user.save// stored encrypted password
  46. 46. Relationship
  47. 47. One-to-Manycase class User(name:String) extends ActiveRecord {! val groupId:Option[Long] = None! lazy val group = belongsTo[Group]}case class Group(name:String) extends ActiveRecord {! lazy val users = hasMany[User]}groupsidnameusersidgroup_idname
  48. 48. One-to-Manyval user1 = User("user1").createval group1 = Group("group1").creategroup1.users << user1group1.users.toList// List(User("user1"))user1.group.getOrElse(Group("group2"))// Group("group1")
  49. 49. Generated SQL sampleScalagroup1.users.where(_.name like "user%")! .orderBy(_.id desc)! .limit(5)! .toListgenerated SQLSelect users.name, users.idFrom usersWhere ((users.group_id = 1) And (users.name like "user%"))Order by users.id Desclimit 5 offset 0
  50. 50. Many-to-Many (HABTM)case class User(name:String) extends ActiveRecord {! lazy val groups = hasAndBelongsToMany[Group]}case class Group(name:String) extends ActiveRecord {! lazy val users = hasAndBelongsToMany[user]}groupsidnamegroups_usersleft_idright_idusersidname
  51. 51. val user1 = User("user1").createval group1 = Group("group1").createval group2 = Group("group2").createuser1.groups := List(group1, group2)user1.groups.toList// List(Group("group1"), Group("group2"))group1.users.toList// List(User("user1"))Many-to-Many (HABTM)
  52. 52. Many-to-Many(hasManyThrough)groupsidnamemembershipsiduser_idgroup_idisAdminusersidname
  53. 53. Many-to-Many(hasManyThrough)case class Membership(! userId:Long, projectid:Long, isAdmin:Boolean = false) extends ActiveRecord {! lazy val user = belongsTo[User]! lazy val group = belongsTo[Group]}case class User(name:String) extends ActiveRecord {! lazy val memberships = hasMany[Membership]! lazy val groups = hasManyThrough[Group, Membership](memberships)}case class Group(name:String) extends ActiveRecord {! lazy val memberships = hasmany[Membership]! lazy val users = hasManyThrough[User, Membership](memberships)}
  54. 54. Conditions Optionscase class Group(name:String) extends ActiveRecord {! lazy val adminUsers =! ! hasMany[User](conditions = Map("isAdmin" -> true))}group.adminUsers << user// user.isAdmin == true
  55. 55. ForeignKey optioncase class Comment(name:String) extends ActiveRecord {! val authorId:Long! lazy val author= belongsTo[User](foreignKey = "authorId")}
  56. 56. Join TablesScalaClient.joins[Order](! (client, order) => client.id === order.clientId).where(! (client, order) => client.age.~ < 20 and order.price.~ > 1000).select(! (client, order) => (client.name, client.age, order.price)).toListgenerated SQLSelect clients.name, clients.age, order.priceFrom clients inner join orders on (clients.id = orders.client_id)Where ((clients.age < 20) and (groups.price > 1000))
  57. 57. Eager loading associationsThe solution for N+1 problem.ScalaOrder.includes(_.client).limit(10).map {! order => order.client.name}.mkString("n")generated SQLSelect orders.price, orders.idFrom orders limit 10 offset 0Select clients.name, clients.age, clients.idFrom clients inner join orders on (clients.id = orders.client_id)Where (orders.id in (1,2,3,4,5,6,7,8,9,10))
  58. 58. Logging and Debugging
  59. 59. See the generated SQLsUse the toSql methodprintln(User.where(_.name like "john%").orderBy(_.age desc).toSql)Set logging level with “debug” in logback.xml<configuration><appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"><encoder><pattern>%highlight(%-5level) %cyan(%logger{15}) - %msg %n</pattern></encoder></appender><root level="DEBUG"><appender-ref ref="STDOUT" /></root></configuration>
  60. 60. In SBT consolebuild.sbt or project/Build.scalainitialCommands in console := """import com.github.aselab.activerecord._import com.github.aselab.activerecord.dsl._import models._SomeTables.initialize(Map("schema" -> "models.SomeTables"))"""In the console> consolescala> User.forceInsertAll{ (1 to 10000).map{i => User("name" + i)} }scala> User.where(_.name === "name10").toList
  61. 61. Testing
  62. 62. Setting for testbuild.sbt or project/Build.scalalibraryDependencies ++= Seq("com.github.aselab" %% "scala-activerecord" % "0.2.2","com.github.aselab" %% "scala-activerecord-specs" % "0.2.2" % "test","org.specs2" %% "specs2" % "1.12.3" % "test")resolvers += Resolver.sonatypeRepo("releases")application.conftest {schema = "models.Tables"driver = "org.h2.Driver"jdbcurl = "jdbc:h2:mem:test"}
  63. 63. Test Exampleimport com.github.aselab.activerecord._object SomeModelSpecs extends ActiveRecordSpecification {override val config= Map("schema" -> "com.example.models.Tables")override def beforeAll = {super.beforeAllSomeModel("test1").create}override def afterAll = {super.afterAll}"sample" should {// Some specifications code}}
  64. 64. Performance
  65. 65. ActiveRecord Overhead• How Sql statements are generated.• The time to create active-record objectfrom the results of query.
  66. 66. ORM Race•Anorm•Slick•Squerl•Scala-ActiveRecord
  67. 67. Race Condition• Create the “User” table which has only 3 columns• Insert 1000 records into the “User” table• Select all from the table with same statements• Time their trip to the end of creation objects• Exclude the time to creating DB connection• Use Play framework 2.1.1• Use H2-database• Run in Global.onStart• Run 5 times• Compare with the average times
  68. 68. The RacersAnorm SquerylSlick Scala-ActiveRecordSQL("SELECT * FROM USER").as(User.simple *)Query(Users).listfrom(AppDB.user)(s => select(s)).toListUser.all.toList
  69. 69. The Race Results39.8ms116.8ms177.2ms258.8msSquerylAnormScala-ActiveRecordSlick
  70. 70. Future
  71. 71. Validation at compiling(with “Macro”)• The findBy method and conditions ofassociation will be type-safe.• Validate whether foreign-key is specifiedwith existing key or not.
  72. 72. Support SerializationForm Model XMLJSONMessagePackValidationViewBindViewHelper
  73. 73. Support Web framework• CRUD controller• Form Helper for Play and Scalatra• Code generator as SBT plugin
  74. 74. Secret
  75. 75. DEMO withYATTER(Yet Another twiTTER )
  76. 76. YATTER’s tablesFollowsiduseridfollows_usersiduser_idfollow_idUsersidnameTweetsiduserIdtextManyToManyOneToManyOneToOne(But not supported yet)
  77. 77. https://github.com/ironpeace/yatter
  78. 78. #scalajpMt.FUJI
  79. 79. Tokyo Station
  80. 80. Japanese Castle
  81. 81. Sushi
  82. 82. Okonomiyaki
  83. 83. @teppei_tosa_enhttps://github.com/aselab/scala-activerecordhttps://github.com/ironpeace/yatterThank you
  1. A particular slide catching your eye?

    Clipping is a handy way to collect important slides you want to go back to later.

×