[Программирование, Kotlin] Руководство по работе с фреймворком Kotlin Exposed (перевод)

Автор Сообщение
news_bot ®

Стаж: 6 лет 4 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
30-Апр-2021 01:30
Перевод подготовлен в рамках набора учащихся на курс "Kotlin Backend Developer".
Также приглашаем всех желающих на демо-урок «Объектно-ориентированное программирование в Kotlin». Цели занятия:
- узнать про элементы объектной модели Kotlin;
- создавать различные классы и объекты;
- выполнять наследование и делегирование;
- пользоваться геттерами и сеттерами.

1. ВведениеВ этой статье мы рассмотрим, как направлять запросы к реляционной базе данных с помощью Exposed.Exposed — это открытая библиотека, разработанная компанией JetBrains. Она распространяется по лицензии Apache и позволяет использовать идиоматический API Kotlin для реализации некоторых реляционных баз данных от различных поставщиков.Exposed можно использовать как в качестве высокоуровневого языка DSL в SQL, так и в качестве облегченной технологии ORM (объектно-реляционного отображения). В этом руководстве мы рассмотрим оба варианта использования.2. УстановкаФреймворк Exposed еще не опубликован в Maven Central, потому нам придется использовать отдельный репозиторий:
<repositories>
    <repository>
        <id>exposed</id>
        <name>exposed</name>
        <url>https://dl.bintray.com/kotlin/exposed</url>
    </repository>
</repositories>
Теперь можно подключить библиотеку:
<dependency>
    <groupId>org.jetbrains.exposed</groupId>
    <artifactId>exposed</artifactId>
    <version>0.10.4</version>
</dependency>
Ниже мы приведем несколько примеров использования базы данных H2 в памяти:
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.197</version>
</dependency>
Последняя версия Exposed доступна на Bintray, а последняя версия H2 — на Maven Central.3. Соединение с базой данныхДля того чтобы установить соединение с базой данных, будем использовать класс Database:
Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver")
Мы можем также указать пользователя (user) и пароль (password) в качестве именованных параметров:
Database.connect(
  "jdbc:h2:mem:test", driver = "org.h2.Driver",
  user = "myself", password = "secret")
