Как работать с SQLite в Android Studio
Для работы с SQLite в Android Studio создайте локальную базу в приложении через SQLiteOpenHelper. SQLite хранит данные без отдельного сервера. В этой инструкции вы создадите две связанные таблицы, выполните CRUD и JOIN, покажете Cursor в RecyclerView и Spinner, а затем проверите записи в Android Studio.
- Что такое SQLite в Android
- Подготовка проекта Android
- Структура таблиц и контракта
- Создание базы через SQLiteOpenHelper
- CRUD: вставка, выборка, обновление и удаление
- Внешний ключ и вставка Employee
- Вывод Cursor в RecyclerView
- Вывод данных SQLite в Spinner
- JOIN-запрос через rawQuery()
- Миграция схемы без потери данных
- Проверка базы в Android Studio
- SQLiteOpenHelper или Room
- Как организовать работу с SQLite в Android-приложении
Что такое SQLite в Android
SQLite – встраиваемая реляционная база данных. Приложение хранит ее в файле и обращается к данным через SQL или Android API.
SQLite использует 5 классов хранения: NULL, INTEGER, REAL, TEXT и BLOB. INTEGER подходит для целых чисел и временных меток, TEXT – для строк, REAL – для дробных чисел, а BLOB – для двоичных данных.
Android предлагает два основных пути работы с SQLite. SQLiteOpenHelper управляет созданием файла и версиями схемы. Room добавляет проверку SQL-запросов и работу с объектами.

Подготовка проекта Android
Подключите AndroidX-библиотеки и включите привязку представлений. Support Library и конфигурация compile не подходят для актуального проекта.
android {
buildFeatures {
viewBinding true
}
}
dependencies {
implementation("androidx.recyclerview:recyclerview:$recyclerviewVersion")
implementation("androidx.cardview:cardview:$cardviewVersion")
implementation("com.google.android.material:material:$materialVersion")
}Задайте версии библиотек в version catalog или переменных Gradle проекта. Номера AndroidX берите из каталога версий AndroidX. Для Material используйте версию, согласованную с зависимостями проекта.
Запросы к базе нельзя выполнять в главном потоке. Выносите чтение и запись в Executor или корутины, чтобы дисковая операция не блокировала интерфейс.
private final ExecutorService databaseExecutor = Executors.newSingleThreadExecutor();
private void loadEmployers() {
databaseExecutor.execute(() -> {
try (Cursor cursor = helper.getReadableDatabase().query(
SampleDBContract.Employer.TABLE_NAME,
new String[]{SampleDBContract.Employer._ID,
SampleDBContract.Employer.COLUMN_NAME},
null, null, null, null, null)) {
ArrayList<String> names = new ArrayList<>();
while (cursor.moveToNext()) {
names.add(cursor.getString(cursor.getColumnIndexOrThrow(
SampleDBContract.Employer.COLUMN_NAME)));
}
runOnUiThread(() -> showEmployerNames(names));
}
});
}Здесь showEmployerNames() обозначает ваш метод обновления интерфейса. Доступ к базе выполняется в фоновом потоке, а результат передается в UI.
Структура таблиц и контракта
Пример использует 2 таблицы. Employer хранит работодателей, а Employee – сотрудников и ссылку на работодателя через employer_id.

