Requery 3 - JOIN והוספות


INNER JOIN typed ושלושה דיאלוגים שמעדכנים את SQLite

זהו פרק הסיום. הוא מתחיל מן המודל וה-seed העובדים של פרק 2 — קשר עם דירוג, מחליף את relationship navigation ב-INNER JOIN typed ומוסיף שלושה כפתורים: Student, Movie ו-Watching. לא נשנה שוב את ה-schema או את Database.java.

חזרה לפרק 2: קשר עם דירוג

השינוי כולו בשלושה קבצים

קובץ שינוי
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, הריצו ובצעו:

  1. לחצו Add Student, הזינו Lia ולחצו Add.
  2. לחצו Add Movie, הזינו Coco ו-Animation.
  3. לחצו 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 ומחיקה כדי לקבל רמזים, שאלות אבחון והסברים המתאימים לשלב שבו אתם נמצאים.