Обратите внимание: вызов метода connect не устанавливает соединения с БД сразу. Соединение будет установлено позже с использованием сохраненных параметров.3.1. Дополнительные параметрыДля того чтобы задать другие параметры соединения, мы будем использовать перегруженный метод connect, который позволит нам полностью контролировать подключение:
Database.connect({ DriverManager.getConnection("jdbc:h2:mem:test;MODE=MySQL") })
В этом случае нам придется использовать замыкание. Exposed вызывает замыкание при необходимости установления нового соединения с базой данных. 3.2. Подключение через DataSourceЕсли мы будем подключаться к базе данных с использованием DataSource (именно этот подход обычно используется в корпоративных приложениях, например, чтобы иметь преимущества пула подключений), нам потребуется соответствующий перегруженный метод connect:
Database.connect(datasource)
4. Открытие транзакцииВсе операции с базами данных в Exposed выполняются только при наличии активных транзакций.Метод transaction принимает замыкание и вызывает его в активной транзакции.
transaction {
    //Do cool stuff
}
Метод transaction возвращает значение, которое вернуло замыкание. После выполнения блока Exposed автоматически закрывает транзакцию.4.1. Подтверждение и откат транзакцийПосле успешного выполнения блока с помощью метода transaction Exposed подтверждает транзакцию. Если же замыкание генерирует исключение, фреймворк откатывает транзакцию.Подтверждение и откат транзакции можно выполнить в ручном режиме. В Kotlin замыкание, которое мы передали в методе transaction, фактически является экземпляром класса Transaction.Таким образом, мы можем использовать метод commit или rollback:
transaction {
    //Do some stuff
    commit()
    //Do other stuff
}
4.2. Запись инструкций в журналПри изучении фреймворка или отладке кода не лишним будет отследить инструкции и запросы SQL, которые Exposed отправляет в базу данных.Для этого добавим регистратор к активной транзакции:
transaction {
    addLogger(StdOutSqlLogger)
    //Do stuff
}
5. Определение таблицКак правило, Exposed не используется для работы с неформатированными строками и именами SQL. Мы определяем таблицы, столбцы, ключи, связи и т. д. с помощью высокоуровневого DSL.ADДля представления каждой таблицы будем использовать экземпляр класса Table:
object StarWarsFilms : Table()
Exposed автоматически присваивает таблице имя на основании имени класса, но мы можем задать имя самостоятельно:
object StarWarsFilms : Table("STAR_WARS_FILMS")
5.1. СтолбцыБез столбцов таблица работать не будет. Определим столбцы как свойства класса Table:
object StarWarsFilms : Table() {
    val id = integer("id").autoIncrement().primaryKey()
    val sequelId = integer("sequel_id").uniqueIndex()
    val name = varchar("name", 50)
    val director = varchar("director", 50)
}
Для краткости мы не указали типы — Kotlin определит их автоматически. В любом случае каждая колонка относится к классу Column<T>, у нее есть имя, тип и, возможно, параметры типа.5.2. Первичные ключиВ предыдущем разделе мы рассмотрели пример, где индексы и первичные ключи определяются с помощью текучего API.Однако, если в таблице в качестве первичного ключа используется целое число, мы можем использовать встроенные в Exposed классы IntIdTable и LongIdTable для определения ключей:
object StarWarsFilms : IntIdTable() {
    val sequelId = integer("sequel_id").uniqueIndex()
    val name = varchar("name", 50)
    val director = varchar("director", 50)
}
Есть также класс UUIDTable, а еще мы можем определить собственные варианты, выделив подклассы в классе IdTable.5.3. Внешние ключиДобавить внешний ключ очень просто. Мы можем использовать статическую типизацию, поскольку мы всегда обращаемся к свойствам, которые были известны в момент создания таблицы.Предположим, что нам нужно узнать имена актеров, которые снимались в каждом фильме:
object Players : Table() {
    val sequelId = integer("sequel_id")
      .uniqueIndex()
      .references(StarWarsFilms.sequelId)
    val name = varchar("name", 50)
}
Для того чтобы не прописывать тип столбца (в этом примере — integer), который можно получить из связанного столбца, воспользуемся методом reference:
val sequelId = reference("sequel_id", StarWarsFilms.sequelId).uniqueIndex()
Если мы ссылаемся на первичный ключ, имя столбца можно не указывать:
val filmId = reference("film_id", StarWarsFilms)
5.4. Создание таблицТаблицы можно создавать программно, как указано выше:
transaction {
    SchemaUtils.create(StarWarsFilms, Players)
    //Do stuff
}
Таблицу можно создать, только если она не существует. Однако миграция баз данных не поддерживается.6. ЗапросыОпределив классы таблиц, как показано выше, мы можем направлять запросы к базе данных с использованием функций расширения, встроенных в фреймворк.6.1. Выбор всех объектовДля того чтобы извлечь данные из базы, будем использовать объекты Query, созданные на основе классов таблиц. Самый простой запрос будет возвращать все строки заданной таблицы:
val query = StarWarsFilms.selectAll()
Запрос является итерируемым и поддерживает циклы forEach:
query.forEach {
    assertTrue { it[StarWarsFilms.sequelId] >= 7 }
}
Параметр замыкания, которому в нашем примере присвоено имя it, — это экземпляр класса ResultRow. В результате столбцам присваиваются ключи.6.2. Выбор подмножества столбцовМы можем также выбрать подмножество столбцов таблицы, то есть выполнить проекцию, с помощью метода slice:
StarWarsFilms.slice(StarWarsFilms.name, StarWarsFilms.director).selectAll()
  .forEach {
      assertTrue { it[StarWarsFilms.name].startsWith("The") }
  }