Описывайте имена таблиц и столбцов в отдельном контракте. Такой класс уменьшает число опечаток в SQL и помогает использовать одинаковые имена в запросах и адаптерах.
public final class SampleDBContract {
private SampleDBContract() {}
public static class Employer implements BaseColumns {
public static final String TABLE_NAME = "employer";
public static final String COLUMN_NAME = "name";
public static final String COLUMN_DESCRIPTION = "description";
public static final String COLUMN_FOUNDED_DATE = "founded_date";
public static final String CREATE_TABLE =
"CREATE TABLE IF NOT EXISTS " + TABLE_NAME + " (" +
_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
COLUMN_NAME + " TEXT NOT NULL, " +
COLUMN_DESCRIPTION + " TEXT, " +
COLUMN_FOUNDED_DATE + " INTEGER)";
}
public static class Employee implements BaseColumns {
public static final String TABLE_NAME = "employee";
public static final String COLUMN_FIRSTNAME = "firstname";
public static final String COLUMN_LASTNAME = "lastname";
public static final String COLUMN_EMPLOYER_ID = "employer_id";
public static final String CREATE_TABLE =
"CREATE TABLE IF NOT EXISTS " + TABLE_NAME + " (" +
_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
COLUMN_FIRSTNAME + " TEXT NOT NULL, " +
COLUMN_LASTNAME + " TEXT NOT NULL, " +
COLUMN_EMPLOYER_ID + " INTEGER NOT NULL, " +
"FOREIGN KEY(" + COLUMN_EMPLOYER_ID + ") REFERENCES " +
Employer.TABLE_NAME + "(" + Employer._ID + "))";
}
}Добавьте в файл контракта импорт android.provider.BaseColumns. В этом примере дата хранится как INTEGER в миллисекундах. Форматируйте дату только при выводе.
Создание базы через SQLiteOpenHelper
SQLiteOpenHelper вызывает onCreate() для нового файла и onUpgrade() при увеличении номера схемы. Создайте один helper на время жизни приложения, а не новый объект для каждого запроса.
public class SampleDBSQLiteHelper extends SQLiteOpenHelper {
private static final int DATABASE_VERSION = 2;
public static final String DATABASE_NAME = "sample_database";
private static SampleDBSQLiteHelper instance;
public static synchronized SampleDBSQLiteHelper getInstance(Context context) {
if (instance == null) {
instance = new SampleDBSQLiteHelper(context);
}
return instance;
}
private SampleDBSQLiteHelper(Context context) {
super(context.getApplicationContext(), DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onConfigure(SQLiteDatabase db) {
super.onConfigure(db);
db.setForeignKeyConstraintsEnabled(true);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(SampleDBContract.Employer.CREATE_TABLE);
db.execSQL(SampleDBContract.Employee.CREATE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion < 2) {
db.execSQL(SampleDBContract.Employee.CREATE_TABLE);
}
}
}Для helper добавьте импорты android.content.Context, android.database.sqlite.SQLiteDatabase и android.database.sqlite.SQLiteOpenHelper. При переходе с версии 1 на 2 oldVersion равен 1, поэтому новая таблица добавляется без удаления Employer.


private SampleDBSQLiteHelper helper;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
helper = SampleDBSQLiteHelper.getInstance(this);
}Общий helper не закрывайте при уничтожении отдельной Activity. Другие экраны могут продолжать работу с той же базой. Каждый экран закрывает собственные Cursor и завершает собственный Executor.
CRUD: вставка, выборка, обновление и удаление
Операции CRUD работают через ContentValues и методы SQLiteDatabase. Один объект ContentValues описывает строку, которую вы добавляете или изменяете.
Вставка строки
Передайте значения в ContentValues и вызовите insert(). Метод вернет идентификатор новой строки или значение меньше нуля при ошибке.
private long saveEmployer(String name, String description, long foundedDate) {
ContentValues values = new ContentValues();
values.put(SampleDBContract.Employer.COLUMN_NAME, name);
values.put(SampleDBContract.Employer.COLUMN_DESCRIPTION, description);
values.put(SampleDBContract.Employer.COLUMN_FOUNDED_DATE, foundedDate);
return helper.getWritableDatabase().insert(
SampleDBContract.Employer.TABLE_NAME, null, values);
}Выборка с условиями
Плейсхолдеры ? и selectionArgs отделяют пользовательские значения от SQL-команды и защищают запрос от SQL-инъекций.
private Cursor readEmployers(String name, String description, long date) {
String selection = SampleDBContract.Employer.COLUMN_NAME + " LIKE ? AND " +
SampleDBContract.Employer.COLUMN_FOUNDED_DATE + " > ? AND " +
SampleDBContract.Employer.COLUMN_DESCRIPTION + " LIKE ?";
String[] args = {"%" + name + "%", String.valueOf(date),
"%" + description + "%"};
return helper.getReadableDatabase().query(
SampleDBContract.Employer.TABLE_NAME,
new String[]{SampleDBContract.Employer._ID,
SampleDBContract.Employer.COLUMN_NAME,
SampleDBContract.Employer.COLUMN_DESCRIPTION,
SampleDBContract.Employer.COLUMN_FOUNDED_DATE},
selection, args, null, null, null);
}Запрос ищет подстроки одновременно в имени и описании. Дата должна быть больше переданного значения, поэтому условие использует знак >.
try {
date = dateFormat.parse(input).getTime();
} catch (ParseException exception) {
Log.e(TAG, "Cannot parse founded date", exception);
return;
}Если разбор даты завершился ошибкой, метод записывает исключение в Log и прекращает операцию. Нулевая дата не подставляется молча.
Обновление и удаление
Для изменения и удаления передайте whereClause и whereArgs. Так операция затронет только выбранную строку.
private int updateEmployer(long id, String name, String description) {
ContentValues values = new ContentValues();
values.put(SampleDBContract.Employer.COLUMN_NAME, name);
values.put(SampleDBContract.Employer.COLUMN_DESCRIPTION, description);
return helper.getWritableDatabase().update(
SampleDBContract.Employer.TABLE_NAME,
values,
SampleDBContract.Employer._ID + " = ?",
new String[]{String.valueOf(id)});
}
private int deleteEmployer(long id) {
return helper.getWritableDatabase().delete(
SampleDBContract.Employer.TABLE_NAME,
SampleDBContract.Employer._ID + " = ?",
new String[]{String.valueOf(id)});
}Число, которое вернул update() или delete(), показывает количество измененных строк. Значение 0 означает, что условие не нашло запись.
Внешний ключ и вставка Employee
Внешний ключ связывает Employee с существующей строкой Employer. SQLite по умолчанию не включает проверку FOREIGN KEY, поэтому helper вызывает setForeignKeyConstraintsEnabled(true) в onConfigure().
@Override
public void onConfigure(SQLiteDatabase db) {
super.onConfigure(db);
db.setForeignKeyConstraintsEnabled(true);
}Получите идентификатор выбранного работодателя из Cursor Spinner и передайте его в полный метод сохранения сотрудника.
private long saveEmployee(String firstname, String lastname, long employerId) {
ContentValues values = new ContentValues();
values.put(SampleDBContract.Employee.COLUMN_FIRSTNAME, firstname);
values.put(SampleDBContract.Employee.COLUMN_LASTNAME, lastname);
values.put(SampleDBContract.Employee.COLUMN_EMPLOYER_ID, employerId);
return helper.getWritableDatabase().insertOrThrow(
SampleDBContract.Employee.TABLE_NAME, null, values);
}Передайте в saveEmployee() идентификатор выбранного работодателя и введенные имя и фамилию. Если идентификатор не существует, включенное ограничение внешнего ключа отклонит вставку.

Вывод Cursor в RecyclerView
Cursor хранит строки результата и позволяет перейти к нужной позиции через moveToPosition(). Адаптер закрывает старый Cursor перед заменой новым.
public class EmployerCursorAdapter
extends RecyclerView.Adapter<EmployerCursorAdapter.ViewHolder> {
private Cursor cursor;
public EmployerCursorAdapter(Cursor cursor) {
this.cursor = cursor;
}
public void replaceCursor(Cursor nextCursor) {
if (cursor == nextCursor) {
return;
}
if (cursor != null) {
cursor.close();
}
cursor = nextCursor;
notifyDataSetChanged();
}
@Override
public int getItemCount() {
return cursor == null ? 0 : cursor.getCount();
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
cursor.moveToPosition(position);
holder.bindCursor(cursor);
}
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(
R.layout.employer_list_item, parent, false);
return new ViewHolder(view);
}
static class ViewHolder extends RecyclerView.ViewHolder {
ViewHolder(View itemView) {
super(itemView);
}
void bindCursor(Cursor cursor) {
TextView name = itemView.findViewById(R.id.nameLabel);
TextView description = itemView.findViewById(R.id.descLabel);
name.setText(cursor.getString(cursor.getColumnIndexOrThrow(
SampleDBContract.Employer.COLUMN_NAME)));
description.setText(cursor.getString(cursor.getColumnIndexOrThrow(
SampleDBContract.Employer.COLUMN_DESCRIPTION)));
}
}
}Создайте файл res/layout/employer_list_item.xml с двумя текстовыми полями.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/nameLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/descLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>Используйте импорты Cursor, LayoutInflater, View, ViewGroup и TextView из Android SDK. RecyclerView импортируйте из androidx.recyclerview.widget.RecyclerView. При уничтожении экрана вызовите adapter.replaceCursor(null).

