זהו פרק הסיום. הוא מתחיל מן המודל וה-seed העובדים של
פרק 2 — קשר עם דירוג, מחליף את
relationship navigation ב-INNER JOIN typed ומוסיף שלושה כפתורים: Student,
Movie ו-Watching. לא נשנה שוב את ה-schema או את Database.java.
השינוי כולו בשלושה קבצים
| קובץ | שינוי |
|---|---|
strings.xml |
שלושה טקסטים לכפתורים |
activity_main.xml |
שורת כפתורים אחת |
MainActivity.java |
JOIN, listeners ושלושה דיאלוגים |
Gradle, קטלוג הספריות, Manifest, themes, קובצי הבדיקות, Database.java וכל
שלוש ה-entities נשארים ללא שינוי.
1. מוסיפים שלושה כפתורים
1.1 טקסטים
ב-app > res > values > strings.xml, הוסיפו:
<resources>
<string name="app_name">sqlrequery</string>
+ <string name="add_student">Add Student</string>
+ <string name="add_movie">Add Movie</string>
+ <string name="add_watching">Add Watching</string>
</resources>
1.2 שורת הכפתורים
ב-activity_main.xml, הוסיפו את ה-LinearLayout הבא לפני ה-TextView הקיים:
tools:context=".MainActivity">
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginTop="16dp"
+ android:orientation="horizontal"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <Button
+ android:id="@+id/button_add_student"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/add_student" />
+
+ <Button
+ android:id="@+id/button_add_movie"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/add_movie" />
+
+ <Button
+ android:id="@+id/button_add_watching"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/add_watching" />
+ </LinearLayout>
+
<TextView
android:id="@+id/results"
layout_width="0dp" יחד עם layout_weight="1" מחלק את רוחב השורה שווה בשווה
בין שלושת הכפתורים. ה-ids ייצרו את binding.buttonAddStudent,
binding.buttonAddMovie ו-binding.buttonAddWatching.
2. מכינים את MainActivity
פתחו app > kotlin+java > com.example.sqlrequery > MainActivity.
2.1 imports
הוסיפו את imports של רכיבי הדיאלוג:
import android.os.Bundle;
+import android.text.InputType;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
import androidx.activity.EdgeToEdge;
+import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
הרחיבו את imports של המודל והוסיפו את שתי רשימות Java:
import com.example.sqlrequery.databinding.ActivityMainBinding;
+import com.example.sqlrequery.model.Movie;
+import com.example.sqlrequery.model.MovieEntity;
+import com.example.sqlrequery.model.Student;
+import com.example.sqlrequery.model.StudentEntity;
import com.example.sqlrequery.model.Watching;
import com.example.sqlrequery.model.WatchingEntity;
+import java.util.ArrayList;
+import java.util.List;
לבסוף הוסיפו את הטיפוסים הדרושים ל-JOIN ולטיפול בכפילות:
import io.requery.Persistable;
+import io.requery.PersistenceException;
import io.requery.query.Result;
+import io.requery.query.Tuple;
import io.requery.sql.EntityDataStore;
הוסיפו תיאור קצר למחלקה:
+/** Displays the typed join and owns the three insert dialogs. */
public class MainActivity extends AppCompatActivity {
2.2 listeners
בתוך onCreate, מיד לאחר פתיחת המסד וה-seed, חברו את הכפתורים. השאירו את
showWatchings() אחרונה:
data = Database.open(this);
Database.seedIfEmpty(data);
+
+binding.buttonAddStudent.setOnClickListener(v -> showAddStudent());
+binding.buttonAddMovie.setOnClickListener(v -> showAddMovie());
+binding.buttonAddWatching.setOnClickListener(v -> showAddWatching());
showWatchings();
3. מחליפים navigation ב-INNER JOIN typed
בפרק 2 התחלנו מכל Watching ועברנו דרך האובייקטים הקשורים. כעת נבקש מ-SQLite
לבצע את החיבור ולשלוח בחזרה רק את שלושת הערכים שאנחנו מציגים.
SQL
SELECT s.name, m.title, w.rating
FROM Student s
INNER JOIN Watching w
ON w.studentId = s.id
INNER JOIN Movie m
ON m.id = w.movieId
ORDER BY s.id, m.id;
Requery typed
data.select(
StudentEntity.NAME,
MovieEntity.TITLE,
WatchingEntity.RATING)
.from(Student.class)
.join(Watching.class)
.on(WatchingEntity.STUDENT_ID
.eq(StudentEntity.ID))
.join(Movie.class)
.on(MovieEntity.ID
.eq(WatchingEntity.MOVIE_ID))
.orderBy(StudentEntity.ID, MovieEntity.ID)
למה דווקא INNER JOIN?
INNER JOIN מחזיר רק צירופים שקיימת בהם התאמה בכל הצדדים. Student חדש שעדיין לא צפה
בסרט לא יופיע, וגם Movie שאין לו Watching לא יופיע. זה מתאים למסך הזה, שמטרתו להציג
צפיות מדורגות ולא קטלוג מלא של תלמידים או סרטים.
אם מטרת המסך הייתה “כל התלמידים, גם מי שלא צפה”, היינו מתחילים מ־Student ומשתמשים
ב־LEFT JOIN. בחירת סוג החיבור אינה פרט תחבירי בלבד; היא קובעת אילו עובדות חסרות עדיין
נחשבות שורה בתוצאה.
אין כאן שמות טבלאות או עמודות כמחרוזות. כל expression נוצר מ-Java Entity, ולכן טעות בשם או בטיפוס נעצרת בזמן קומפילציה.
החליפו את showWatchings:
-/** Displays every rated relationship through its Student and Movie objects. */
+/** Runs the typed equivalent of two INNER JOINs and displays every result tuple. */
private void showWatchings() {
StringBuilder text = new StringBuilder("STUDENT | MOVIE | RATING\n\n");
- try (Result<Watching> watchings = data.select(Watching.class)
- .orderBy(WatchingEntity.STUDENT_ID, WatchingEntity.MOVIE_ID)
+ try (Result<Tuple> rows = data
+ .select(StudentEntity.NAME, MovieEntity.TITLE, WatchingEntity.RATING)
+ .from(Student.class)
+ .join(Watching.class).on(WatchingEntity.STUDENT_ID.eq(StudentEntity.ID))
+ .join(Movie.class).on(MovieEntity.ID.eq(WatchingEntity.MOVIE_ID))
+ .orderBy(StudentEntity.ID, MovieEntity.ID)
.get()) {
- for (Watching watching : watchings) {
- text.append(watching.getStudent().getName())
+ for (Tuple row : rows) {
+ text.append(row.get(StudentEntity.NAME))
.append(" | ")
- .append(watching.getMovie().getTitle())
+ .append(row.get(MovieEntity.TITLE))
.append(" | ")
- .append(watching.getRating())
+ .append(row.get(WatchingEntity.RATING))
.append('\n');
}
}
בחירה של כמה expressions מחזירה Tuple. כל ערך נקרא באמצעות אותו expression
שבחר אותו: StudentEntity.NAME, MovieEntity.TITLE או
WatchingEntity.RATING.
אפשר לבצע כאן Build > Make Project ולהריץ. המסך עדיין יציג אותן ארבע שורות,
אבל Logcat יכיל כעת שני inner join-ים. שינוי דרך השליפה לא שינה את התוצאה.
4. בונים טפסי דיאלוג קטנים
שלושת הדיאלוגים צריכים כמה Views פשוטים. במקום ליצור שלושה קובצי XML נוספים,
נוסיף פעולות עזר קטנות בתחתית MainActivity:
/** Creates one plain single-line dialog input. */
private EditText field(String hint) {
EditText field = new EditText(this);
field.setHint(hint);
field.setSingleLine();
return field;
}
/** Creates a small label for a dialog field. */
private TextView label(String text) {
TextView label = new TextView(this);
label.setText(text);
return label;
}
/** Creates a Spinner that displays the supplied strings. */
private Spinner spinner(List<String> values) {
Spinner spinner = new Spinner(this);
spinner.setAdapter(new ArrayAdapter<>(this,
android.R.layout.simple_spinner_dropdown_item, values));
return spinner;
}
/** Stacks dialog controls vertically with density-independent horizontal padding. */
private LinearLayout form(View... fields) {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
int padding = (int) (24 * getResources().getDisplayMetrics().density);
form.setPadding(padding, 0, padding, 0);
for (View field : fields) form.addView(field);
return form;
}
/** Shows brief feedback without adding another persistent view to the screen. */
private void toast(String message) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
}
form(View... fields) מקבלת מספר משתנה של Views, מסדרת אותם אנכית ומוסיפה
padding שמומר מ-dp לפי צפיפות המכשיר. כך אותה פעולה משרתת את כל הדיאלוגים.
5. דיאלוג Student
הוסיפו מעל פעולות העזר:
/** Shows a one-field dialog and inserts a Student when its name is non-empty. */
private void showAddStudent() {
EditText name = field("Name");
new AlertDialog.Builder(this)
.setTitle("Add Student")
.setView(form(name))
.setNegativeButton("Cancel", null)
.setPositiveButton("Add", (dialog, which) -> {
String value = name.getText().toString().trim();
if (value.isEmpty()) {
toast("Enter a student name");
} else {
Database.addStudent(data, value);
toast("Student added");
}
})
.show();
}
הפעולה משתמשת ב-Database.addStudent שכבר כתבנו בפרק 1. תלמיד בלי Watching
עדיין לא יופיע בתוצאת ה-JOIN; נשתמש בו מיד בדיאלוג השלישי.
6. דיאלוג Movie
הוסיפו:
/** Shows a two-field dialog and inserts a Movie when both values are non-empty. */
private void showAddMovie() {
EditText title = field("Title");
EditText genre = field("Genre");
new AlertDialog.Builder(this)
.setTitle("Add Movie")
.setView(form(title, genre))
.setNegativeButton("Cancel", null)
.setPositiveButton("Add", (dialog, which) -> {
String movieTitle = title.getText().toString().trim();
String movieGenre = genre.getText().toString().trim();
if (movieTitle.isEmpty() || movieGenre.isEmpty()) {
toast("Enter a title and genre");
} else {
Database.addMovie(data, movieTitle, movieGenre);
toast("Movie added");
}
})
.show();
}
גם כאן אנחנו משתמשים בפעולת ה-insert שכבר קיימת. סרט ללא Watching עדיין אינו
מופיע ב-INNER JOIN.
7. דיאלוג Watching
הדיאלוג צריך לשמור את האובייקטים שנבחרו, אבל Spinner מציג טקסט. הוסיפו שתי פעולות שמייצרות תוויות באותו סדר של הרשימות המקוריות:
/** Returns Student names in the same order as their objects. */
private List<String> namesOf(List<Student> students) {
List<String> names = new ArrayList<>();
for (Student student : students) names.add(student.getName());
return names;
}
/** Returns Movie titles in the same order as their objects. */
private List<String> titlesOf(List<Movie> movies) {
List<String> titles = new ArrayList<>();
for (Movie movie : movies) titles.add(movie.getTitle());
return titles;
}
כעת הוסיפו את הדיאלוג:
/** Selects Student and Movie choices, then inserts one rated relationship. */
private void showAddWatching() {
List<Student> students;
List<Movie> movies;
try (Result<Student> rows = data.select(Student.class)
.orderBy(StudentEntity.NAME).get()) {
students = rows.toList();
}
try (Result<Movie> rows = data.select(Movie.class)
.orderBy(MovieEntity.TITLE).get()) {
movies = rows.toList();
}
Spinner student = spinner(namesOf(students));
Spinner movie = spinner(titlesOf(movies));
EditText rating = field("Rating (1-10)");
rating.setInputType(InputType.TYPE_CLASS_NUMBER);
new AlertDialog.Builder(this)
.setTitle("Add Watching")
.setView(form(label("Student"), student, label("Movie"), movie, rating))
.setNegativeButton("Cancel", null)
.setPositiveButton("Add", (dialog, which) -> {
int score;
try {
score = Integer.parseInt(rating.getText().toString());
} catch (NumberFormatException e) {
toast("Enter a rating from 1 to 10");
return;
}
if (score < 1 || score > 10) {
toast("Enter a rating from 1 to 10");
return;
}
try {
Database.addWatching(data,
students.get(student.getSelectedItemPosition()),
movies.get(movie.getSelectedItemPosition()), score);
showWatchings();
toast("Watching added");
} catch (PersistenceException e) {
toast("That student already watched that movie");
}
})
.show();
}
ה-index שנבחר בכל Spinner ניגש לאותו index ברשימת ה-entities. לכן
Database.addWatching מקבלת Student ו-Movie אמיתיים ולא רק את הטקסט שלהם.
rating מוגבלת ל-1–10 לפני ה-insert. אם אותו צמד Student–Movie כבר קיים,
המפתח הראשי המורכב של Watching גורם ל-PersistenceException, והאפליקציה מציגה
הודעה במקום לקרוס.
8. מריצים את המסלול המלא
בצעו Build > Make Project, הריצו ובצעו:
- לחצו Add Student, הזינו
Liaולחצו Add. - לחצו Add Movie, הזינו
Cocoו-Animation. - לחצו Add Watching, בחרו
Lia, בחרוCoco, הזינו7ולחצו Add.
השורה החמישית צריכה להופיע מיד:
Lia | Coco | 7
ב-Logcat אמורות להופיע פעולת insert אחת לכל טבלה ולאחר מכן שאילתה הכוללת:
inner join Watching ... inner join Movie ...
סגרו והריצו שוב. השורה החמישית צריכה להישאר, והפתיחה מחדש צריכה לבצע אפס inserts.
נסו להוסיף שוב Lia → Coco. במקום שורה כפולה אמורה להופיע ההודעה:
That student already watched that movie
רשימת בדיקה
- השתנו רק
strings.xml,activity_main.xmlו-MainActivity.java. - המסך הראשוני עדיין מציג את ארבע שורות ה-seed.
- Logcat מציג שני
INNER JOIN-ים. - ניתן להוסיף Student ו-Movie דרך הדיאלוגים.
- Watching חדש מרענן מיד את תוצאת ה-JOIN.
- rating מחוץ לטווח 1–10 אינו נשמר.
- צמד Student–Movie כפול אינו נשמר.
- השורה החדשה נשארת לאחר restart ללא seed נוסף.
סיימנו יישום Requery קטן מקצה לקצה: מודל Java יצר schema ב-SQLite, קשר עם מידע משלו נשמר במפתח מורכב, שאילתת JOIN נכתבה באמצעות expressions typed, ושלושת סוגי הרשומות ניתנים להוספה מן המסך.
המשך לפרק 4: RecyclerView ומחיקת Watching
מורה־עזר ב־Gemini
פתחו את Gem: Requery: JOIN, RecyclerView ומחיקה כדי לקבל רמזים, שאלות אבחון והסברים המתאימים לשלב שבו אתם נמצאים.