Этот метод позволяет применить функцию к столбцу:
StarWarsFilms.slice(StarWarsFilms.name.countDistinct())
Часто при использовании агрегатных функций, например count и avg, направляя запрос, мы используем группировку по оператору. О группах мы поговорим в разделе 6.5.6.3. Фильтрация с помощью выражения whereВ Exposed для выражений where, которые используются для фильтрации запросов и других типов инструкций, используется специальный DSL. В основе этого мини-языка лежат свойства столбцов, с которыми мы познакомились ранее, и серия логических операторов.Вот пример выражения where:
{ (StarWarsFilms.director like "J.J.%") and (StarWarsFilms.sequelId eq 7) }
Оно относится к комплексному типу и является подклассом SqlExpressionBuilder, который определяет такие операторы, как like, eq, and. Как видим, это последовательность операций сравнения, соединенных операторами and и or.Мы можем передать такое выражение в метод select, который вернет очередной запрос:
val select = StarWarsFilms.select { ... }
assertEquals(1, select.count())
Поскольку тип может быть выведен из контекста, нам не обязательно указывать тип complex для выражения where, когда оно передается непосредственно в метод select, как в рассмотренном примере.В Kotlin выражения с where являются объектами, поэтому специальных параметров для запросов нет. Мы используем переменные:
val sequelNo = 7
StarWarsFilms.select { StarWarsFilms.sequelId >= sequelNo }
6.4. Дополнительная фильтрацияСуществует несколько методов для уточнения запросов, которые возвращает метод select и его эквиваленты.Например, можно удалить повторяющиеся строки:
query.withDistinct(true).forEach { ... }
Или вернуть только подмножество строк, например в случае нумерации страниц с результатами при работе над пользовательским интерфейсом:
query.limit(20, offset = 40).forEach { ... }
Эти методы будут возвращать новые объекты Query, поэтому мы можем выстроить их вызовы в цепочку.6.5. Методы orderBy и groupByМетод Query.orderBy принимает список столбцов, связанных со значением SortOrder, которое задает тип сортировки элементов — по возрастанию или по убыванию:
query.orderBy(StarWarsFilms.name to SortOrder.ASC)
Группировка по одному или нескольким столбцам будет особенно полезна при использовании агрегатной функции (см. раздел 6.2). Для этого воспользуемся методом groupBy:
StarWarsFilms
  .slice(StarWarsFilms.sequelId.count(), StarWarsFilms.director)
  .selectAll()
  .groupBy(StarWarsFilms.director)
6.6. СоединенияСоединения — это, пожалуй, одна из самых важных характеристик реляционной базы данных. Вот самый простой пример. Если мы знаем внешний ключ и у нас нет условий соединения, мы можем воспользоваться встроенными операторами соединения:
(StarWarsFilms innerJoin Players).selectAll()
В этом примере мы использовали оператор innerJoin, но по этому же принципу можно использовать операторы LEFT JOIN, RIGHT JOIN и CROSS JOIN.Затем можно добавить условия соединения, используя выражение where; например, если у нас нет внешнего ключа, придется указать явную операцию соединения:
(StarWarsFilms innerJoin Players)
  .select { StarWarsFilms.sequelId eq Players.sequelId }
В общем случае операция соединения полностью записывается так:
val complexJoin = Join(
  StarWarsFilms, Players,
  onColumn = StarWarsFilms.sequelId, otherColumn = Players.sequelId,
  joinType = JoinType.INNER,
  additionalConstraint = { StarWarsFilms.sequelId eq 8 })
complexJoin.selectAll()
6.7. ПсевдонимыБлагодаря тому что имена столбцов связаны со свойствами, при типичном соединении нам не придется присваивать им псевдонимы, даже если у некоторых столбцов имена совпадают:
(StarWarsFilms innerJoin Players)
  .selectAll()
  .forEach {
      assertEquals(it[StarWarsFilms.sequelId], it[Players.sequelId])
  }
На самом деле в этом примере StarWarsFilms.sequelId и Players.sequelId — это разные столбцы.Однако, если в запросе одна и та же таблица появляется несколько раз, можно присвоить ей псевдоним. Для этого воспользуемся функцией alias:
val sequel = StarWarsFilms.alias("sequel")
Псевдоним можно указывать в качестве названия таблицы:
Join(StarWarsFilms, sequel,
  additionalConstraint = {
      sequel[StarWarsFilms.sequelId] eq StarWarsFilms.sequelId + 1 
  }).selectAll().forEach {
      assertEquals(
        it[sequel[StarWarsFilms.sequelId]], it[StarWarsFilms.sequelId] + 1)
  }