Вывод данных SQLite в Spinner
SimpleCursorAdapter ожидает Cursor с обязательным столбцом _id. Получите его вместе с названием работодателя и закройте Cursor, когда Spinner перестанет им пользоваться.
Создайте адаптер в главном потоке с пустым Cursor. Чтение базы выполните через общий helper в фоне.
private SimpleCursorAdapter spinnerAdapter;
private boolean screenDestroyed;
private void configureSpinner() {
spinnerAdapter = new SimpleCursorAdapter(
this,
android.R.layout.simple_spinner_item,
null,
new String[]{SampleDBContract.Employer.COLUMN_NAME},
new int[]{android.R.id.text1},
0);
spinnerAdapter.setDropDownViewResource(
android.R.layout.simple_spinner_dropdown_item);
binding.employerSpinner.setAdapter(spinnerAdapter);
}
private void loadSpinner() {
databaseExecutor.execute(() -> {
Cursor cursor = helper.getReadableDatabase().query(
SampleDBContract.Employer.TABLE_NAME,
new String[]{SampleDBContract.Employer._ID,
SampleDBContract.Employer.COLUMN_NAME},
null, null, null, null,
SampleDBContract.Employer.COLUMN_NAME + " ASC");
cursor.getCount();
runOnUiThread(() -> {
if (screenDestroyed) {
cursor.close();
} else {
spinnerAdapter.changeCursor(cursor);
}
});
});
}
@Override
protected void onDestroy() {
screenDestroyed = true;
spinnerAdapter.changeCursor(null);
databaseExecutor.shutdown();
super.onDestroy();
}Вызовите configureSpinner() после создания формы, затем вызовите loadSpinner(). Метод changeCursor() закрывает прежний Cursor при замене результата.

