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)

אין כאן שמות טבלאות או עמודות כמחרוזות. כל 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