В этом примере sequel — это таблица, которая участвует в операции соединения. Чтобы обратиться к столбцу, в качестве ключа будем использовать столбец таблицы, представленной псевдонимом:
sequel[StarWarsFilms.sequelId]
7. ИнструкцииМы рассмотрели, как выполнять запросы к базе данных. Теперь разберемся с DML-инструкциями.7.1. Вставка данныхДля того чтобы вставить данные, вызовем функцию, эквивалентную функции insert. Все они принимают замыкание:
StarWarsFilms.insert {
    it[name] = "The Last Jedi"
    it[sequelId] = 8
    it[director] = "Rian Johnson"
}
В этом замыкании используются два объекта:
  • this (само замыкание) — это экземпляр класса StarWarsFilms; именно этот объект позволяет нам обращаться к столбцам, которые являются свойствами, по неуточненному имени;
  • it (параметр замыкания) — это InsertStatement; это структура, аналогичная коллекции ключ/значение, в которой есть слоты для вставки столбцов.
7.2. Извлечение автоинкрементного значения столбцовЕсли у нас есть инструкция insert с автоматически генерируемыми столбцами (обычно это автоматическое увеличение индекса или последовательности), мы можем извлечь сгенерированные значения.В типичном сценарии есть только одно сгенерированное значение. Воспользуемся методом insertAndGetId:
val id = StarWarsFilms.insertAndGetId {
    it[name] = "The Last Jedi"
    it[sequelId] = 8
    it[director] = "Rian Johnson"
}
assertEquals(1, id.value)
Если у нас несколько сгенерированных значений, их можно считывать по имени:
val insert = StarWarsFilms.insert {
    it[name] = "The Force Awakens"
    it[sequelId] = 7
    it[director] = "J.J. Abrams"
}
assertEquals(2, insert[StarWarsFilms.id]?.value)
7.3. Обновление данныхМы научились выполнять запросы и вставлять данные. Теперь перейдем к обновлению данных, которые содержатся в базе. Самый простой способ обновления похож на комбинацию методов select и insert:
StarWarsFilms.update ({ StarWarsFilms.sequelId eq 8 }) {
    it[name] = "Episode VIII – The Last Jedi"
}
В этом примере выражение where используется вместе с замыканием UpdateStatement. UpdateStatement и InsertStatement — это потомки класса UpdateBuilder, поэтому в них используется один и тот же API и одна и та же логика. Родительский класс позволяет задать значение столбца с помощью квадратных скобок.Если для обновления столбца нам нужно вычислять новое значение из старого, воспользуемся SqlExpressionBuilder:
StarWarsFilms.update ({ StarWarsFilms.sequelId eq 8 }) {
    with(SqlExpressionBuilder) {
        it.update(StarWarsFilms.sequelId, StarWarsFilms.sequelId + 1)
    }
}
Этот объект позволяет использовать инфиксный оператор (например, plus, minus и т. д.) для создания инструкции обновления.7.4. Удаление данныхИ наконец, мы можем удалить данные с помощью метода deleteWhere:
StarWarsFilms.deleteWhere ({ StarWarsFilms.sequelId eq 8 })
8. API DAO, облегченная технология ORMМы использовали Exposed для того, чтобы связать операции над объектами Kotlin с запросами и инструкциями SQL напрямую. Такие методы, как insert, update, select и т. д., немедленно отправляют строку SQL в базу данных.Однако в Exposed есть высокоуровневый API DAO, который представляет собой простую технологию ORM. Давайте рассмотрим его подробнее.8.1. СущностиВ рассмотренных выше примерах мы использовали классы для представления таблиц базы данных и описания операций над ними с использованием статических методов.Теперь мы можем определить сущности на основе этих классов таблиц, где каждый экземпляр сущности представляет собой строку базы данных:
class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {
    companion object : EntityClass<Int, StarWarsFilm>(StarWarsFilms)
    var sequelId by StarWarsFilms.sequelId
    var name     by StarWarsFilms.name
    var director by StarWarsFilms.director
}
Давайте подробно проанализируем это определение.Из первой строки видно, что сущность — это класс, расширяющий Entity. У нее есть ID специфического типа, в нашем случае — Int.
class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {
Затем определяем объект-компаньон. Объект-компаньон — это класс сущности, то есть статические метаданные, которые определяют сущность и операции, которые мы можем выполнять над ней.Объявляя объект-компаньон, мы соединяем сущность с именем StarWarsFilm (в единственном числе), которая представляет собой одну строку, с таблицей с именем StarWarsFilms (во множественном числе), которая представляет собой коллекцию всех строк.
companion object : EntityClass<Int, StarWarsFilm>(StarWarsFilms)
Наконец, мы задаем свойства с помощью делегатов свойств для соответствующих столбцов таблицы.
var sequelId by StarWarsFilms.sequelId
var name     by StarWarsFilms.name
var director by StarWarsFilms.director
Обратите внимание, что раньше мы объявляли столбцы с помощью val, поскольку они являются неизменяемыми метаданными. Теперь же мы объявляем свойства сущности с помощью var, поскольку они являются изменяемыми слотами в строке базы данных.8.2. Вставка данныхЧтобы вставить строку в таблицу, нам нужно просто создать экземпляр класса сущности с использованием статического фабричного метода new в транзакции:
val theLastJedi = StarWarsFilm.new {
    name = "The Last Jedi"
    sequelId = 8
    director = "Rian Johnson"
}
Обратите внимание: с базой данных выполняются отложенные операции, которые запускаются только после выполнения warm cache. В Hibernate, например, теплый кэш привязан к сессии (session).Операция выполняется автоматически. Например, когда мы в первый раз считываем сгенерированный идентификатор, Exposed выполняет инструкцию insert:
assertEquals(1, theLastJedi.id.value) //Reading the ID causes a flush
Сравните это поведение с методом insert, который мы рассматривали в разделе 7.1, — в этом примере метод сразу же выполняет инструкцию в базе данных. Здесь же мы работаем на более высоком уровне абстракции.8.3. Обновление и удаление объектовДля обновления строк нужно просто задать их свойства:
theLastJedi.name = "Episode VIII – The Last Jedi"
Для удаления объекта вызовем метод delete этого объекта:
theLastJedi.delete()
Так же, как при использовании метода new, обновление и операции выполняются в отложенном режиме.Обновить и удалить можно только ранее загруженный объект. В этом фреймворке нет API для обновления и удаления нескольких объектов, и нам придется использовать API более низкого уровня (см. раздел 7). Тем не менее в одной транзакции можно одновременно использовать и тот и другой API.8.4. ЗапросыAPI DAO позволяет выполнять три типа запросов.Для загрузки всех объектов, для которых не заданы условия, будем использовать статический метод all:
val movies = StarWarsFilm.all()
Для загрузки одного объекта по ID воспользуемся методом findById:
val theLastJedi = StarWarsFilm.findById(1)
Если объекта с таким ID нет, findById вернет значение null.В самом общем случае мы можем использовать метод find с выражением where:
val movies = StarWarsFilm.find { StarWarsFilms.sequelId eq 8 }
8.5. Связь «многие к одному»В ORM соотнесение соединений со ссылками так же важно, как соединения в реляционных базах данных. Посмотрим, какие возможности предлагает Exposed.Предположим, что нам нужно узнать пользовательский рейтинг каждого фильма. Сначала определим две дополнительные таблицы:
object Users: IntIdTable() {
    val name = varchar("name", 50)
}
object UserRatings: IntIdTable() {
    val value = long("value")
    val film = reference("film", StarWarsFilms)
    val user = reference("user", Users)
}
Затем создадим соответствующие сущности. Опустим сущность User (это очевидно) и перейдем сразу к классу UserRating:
class UserRating(id: EntityID<Int>): IntEntity(id) {
    companion object : IntEntityClass<UserRating>(UserRatings)
    var value by UserRatings.value
    var film  by StarWarsFilm referencedOn UserRatings.film
    var user  by User         referencedOn UserRatings.user
}
Обратите внимание: инфиксный метод referencedOn вызывает свойства, которые представляют собой связи. Модель следующая: объявляем переменную var через сущность (by) со ссылкой на соответствующий столбец (referencedOn).Свойства, объявленные таким образом, ведут себя как обычные свойства, но их значением является связанный объект:
val someUser = User.new {
    name = "Some User"
}
val rating = UserRating.new {
    value = 9
    user = someUser
    film = theLastJedi
}
assertEquals(theLastJedi, rating.film)
8.6. Дополнительные связиРассмотренные выше связи являются обязательными — мы должны всегда указывать значение.Чтобы установить дополнительные связи, нам нужно сначала разрешить столбцу таблицы принимать значение null:
val user = reference("user", Users).nullable()
Вместо метода referencedOn будем использовать optionalReferencedOn:
var user by User optionalReferencedOn UserRatings.user
Таким образом, свойство user сможет принимать значение null.8.7. Связь «один ко многим»Мы можем создать обратную связь с помощью внешнего ключа. Будем использовать его для моделирования рейтинга фильма в базе данных; у фильма будет несколько рейтингов.Для отображения рейтингов нужно добавить свойство к той стороне связи, где используется «один» объект. В нашем случае это сущность film:
class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {
    //Other properties elided
    val ratings  by UserRating referrersOn UserRatings.film
}
Модель похожа на модель связи «многие к одному», но сейчас мы используем метод referrersOn. Свойство, определенное таким образом, является итерируемым, поэтому мы можем выполнить обход с помощью цикла forEach:
theLastJedi.ratings.forEach { ... }
В отличие от обычных свойств, здесь мы определили ratings через valСвойство неизменяемо, поэтому мы можем только считывать его.У значения свойства тоже нет API для изменения. Поэтому для добавления нового рейтинга нам нужно создать его, указав ссылку на фильм:
UserRating.new {
    value = 8
    user = someUser
    film = theLastJedi
}
Теперь новый рейтинг появится в перечне рейтингов для этого фильма.8.8. Связь «многие ко многим»Иногда требуется установить связь «многие ко многим». Предположим, что нам нужно связать класс StarWarsFilm с таблицей Actors:
object Actors: IntIdTable() {
    val firstname = varchar("firstname", 50)
    val lastname = varchar("lastname", 50)
}
class Actor(id: EntityID<Int>): IntEntity(id) {
    companion object : IntEntityClass<Actor>(Actors)
    var firstname by Actors.firstname
    var lastname by Actors.lastname
}
После того как мы определим таблицу и сущность, нужно будет создать другую таблицу для установления связи:
object StarWarsFilmActors : Table() {
    val starWarsFilm = reference("starWarsFilm", StarWarsFilms).primaryKey(0)
    val actor = reference("actor", Actors).primaryKey(1)
}
В этой таблице два столбца, каждый из которых является внешним ключом, а вместе они представляют собой сложный первичный ключ.Теперь можно подключить таблицу связей к сущности StarWarsFilm:
class StarWarsFilm(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<StarWarsFilm>(StarWarsFilms)
    //Other properties elided
    var actors by Actor via StarWarsFilmActors
}
На момент написания статьи создать сущность с генерируемым идентификатором и использовать ее в связи «многие ко многим» в одной транзакции нельзя.Нам приходится выполнять несколько транзакций:
//First, create the film
val film = transaction {
   StarWarsFilm.new {
    name = "The Last Jedi"
    sequelId = 8
    director = "Rian Johnson"r
  }
}
//Then, create the actor
val actor = transaction {
  Actor.new {
    firstname = "Daisy"
    lastname = "Ridley"
  }
}
//Finally, link the two together
transaction {
  film.actors = SizedCollection(listOf(actor))
}
В этом примере мы для удобства выполнили три транзакции, хотя двух было бы достаточно.9. ЗаключениеВ этой статье мы подробно рассмотрели фреймворк Kotlin Exposed. Дополнительную информацию и примеры можно найти в вики-учебнике по Exposed.Варианты реализации рассмотренных примеров и фрагменты кода можно найти на GitHub.
===========
Источник:
habr.com
===========

===========
Автор оригинала: Alessio Stalla
===========
Похожие новости: Теги для поиска: #_programmirovanie (Программирование), #_kotlin, #_kotlin, #_oop (ооп), #_blog_kompanii_otus (
Блог компании OTUS
)
, #_programmirovanie (
Программирование
)
, #_kotlin
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 18-Июн 17:16
Часовой пояс: UTC + 5