JOIN-запрос через rawQuery()
JOIN объединяет сотрудников с названиями их работодателей. Метод rawQuery() принимает SQL-строку и аргументы для плейсхолдеров. Перечислите столбцы явно, чтобы два столбца _id не столкнулись в результате.
Добавьте константу внутрь SampleDBContract, вне вложенных классов Employer и Employee.
public static final String SELECT_EMPLOYEE_WITH_EMPLOYER =
"SELECT ee._id AS employee_id, " +
"ee.firstname, ee.lastname, " +
"er._id AS employer_id, er.name AS employer_name " +
"FROM employee ee " +
"INNER JOIN employer er " +
"ON ee.employer_id = er._id " +
"WHERE ee.firstname LIKE ? AND ee.lastname LIKE ?";Разметка строки и адаптер результата
Создайте файл res/layout/employee_list_item.xml с полями employeeLabel и employerLabel. Адаптер получает Cursor, перемещает его к позиции строки и закрывает старый результат при замене.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/employeeLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/employerLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>public class JoinCursorAdapter
extends RecyclerView.Adapter<JoinCursorAdapter.ViewHolder> {
private Cursor cursor;
public JoinCursorAdapter(Cursor cursor) {
this.cursor = cursor;
}
public void replaceCursor(Cursor nextCursor) {
if (cursor == nextCursor) {
return;
}
if (cursor != null) {
cursor.close();
}
cursor = nextCursor;
notifyDataSetChanged();
}
@Override
public int getItemCount() {
return cursor == null ? 0 : cursor.getCount();
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
cursor.moveToPosition(position);
holder.bind(cursor);
}
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(
R.layout.employee_list_item, parent, false);
return new ViewHolder(view);
}
static class ViewHolder extends RecyclerView.ViewHolder {
ViewHolder(View itemView) {
super(itemView);
}
void bind(Cursor cursor) {
TextView employee = itemView.findViewById(R.id.employeeLabel);
TextView employer = itemView.findViewById(R.id.employerLabel);
String firstname = cursor.getString(cursor.getColumnIndexOrThrow("firstname"));
String lastname = cursor.getString(cursor.getColumnIndexOrThrow("lastname"));
String employerName = cursor.getString(
cursor.getColumnIndexOrThrow("employer_name"));
employee.setText(firstname + " " + lastname);
employer.setText(employerName);
}
}
}Получение Cursor в фоне
Создайте отдельный экран EmployeeListActivity для вывода результата JOIN. Экран создаст RecyclerView программно, подключит JoinCursorAdapter и запустит чтение в Executor.
Сохраните класс в файле EmployeeListActivity.java рядом с helper и контрактом. Добавьте строку <activity android:name=".EmployeeListActivity" /> внутри элемента application в AndroidManifest.xml.
import android.app.Activity;
import android.database.Cursor;
import android.os.Bundle;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class EmployeeListActivity extends Activity {
private final ExecutorService databaseExecutor =
Executors.newSingleThreadExecutor();
private SampleDBSQLiteHelper helper;
private JoinCursorAdapter adapter;
private boolean screenDestroyed;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
helper = SampleDBSQLiteHelper.getInstance(this);
RecyclerView recyclerView = new RecyclerView(this);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
adapter = new JoinCursorAdapter(null);
recyclerView.setAdapter(adapter);
setContentView(recyclerView);
loadEmployees("", "");
}
private Cursor readEmployees(String firstname, String lastname) {
String[] args = {"%" + firstname + "%", "%" + lastname + "%"};
return helper.getReadableDatabase().rawQuery(
SampleDBContract.SELECT_EMPLOYEE_WITH_EMPLOYER, args);
}
private void loadEmployees(String firstname, String lastname) {
databaseExecutor.execute(() -> {
Cursor result = readEmployees(firstname, lastname);
try {
result.getCount();
} catch (RuntimeException exception) {
result.close();
throw exception;
}
runOnUiThread(() -> {
if (screenDestroyed) {
result.close();
} else {
adapter.replaceCursor(result);
}
});
});
}
@Override
protected void onDestroy() {
screenDestroyed = true;
adapter.replaceCursor(null);
databaseExecutor.shutdown();
super.onDestroy();
}
}Добавьте в начало Java-файла строку package с пакетом вашего приложения. Для JoinCursorAdapter используйте те же импорты представлений, что и для EmployerCursorAdapter.
Откройте экран из существующей Activity вызовом startActivity(new android.content.Intent(this, EmployeeListActivity.class));, например в обработчике кнопки. Если таблица Employee содержит записи, список покажет имена сотрудников и работодателей.
Пустые строки в loadEmployees() превращаются в шаблоны %%. Запрос выбирает всех сотрудников с существующим работодателем. После передачи результата адаптер владеет Cursor и закрывает его при замене.
При уничтожении экрана onDestroy() закрывает текущий Cursor и завершает Executor. Если фоновый запрос завершится позже, обработчик результата закроет его Cursor без обновления интерфейса.

