Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 180 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,24 @@ You get the ergonomics of an ORM without losing the transparency of SQL. Everyth
* [Quick Start](#quick-start)

* [1) Define your model](#1-define-your-model)
* [2) Create your-dbcontext](#2-create-your-dbcontext)
* [2) Create your DbContext](#2-create-your-dbcontext)(#2-create-your-dbcontext)
* [3) Use a repository](#3-use-a-repository)
* [Fluent SQL Builders](#fluent-sql-builders)

* [Select](#select)
* [UpdateSql](#updatesql)
* [DeleteSql](#deletesql)
* [Single-Record Helpers](#single-record-helpers)
* [Custom Type Converters (NEW)](#custom-type-converters-new)

* [When to use](#when-to-use)
* [Implement a converter](#implement-a-converter)
* [Annotate your field](#annotate-your-field)
* [How it works under the hood](#how-it-works-under-the-hood)
* [Examples](#examples)
* [Migrations & schema notes](#migrations--schema-notes)
* [FAQ for converters](#faq-for-converters)
* [Simple Event Bus (NEW)](#simple-event-bus-new)
* [Async & Threading](#async--threading)
* [Migrations](#migrations)
* [Tuning & Pragmas](#tuning--pragmas)
Expand All @@ -48,6 +58,8 @@ You get the ergonomics of an ORM without losing the transparency of SQL. Everyth
## Highlights

* **Annotation Mapping**: Map entities with `@DbTableAnnotation` and `@DbColumnAnnotation` (PK, identity, nullability, ordinal).
* **Custom Type Converters (NEW)**: Store non‑primitive/complex types using `@DbConverterAnnotation` + `TypeConverter<F, D>`. Full round‑trip on **INSERT/UPDATE/DELETE/SELECT**.
* **Simple Event Bus (NEW)**: Lightweight, zero‑dep, in‑process pub/sub for domain events (e.g., fire `TodoCreatedEvent` → handle in one place).
* **Generic Repository**: Reusable `GenericRepository<T>` handles async CRUD in a thread-safe way.
* **Fluent Query Builders**: Type-aware `Select`, `UpdateSql`, `DeleteSql` compile to parameterized SQL with escaped identifiers.
* **Safe by Default**: Parameter binding everywhere. Identifiers backticked. Null-safe APIs (e.g., `whereNull` / `whereNotNull`).
Expand Down Expand Up @@ -104,7 +116,7 @@ import com.example.adbkit.entities.Todo;

public class DbContext extends ADbContext {
private static final String DB_NAME = "local.db";
private static final int VERSION = 3;
private static final int VERSION = 5;

public DbContext(Context context) { super(context, DB_NAME, VERSION); }

Expand Down Expand Up @@ -250,6 +262,159 @@ todos.deleteById(rowsRes -> {
}, 123);
```

---
## Custom Type Converters (NEW)

Many apps need to persist **enums, dates, JSON objects, UUIDs** or any domain‑specific value object. With custom converters you can declare **how to serialize** your field to a SQLite‑friendly type and **how to deserialize** it back.

### When to use

Use converters whenever a field is not directly representable as a primitive SQLite column, or when you want a custom storage representation (e.g., `Date` as epoch millis instead of ISO `TEXT`).

### Implement a converter

Create a class that implements `TypeConverter<F, D>` where:

* `F` = field type in your model (e.g., `EventType`, `Date`)
* `D` = database column type (e.g., `String`, `Long`, `byte[]`)

```java
import lib.persistence.converters.TypeConverter;

public final class EventTypeConverter implements TypeConverter<EventType, String> {
@Override public String toDatabaseValue(EventType v) { return v == null ? null : v.name(); }
@Override public EventType fromDatabaseValue(String s) { return s == null ? null : EventType.valueOf(s); }
@Override public String sqliteType() { return "TEXT"; } // column type in DDL
}

public final class DateMillisConverter implements TypeConverter<java.util.Date, Long> {
@Override public Long toDatabaseValue(java.util.Date v) { return v == null ? null : v.getTime(); }
@Override public java.util.Date fromDatabaseValue(Long t) { return t == null ? null : new java.util.Date(t); }
@Override public String sqliteType() { return "INTEGER"; }
}
```

> `sqliteType()` informs the table generator which SQLite type to emit in `CREATE TABLE`.

### Annotate your field

Attach a converter to a field with `@DbConverterAnnotation`:

```java
import lib.persistence.annotations.*;

@DbTableAnnotation(name = "events")
public class Event {
@DbColumnAnnotation(ordinal = 1, isPrimaryKey = true, isIdentity = true)
int id;

@DbColumnAnnotation(ordinal = 2, name = "event_type")
@DbConverterAnnotation(converter = EventTypeConverter.class)
EventType type;

@DbColumnAnnotation(ordinal = 3, name = "event_message")
String message;

@DbColumnAnnotation(ordinal = 4, name = "created_at")
@DbConverterAnnotation(converter = DateMillisConverter.class)
java.util.Date createdAt;

public Event() {}
}
```

### How it works under the hood

* **DDL generation**: `CreateTableCommand` inspects fields; if a field has a converter, its `sqliteType()` determines the column type.
* **INSERT/UPDATE**: `Mapper.objectToContentValues` calls your converter’s `toDatabaseValue(...)` before writing into `ContentValues`.
* **SELECT**: `Mapper.cursorToObject` fetches the raw DB value and calls `fromDatabaseValue(...)` to set the field.
* **DELETE/UPDATE WHERE (PK)**: If a **primary key** field is annotated with a converter, the key is transformed with `toDatabaseValue(...)` before binding to the `WHERE` clause.
* **Caching**: Converters are cached internally for performance; you don’t have to register them manually.

### Examples

**1) Enum ↔ TEXT**

```java
@DbConverterAnnotation(converter = EventTypeConverter.class)
EventType type; // stored as TEXT (e.g., "CREATED")
```

**2) Date ↔ INTEGER (epoch millis)**

```java
@DbConverterAnnotation(converter = DateMillisConverter.class)
Date createdAt; // stored as INTEGER
```

**3) JSON ↔ TEXT**

```java
public final class AddressJsonConverter implements TypeConverter<Address, String> {
@Override public String toDatabaseValue(Address v) { return v == null ? null : v.toJson(); }
@Override public Address fromDatabaseValue(String s) { return s == null ? null : Address.fromJson(s); }
@Override public String sqliteType() { return "TEXT"; }
}
```
### Migrations & schema notes

* Changing a field to use a converter **may change the column type** (e.g., `TEXT` → `INTEGER`). Provide a migration step if your table already exists.
* Identity integer PKs remain `INTEGER PRIMARY KEY AUTOINCREMENT` and are **not** updated via SET.
* Ensure `ordinal` values are unique per entity and add a **no‑arg constructor** for reflection.

### FAQ for converters

* **Do I need to register converters?** No. Attach with `@DbConverterAnnotation` on fields. Instances are cached internally.
* **Are converters used in custom `Select` builders?** Builders bind your literal arguments. Mapping back to objects always goes through `Mapper.cursorToObject`, so fields with converters are correctly materialized.
* **Composite PKs?** Supported. If a PK field has a converter, it’s applied to the `WHERE` binding.

---

# Simple Event Bus (NEW)

A tiny, zero‑dependency pub/sub helper to emit domain events from repositories/UI and handle them in one place. Great for logging/auditing (e.g., store `Event` rows when a `Todo` is created/updated/deleted).

### API

```java
public final class SimpleEventBus {
// Register a single consumer per event type
public static <T> void subscribe(Class<T> eventType, java.util.function.Consumer<T> listener);
public static <T> void post(T event); // Synchronous, in‑process
}
```

> Current implementation keeps **one subscriber per event class** (a simple `Map<Class<?>, Consumer<?>>`). If you need fan‑out (N subscribers), replace the `Consumer<?>` with a `List<Consumer<?>>` — the public API can stay the same.

### Built‑in sample events

```java
// app/src/main/java/com/example/adbkit/events
class TodoCreatedEvent { public final Todo todo; /* ctor */ }
class TodoUpdatedEvent { public final Todo todo; /* ctor */ }
class TodoDeletedEvent { public final Todo todo; /* ctor */ }
```

### Wiring example (MainActivity)

```java
// Subscribe once (e.g., in onCreate)
SimpleEventBus.subscribe(TodoCreatedEvent.class, e -> {
Event ev = new Event(EventType.CREATED, "Yeni Todo: " + e.todo.id + " title: " + e.todo.title);
eventRepository.insert(ev, r -> { /* handle DbResult */ });
});

// Post from your repository callbacks
SimpleEventBus.post(new TodoCreatedEvent(createdTodo));
```

This example persists an `Event` row with `EventType` and message. `EventType` is stored as `TEXT` via `EventTypeConverter`; `created_at` as epoch millis via `DateMillisConverter`.

### Notes

* Calls are **synchronous** on the caller thread; keep handlers light and push heavy work to your existing executors.
* The bus is package‑private and simple by design. For cross‑module or sticky events, bring your favorite event lib.

---

## Async & Threading
Expand Down Expand Up @@ -286,6 +451,18 @@ new MigrationStep() {
};
```

Use versioned steps or simple recreate for demos:

```java
new MigrationStep() {
public int from() { return 4; }
public int to() { return 5; }
public void apply(SQLiteDatabase db) {
db.execSQL(CreateTableCommand.build(Event.class).getQuery());
}
};
```

---

## Tuning & Pragmas
Expand Down Expand Up @@ -332,7 +509,7 @@ todos.insert(item, result -> {
A: Only if you want to. Builders cover most cases; raw SQL is still available via `rawQuery`.

**Q: How are booleans stored?**
A: As `INTEGER` (0/1). Dates as ISO strings when using `LocalDate`/`LocalDateTime`.
A: As `INTEGER` (0/1). Dates as ISO strings when using `LocalDate`/`LocalDateTime`. Dates can also be stored via converters (e.g., epoch millis with `DateMillisConverter`), or as text if you provide a different converter.

**Q: Composite primary keys?**
A: Fully supported in `DeleteCommand`, `UpdateCommand`, and builder APIs.
Expand Down
3 changes: 1 addition & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
android:name=".MyApplication"
Expand Down
16 changes: 9 additions & 7 deletions app/src/main/java/com/example/adbkit/DbContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;

import com.example.adbkit.entities.Todo;

import lib.persistence.ADbContext;
import lib.persistence.command.definition.CreateTableCommand;
import lib.persistence.command.definition.CreateIndexCommand;
import lib.persistence.command.definition.DropTableCommand;
import lib.persistence.command.definition.CreateTableCommand;
import lib.persistence.migration.Migrations;

import com.example.adbkit.entities.Todo;

public class DbContext extends ADbContext {
private static final String dbName = "local.db";
private static final int version = 3;
private static final int version = 5;

public DbContext(Context context) {
super(context, dbName, version);
}
Expand Down Expand Up @@ -44,10 +44,12 @@ protected void onCreateSchema(SQLiteDatabase db) {

@Override
protected void onUpgradeSchema(SQLiteDatabase db, int oldVersion, int newVersion) {
// Migrations.apply(db, oldVersion, newVersion);
// Migrations.apply(db, oldVersion, newVersion);

// Basit senaryoda drop + recreate
db.execSQL(DropTableCommand.build(Todo.class).getQuery());
// db.execSQL(DropTableCommand.build(Todo.class).getQuery());

Migrations.apply(db, 4, 5);
onCreateSchema(db);
}
}
82 changes: 80 additions & 2 deletions app/src/main/java/com/example/adbkit/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,27 @@

import androidx.appcompat.app.AppCompatActivity;

import com.example.adbkit.entities.Event;
import com.example.adbkit.entities.EventType;
import com.example.adbkit.entities.Todo;
import com.example.adbkit.events.SimpleEventBus;
import com.example.adbkit.events.TodoCreatedEvent;
import com.example.adbkit.events.TodoDeletedEvent;
import com.example.adbkit.events.TodoUpdatedEvent;
import com.example.adbkit.repositories.EventRepository;
import com.example.adbkit.repositories.TodoRepository;

import java.util.ArrayList;

import lib.persistence.DbCallback;
import lib.persistence.DbResult;
import lib.persistence.RepositoryFactory;
import com.example.adbkit.entities.Todo;
import com.example.adbkit.repositories.TodoRepository;

public class MainActivity extends AppCompatActivity {

private static final String TAG = "MainActivity";
private TodoRepository todoRepository;
private EventRepository eventRepository;

// @Override
// protected void onCreate(Bundle savedInstanceState) {
Expand All @@ -40,6 +49,59 @@ protected void onCreate(Bundle savedInstanceState) {
// DbContext dbContext = new DbContext(getApplicationContext());
// todoRepository = new TodoRepository(dbContext);
todoRepository = RepositoryFactory.getTodoRepository(getApplicationContext());
eventRepository = RepositoryFactory.getEventRepository(getApplicationContext());

SimpleEventBus.subscribe(TodoCreatedEvent.class, event -> {
Event newEvent = new Event(EventType.CREATED, "Todo: " + event.todo.id + " title: " + event.todo.title);

eventRepository.insert(newEvent, result -> {
if (!result.isSuccess()) {
Exception e = ((DbResult.Error) result).getException();
Log.e(TAG, "CREATE - Hata: " + e.getMessage());
}
});

// eventRepository.insert(newEvent, new DbCallback<Event>() {
// @Override
// public void onResult(DbResult<Event> result) {
// if (!result.isSuccess()) {
// Exception e = ((DbResult.Error) result).getException();
// Log.e(TAG, "CREATE - Hata: " + e.getMessage());
// }
// }
// });
});

SimpleEventBus.subscribe(TodoUpdatedEvent.class, event -> {
Event newEvent = new Event(EventType.UPDATED, "Todo: " + event.todo.id + " title: " + event.todo.title);

eventRepository.insert(newEvent, result -> {
if (!result.isSuccess()) {
Exception e = ((DbResult.Error) result).getException();
Log.e(TAG, "UPDATE - Hata: " + e.getMessage());
}
});

// eventRepository.insert(newEvent, new DbCallback<Event>() {
// @Override
// public void onResult(DbResult<Event> result) {
// if (!result.isSuccess()) {
// Exception e = ((DbResult.Error) result).getException();
// Log.e(TAG, "UPDATE - Hata: " + e.getMessage());
// }
// }
// });
});

SimpleEventBus.subscribe(TodoDeletedEvent.class, event -> {
Event newEvent = new Event(EventType.DELETED, "Todo: " + event.todo.id + " title: " + event.todo.title);
eventRepository.insert(newEvent, result -> {
if (!result.isSuccess()) {
Exception e = ((DbResult.Error) result).getException();
Log.e(TAG, "DELETE - Hata: " + e.getMessage());
}
});
});

// Örnek bir işlem başlatmak için bir buton tanımlayalım
Button startDbOpsButton = findViewById(R.id.start_db_ops_button);
Expand Down Expand Up @@ -119,6 +181,7 @@ public void onResult(DbResult<Todo> result) {
Todo updatedTodo = ((DbResult.Success<Todo>) result).getData();
Log.d(TAG, "UPDATE - Başarılı: " + updatedTodo);

SimpleEventBus.post(new TodoUpdatedEvent(updatedTodo));
// Bir sonraki adıma geçelim: okuma
readUpdatedTodo(updatedTodo.id);
} else {
Expand Down Expand Up @@ -160,8 +223,23 @@ private void deleteTodo(Todo todoToDelete) {
public void onResult(DbResult<Todo> result) {
if (result.isSuccess()) {
Log.d(TAG, "DELETE - Başarılı: Todo silindi.");

SimpleEventBus.post(new TodoDeletedEvent(todoToDelete));

// Son kontrol için tüm todoları tekrar okuyalım
readAllTodos();

eventRepository.selectAll(new DbCallback<ArrayList<Event>>() {
@Override
public void onResult(DbResult<ArrayList<Event>> result) {
ArrayList<Event> events = ((DbResult.Success<ArrayList<Event>>) result).getData();
Log.d(TAG, "READ ALL - Başarılı, bulunan Todo sayısı: " + events.size());
for (Event event : events) {
Log.d(TAG, "READ ALL - Todo: " + event.toString());
}
}
});

} else {
Exception e = ((DbResult.Error) result).getException();
Log.e(TAG, "DELETE - Hata: " + e.getMessage());
Expand Down
Loading