Миграция схемы без потери данных
Версия базы растет вместе со схемой. При переходе с версии 1 на 2 SQLite передаст oldVersion == 1, поэтому проверяйте диапазон через сравнение с целевой версией.
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion < 2) {
db.execSQL(SampleDBContract.Employee.CREATE_TABLE);
}
}Этот переход предполагает, что версия 1 уже содержит Employer с теми же столбцами. Переименование столбца требует отдельной миграции.
Не удаляйте таблицы в onUpgrade(), если задача не требует уничтожить данные. Для сложного изменения создайте новую таблицу, перенесите строки и удалите старую после успешного переноса.
Проверка базы в Android Studio
Для просмотра записей SQLite в Android Studio используйте Database Inspector. Он показывает базы запущенного приложения, таблицы и результаты SQL-запросов.
- Запустите приложение на устройстве или эмуляторе с API 26 или выше.
- В Android Studio откройте «View» → «Tool Windows» → «App Inspection».
- Выберите вкладку «Database Inspector».
- В меню выберите процесс запущенного приложения. Базы появятся в панели «Databases».
- Раскройте узел
sample_databaseи дважды щелкните имя таблицы. В окне появятся ее строки. - Нажмите «Open New Query tab», введите
SELECT * FROM employerи выполните запрос.
После вставки появится строка с новым _id. Если база не отображается, вызовите getWritableDatabase() в приложении и подключите Database Inspector к запущенному процессу снова.
SQLiteOpenHelper или Room
SQLiteOpenHelper подходит для небольшой базы, учебного проекта и ситуации, где вам нужен прямой контроль над SQL. Room удобнее в приложении с большим числом запросов: он связывает таблицы с Entity, проверяет SQL при сборке и организует доступ через Dao.
Google рекомендует использовать Room вместо прямой работы с SQLite API. Подключите библиотеку Room с версиями, согласованными с проектом.
dependencies {
implementation("androidx.room:room-runtime:$roomVersion")
annotationProcessor("androidx.room:room-compiler:$roomVersion")
}@Entity
public class EmployerEntity {
@PrimaryKey(autoGenerate = true)
public long id;
public String name;
}
@Dao
public interface EmployerDao {
@Insert
long insert(EmployerEntity employer);
@Query("SELECT * FROM EmployerEntity ORDER BY name")
List<EmployerEntity> findAll();
}
@Database(entities = {EmployerEntity.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
public abstract EmployerDao employerDao();
}Это отдельная схема для знакомства с Entity, Dao и Database. Она не заменяет существующую базу примера без настройки таблиц и миграций.
Выбирайте Room, когда схема растет и запросы должны проходить проверку при сборке. Оставляйте SQLiteOpenHelper, когда прямой SQL важнее слоя абстракции и база остается небольшой.
Как организовать работу с SQLite в Android-приложении
SQLiteOpenHelper помогает создать локальную базу, а ContentValues и Cursor закрывают базовые операции CRUD. Для связей включайте внешние ключи, для миграций повышайте версию без DROP, а для JOIN перечисляйте столбцы и используйте rawQuery().
После первого рабочего CRUD-сценария добавьте тест перехода с версии 1 на 2. Сохраните работодателя до обновления и убедитесь, что запись осталась после миграции.
Совет от нашей редакции: держите один helper, закрывайте каждый замененный Cursor и выполняйте операции с базой за пределами главного потока.
Популярное за неделю
- Как создать сайт самому в 2026: 4 способа + пошаговая инструкция 34 мин чтения
- Лучшие конструкторы для создания сайтов — плюсы, минусы и сравнения 22 мин чтения
- 7 бесплатных СУБД для управления базами данных 5 мин чтения
- Команды MySQL в консоли: от входа до бэкапа 15 мин чтения
- Модели баз данных: иерархическая, сетевая и реляционная 9 мин чтения
- Работа с датой и временем в MySQL 5 мин чтения
Комментарии