diff --git a/README.md b/README.md
index 12717c9..10e2bae 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,36 @@
# DocumentFileCompat
-A faster alternative to AndroidX's DocumentFile.
+AndroidX `DocumentFile`, without the usual performance tax.
+
+`DocumentFileCompat` is a faster, practical alternative to AndroidX's `DocumentFile` for working
+with SAF tree and document Uris. It reduces repeated `ContentResolver` lookups by fetching the
+metadata you actually need up front, so directory listing and file inspection stay usable even in
+large folders.
### The Problem with DocumentFile
-It is horribly slow!\
-For **almost** every method, there is a query to **ContentResolver**.
+`DocumentFile` is convenient, but it can get painfully slow.
+
+For many common operations, it repeatedly queries `ContentResolver`. That cost adds up fast when
+you:
+
+- list large directories
+- call `findFile()`
+- read names, sizes, MIME types, and timestamps for many children
+- build your own file models from SAF results
-The most common one is `DocumentFile.findFile()`, `DocumentFile.getName()` and other is building a
-Custom Data Model with multiple parameters.\
-This can take like a horrible amount of time.
+### Why DocumentFileCompat
-### Solution
+`DocumentFileCompat` keeps the API familiar, but fetches relevant file metadata in a single pass
+when possible.
-`DocumentFileCompat` is a drop-in replacement which gathers relevant parameters when querying for
-files.\
-The performance can sometimes peak to 2x or quite higher, depending on the size of the folder.
+That means you get:
+
+- faster directory listing
+- fewer redundant SAF queries
+- custom projections when you only need a few columns
+- query support for filtering, sorting, paging, and projection
+- a drop-in-friendly replacement for most `DocumentFile` usage
Check the screenshots below:
@@ -23,15 +38,11 @@ Check the screenshots below:
[
](/screenshots/filecompat_file_perf.jpeg)
-**48 whopping seconds for directory listing compared to 3.5!** (Obviously, No competition with the
-Native File API).\
-Also extracting file information does not take that much time but the improvement is still
-significant.
+One local benchmark dropped a large directory listing from **48 seconds to 3.5 seconds**.\
+It still does not beat the native `File` API, but for SAF-heavy code it is a major improvement.
-**Note:** `DocumentFileCompat` is something that I used internally for some projects & therefore I
-didn't do much of file manipulation with it (only delete files) and therefore this API does
-not offer too much out of the box.\
-This is now a completely usable alternative to `DocumentFile`.
+What started as an internal utility is now a solid, usable alternative to `DocumentFile` for
+real-world SAF workflows.
### Installation
@@ -57,9 +68,58 @@ dependencies {
### Usage
-Almost all of the methods & getters are identical to `DocumentFile`, you'll just have to replace the
-imports.\
-Additional methods like `copyTo(destination: Uri)` & `copyFrom(source: Uri)` are added as well.
+Most methods and getters are intentionally close to AndroidX `DocumentFile`, so migration is mostly
+about swapping imports.
+
+Extras include:
+
+- `copyTo(destination: Uri)`
+- `copyFrom(source: Uri)`
+- custom projections
+- query-based child listing
+
+Basic example:
+
+```kotlin
+val directory = DocumentFileCompat.fromTreeUri(context, treeUri) ?: return
+
+val recentFiles = directory.listFiles()
+```
+
+#### Querying Child Documents
+
+`DocumentFileCompat` now supports `listFiles(vararg queries: Query)` for tree-backed directories
+when you need filtering, sorting, paging, or projection without dropping down to raw resolver code.
+
+```kotlin
+import android.provider.DocumentsContract
+import com.lazygeniouz.dfc.file.Query
+
+val directory = DocumentFileCompat.fromTreeUri(context, treeUri) ?: return
+
+val files = directory.listFiles(
+ Query.filesOnly(),
+ Query.orderByDesc(DocumentsContract.Document.COLUMN_LAST_MODIFIED),
+ Query.limit(100),
+ Query.select(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_SIZE,
+ ),
+)
+```
+
+Notes:
+
+- `listFiles(vararg queries: Query)` is only supported for tree-backed `DocumentsProvider`
+ directories.
+- On API 21-25, only `Query.select(...)`, `Query.projection(...)`, `Query.orderByAsc(...)`, and
+ `Query.orderByDesc(...)` are honored.
+- On API 26+, filter queries, `Query.limit(...)`, `Query.offset(...)`, and
+ `Query.rawSelection(...)` are also forwarded.
+- Unsupported queries are ignored and logged.
+- Providers may still ignore supported query arguments. `DocumentFileCompat` forwards them, but the
+ underlying provider decides what gets honored.
#### Reference:
diff --git a/app/src/main/java/com/lazygeniouz/filecompat/example/performance/ProjectionPerformance.kt b/app/src/main/java/com/lazygeniouz/filecompat/example/performance/ProjectionPerformance.kt
index d13613b..56306f6 100644
--- a/app/src/main/java/com/lazygeniouz/filecompat/example/performance/ProjectionPerformance.kt
+++ b/app/src/main/java/com/lazygeniouz/filecompat/example/performance/ProjectionPerformance.kt
@@ -2,9 +2,11 @@ package com.lazygeniouz.filecompat.example.performance
import android.content.Context
import android.net.Uri
+import android.os.Build
import android.provider.DocumentsContract.Document
import androidx.documentfile.provider.DocumentFile
import com.lazygeniouz.dfc.file.DocumentFileCompat
+import com.lazygeniouz.dfc.file.Query
import com.lazygeniouz.filecompat.example.performance.Performance.measureTimeSeconds
object ProjectionPerformance {
@@ -31,6 +33,14 @@ object ProjectionPerformance {
results += "=".repeat(48).plus("\n\n")
+ // Test 5: Projection overload parity vs Query.select
+ results += testSelectQueryParity(context, uri) + "\n\n"
+
+ // Test 6: Provider query correctness for filesOnly
+ results += testFilesOnlyQuery(context, uri) + "\n\n"
+
+ results += "=".repeat(48).plus("\n\n")
+
return results
}
@@ -119,4 +129,63 @@ object ProjectionPerformance {
"DFC.count() = ${dfcCountTime}s\n" +
"DFC.listFiles().size = ${dfcListSizeTime}s"
}
+
+ private fun testSelectQueryParity(context: Context, uri: Uri): String {
+ val documentFile = DocumentFileCompat.fromTreeUri(context, uri)
+ ?: return "Query.select parity:\nFailed to access directory"
+
+ val projection = arrayOf(
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_SIZE,
+ )
+
+ var projectionCount = 0
+ val projectionTime = measureTimeSeconds {
+ projectionCount = documentFile.listFiles(projection).size
+ }
+
+ var queryCount = 0
+ val queryTime = measureTimeSeconds {
+ queryCount = documentFile.listFiles(
+ Query.select(
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_SIZE,
+ )
+ ).size
+ }
+
+ return "Projection overload vs Query.select:\n" +
+ "Projection count = $projectionCount (${projectionTime}s)\n" +
+ "Query.select count = $queryCount (${queryTime}s)\n" +
+ "Match = ${projectionCount == queryCount}"
+ }
+
+ private fun testFilesOnlyQuery(context: Context, uri: Uri): String {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return "Query.filesOnly correctness:\nSkipped on API < 26 (query filters are ignored)."
+ }
+
+ val documentFile = DocumentFileCompat.fromTreeUri(context, uri)
+ ?: return "Query.filesOnly correctness:\nFailed to access directory"
+
+ var providerResult = emptyList()
+ val providerTime = measureTimeSeconds {
+ providerResult = documentFile.listFiles(Query.filesOnly())
+ }
+
+ var clientSideResult = emptyList()
+ val clientSideTime = measureTimeSeconds {
+ clientSideResult = documentFile.listFiles().filter { !it.isDirectory() }
+ }
+
+ val providerUris = providerResult.map { it.uri }.toSet()
+ val clientSideUris = clientSideResult.map { it.uri }.toSet()
+
+ return "Query.filesOnly correctness:\n" +
+ "Provider query count = ${providerResult.size} (${providerTime}s)\n" +
+ "Client-side filter count = ${clientSideResult.size} (${clientSideTime}s)\n" +
+ "Match = ${providerUris == clientSideUris}"
+ }
}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 7a537b1..38ff10f 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,21 +1,28 @@
-
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:fillViewport="true">
+
+
+
@@ -23,6 +30,7 @@
android:id="@+id/buttonDir"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
android:layout_marginVertical="12dp"
android:paddingVertical="12dp"
android:text="@string/select_directory" />
@@ -31,6 +39,7 @@
android:id="@+id/buttonFile"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
android:layout_marginVertical="12dp"
android:paddingVertical="12dp"
android:text="@string/select_a_file" />
@@ -39,8 +48,9 @@
android:id="@+id/buttonProjections"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
android:layout_marginVertical="12dp"
android:paddingVertical="12dp"
android:text="@string/test_custom_projections" />
-
\ No newline at end of file
+
diff --git a/dfc/build.gradle b/dfc/build.gradle
index 6aca964..b7ffb56 100644
--- a/dfc/build.gradle
+++ b/dfc/build.gradle
@@ -24,4 +24,8 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
+}
+
+dependencies {
+ testImplementation "junit:junit:4.13.2"
}
\ No newline at end of file
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt b/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt
index 85d189a..0485ed5 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt
@@ -8,6 +8,7 @@ import android.net.Uri
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import com.lazygeniouz.dfc.file.DocumentFileCompat
+import com.lazygeniouz.dfc.file.Query
import com.lazygeniouz.dfc.resolver.ResolverCompat
@@ -39,6 +40,15 @@ internal class DocumentController(
else ResolverCompat.listFiles(context, fileCompat, projection)
}
+ /**
+ * List child documents using provider-specific query arguments.
+ */
+ internal fun listFiles(vararg queries: Query): List {
+ return if (!isDirectory()) {
+ throw UnsupportedOperationException("Selected document is not a Directory.")
+ } else ResolverCompat.queryFiles(context, fileCompat, *queries)
+ }
+
/**
* This will return the children count in the directory.
*
@@ -115,7 +125,6 @@ internal class DocumentController(
if (Document.MIME_TYPE_DIR == fileCompat.documentMimeType &&
fileCompat.documentFlags and DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE != 0
) return true
-
else if (fileCompat.documentMimeType.isNotEmpty() &&
fileCompat.documentFlags and Document.FLAG_SUPPORTS_WRITE != 0
) return true
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt
index 1d279df..d6408b8 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt
@@ -73,6 +73,20 @@ abstract class DocumentFileCompat(
*/
abstract fun listFiles(projection: Array): List
+ /**
+ * List child documents using [Query] clauses.
+ *
+ * This is only supported for tree-backed directories.
+ *
+ * API support for SAF child-document queries:
+ *
+ * - API 21-25: only [Query.select], [Query.projection], [Query.orderByAsc], and [Query.orderByDesc] are honored.
+ * - API 26+: filter queries, [Query.limit], [Query.offset], and [Query.rawSelection] are also forwarded.
+ *
+ * **NOTE: Unsupported clauses are ignored and logged.**
+ */
+ abstract fun listFiles(vararg queries: Query): List
+
/**
* This will return the children count inside a **Directory** without creating [DocumentFileCompat] objects.
*
@@ -255,4 +269,4 @@ abstract class DocumentFileCompat(
return paths.size >= 2 && "tree" == paths[0]
}
}
-}
\ No newline at end of file
+}
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt
new file mode 100644
index 0000000..90d8eb8
--- /dev/null
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt
@@ -0,0 +1,513 @@
+package com.lazygeniouz.dfc.file
+
+import android.provider.DocumentsContract.Document
+import com.lazygeniouz.dfc.file.Query.Companion.limit
+import com.lazygeniouz.dfc.file.Query.Companion.offset
+import com.lazygeniouz.dfc.file.Query.Companion.orderByAsc
+import com.lazygeniouz.dfc.file.Query.Companion.orderByDesc
+import com.lazygeniouz.dfc.file.Query.Companion.projection
+import com.lazygeniouz.dfc.file.Query.Companion.rawSelection
+
+/**
+ * Query clauses for [DocumentFileCompat.listFiles].
+ *
+ * For tree-backed SAF directories:
+ *
+ * - API 21-25: only [projection], [orderByAsc], and [orderByDesc] are honored.
+ * - API 26+: filter queries, [limit], [offset], and [rawSelection] are also forwarded.
+ *
+ * Unsupported clauses are ignored and logged.
+ */
+sealed class Query {
+
+ internal data class Projection(val columns: List) : Query()
+
+ internal data class Sort(val column: String, val descending: Boolean) : Query()
+
+ internal data class Limit(val count: Int) : Query()
+
+ internal data class Offset(val count: Int) : Query()
+
+ internal data class Selection(
+ val attribute: String,
+ val operator: Operator,
+ val values: List,
+ ) : Query()
+
+ internal data class RawSelection(
+ val selection: String,
+ val args: List,
+ ) : Query()
+
+ internal enum class Operator {
+ EQUAL,
+ NOT_EQUAL,
+ IN,
+ NOT_IN,
+ GREATER_THAN,
+ GREATER_THAN_OR_EQUAL,
+ LESS_THAN,
+ LESS_THAN_OR_EQUAL,
+ BETWEEN,
+ IS_NULL,
+ IS_NOT_NULL,
+ LIKE,
+ NOT_LIKE,
+ }
+
+ companion object {
+
+ /**
+ * Fetch only the given columns.
+ *
+ * Honored on API 21+.
+ */
+ @JvmStatic
+ fun select(vararg columns: String): Query {
+ return Projection(columns.toList())
+ }
+
+ /**
+ * Fetch only the given columns.
+ *
+ * Honored on API 21+.
+ */
+ @Deprecated(
+ message = "Use select(...) instead.",
+ replaceWith = ReplaceWith("select(*columns)"),
+ )
+ @JvmStatic
+ fun projection(vararg columns: String): Query {
+ return select(*columns)
+ }
+
+ /**
+ * Sort ascending by the given column.
+ *
+ * Honored on API 21+.
+ */
+ @JvmStatic
+ fun orderByAsc(column: String): Query {
+ return Sort(column, descending = false)
+ }
+
+ /**
+ * Sort descending by the given column.
+ *
+ * Honored on API 21+.
+ */
+ @JvmStatic
+ fun orderByDesc(column: String): Query {
+ return Sort(column, descending = true)
+ }
+
+ /**
+ * Limit the number of returned child documents.
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun limit(count: Int): Query {
+ require(count >= 0) { "limit must be >= 0" }
+ return Limit(count)
+ }
+
+ /**
+ * Skip the first [count] child documents.
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun offset(count: Int): Query {
+ require(count >= 0) { "offset must be >= 0" }
+ return Offset(count)
+ }
+
+ /**
+ * Attribute equals [value].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun equal(attribute: String, value: Any?): Query {
+ return if (value == null) isNull(attribute) else Selection(
+ attribute,
+ Operator.EQUAL,
+ listOf(value),
+ )
+ }
+
+ /**
+ * Attribute does not equal [value].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun notEqual(attribute: String, value: Any?): Query {
+ return if (value == null) isNotNull(attribute) else Selection(
+ attribute,
+ Operator.NOT_EQUAL,
+ listOf(value),
+ )
+ }
+
+ /**
+ * Attribute equals one of [values].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun `in`(attribute: String, vararg values: Any?): Query {
+ require(values.isNotEmpty()) { "in requires at least one value" }
+ return Selection(attribute, Operator.IN, values.toList())
+ }
+
+ /**
+ * Attribute does not equal any of [values].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun notIn(attribute: String, vararg values: Any?): Query {
+ require(values.isNotEmpty()) { "notIn requires at least one value" }
+ return Selection(attribute, Operator.NOT_IN, values.toList())
+ }
+
+ /**
+ * Attribute is greater than [value].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun greaterThan(attribute: String, value: Any): Query {
+ return Selection(attribute, Operator.GREATER_THAN, listOf(value))
+ }
+
+ /**
+ * Attribute is greater than or equal to [value].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun greaterThanOrEqual(attribute: String, value: Any): Query {
+ return Selection(attribute, Operator.GREATER_THAN_OR_EQUAL, listOf(value))
+ }
+
+ /**
+ * Attribute is less than [value].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun lessThan(attribute: String, value: Any): Query {
+ return Selection(attribute, Operator.LESS_THAN, listOf(value))
+ }
+
+ /**
+ * Attribute is less than or equal to [value].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun lessThanOrEqual(attribute: String, value: Any): Query {
+ return Selection(attribute, Operator.LESS_THAN_OR_EQUAL, listOf(value))
+ }
+
+ /**
+ * Attribute is between [start] and [endInclusive].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun between(attribute: String, start: Any, endInclusive: Any): Query {
+ return Selection(attribute, Operator.BETWEEN, listOf(start, endInclusive))
+ }
+
+ /**
+ * Attribute is null.
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun isNull(attribute: String): Query {
+ return Selection(attribute, Operator.IS_NULL, emptyList())
+ }
+
+ /**
+ * Attribute is not null.
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun isNotNull(attribute: String): Query {
+ return Selection(attribute, Operator.IS_NOT_NULL, emptyList())
+ }
+
+ /**
+ * Attribute matches the SQL LIKE [pattern].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun like(attribute: String, pattern: String): Query {
+ return Selection(attribute, Operator.LIKE, listOf(pattern))
+ }
+
+ /**
+ * Attribute does not match the SQL LIKE [pattern].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun notLike(attribute: String, pattern: String): Query {
+ return Selection(attribute, Operator.NOT_LIKE, listOf(pattern))
+ }
+
+ /**
+ * Pass a raw SQL-style selection expression.
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun rawSelection(selection: String, vararg args: String): Query {
+ require(selection.isNotBlank()) { "selection must not be blank" }
+ return RawSelection(selection, args.toList())
+ }
+
+ /**
+ * Exclude directories.
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun filesOnly(): Query {
+ return notEqual(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
+ }
+
+ /**
+ * Include only directories.
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun directoriesOnly(): Query {
+ return equal(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
+ }
+
+ /**
+ * Name equals [value].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun nameEquals(value: String): Query {
+ return equal(Document.COLUMN_DISPLAY_NAME, value)
+ }
+
+ /**
+ * Name contains [value].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun nameContains(value: String): Query {
+ return like(
+ Document.COLUMN_DISPLAY_NAME,
+ "%${escapeLikePattern(value)}%",
+ )
+ }
+
+ /**
+ * MIME type equals [value].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun mimeType(value: String): Query {
+ return equal(Document.COLUMN_MIME_TYPE, value)
+ }
+
+ /**
+ * MIME type equals one of [values].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun mimeTypeIn(vararg values: String): Query {
+ return `in`(Document.COLUMN_MIME_TYPE, *values)
+ }
+
+ /**
+ * Size is greater than [bytes].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun sizeGreaterThan(bytes: Long): Query {
+ return greaterThan(Document.COLUMN_SIZE, bytes)
+ }
+
+ /**
+ * Size is less than [bytes].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun sizeLessThan(bytes: Long): Query {
+ return lessThan(Document.COLUMN_SIZE, bytes)
+ }
+
+ /**
+ * Last modified time is after [timestampMillis].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun lastModifiedAfter(timestampMillis: Long): Query {
+ return greaterThan(Document.COLUMN_LAST_MODIFIED, timestampMillis)
+ }
+
+ /**
+ * Last modified time is before [timestampMillis].
+ *
+ * Honored on API 26+.
+ */
+ @JvmStatic
+ fun lastModifiedBefore(timestampMillis: Long): Query {
+ return lessThan(Document.COLUMN_LAST_MODIFIED, timestampMillis)
+ }
+
+ private fun escapeLikePattern(value: String): String {
+ return value
+ .replace("\\", "\\\\")
+ .replace("%", "\\%")
+ .replace("_", "\\_")
+ }
+ }
+}
+
+internal object QueryDefaults {
+ val DEFAULT_PROJECTION = listOf(
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_SIZE,
+ Document.COLUMN_LAST_MODIFIED,
+ Document.COLUMN_MIME_TYPE,
+ Document.COLUMN_FLAGS,
+ )
+}
+
+internal data class SelectionPart(
+ val selection: String,
+ val args: List,
+)
+
+internal fun Query.Selection.toSelectionPart(): SelectionPart {
+ val attribute = attribute
+
+ return when (operator) {
+ Query.Operator.EQUAL -> SelectionPart("($attribute = ?)", listOf(values.first().toSqlArg()))
+ Query.Operator.NOT_EQUAL -> SelectionPart(
+ "($attribute != ?)",
+ listOf(values.first().toSqlArg())
+ )
+
+ Query.Operator.IN -> buildInSelection(attribute, values)
+ Query.Operator.NOT_IN -> buildNotInSelection(attribute, values)
+
+ Query.Operator.GREATER_THAN ->
+ SelectionPart("($attribute > ?)", listOf(values.first().toSqlArg()))
+
+ Query.Operator.GREATER_THAN_OR_EQUAL ->
+ SelectionPart("($attribute >= ?)", listOf(values.first().toSqlArg()))
+
+ Query.Operator.LESS_THAN ->
+ SelectionPart("($attribute < ?)", listOf(values.first().toSqlArg()))
+
+ Query.Operator.LESS_THAN_OR_EQUAL ->
+ SelectionPart("($attribute <= ?)", listOf(values.first().toSqlArg()))
+
+ Query.Operator.BETWEEN -> SelectionPart(
+ "($attribute BETWEEN ? AND ?)",
+ listOf(values[0].toSqlArg(), values[1].toSqlArg()),
+ )
+
+ Query.Operator.IS_NULL -> SelectionPart("($attribute IS NULL)", emptyList())
+ Query.Operator.IS_NOT_NULL -> SelectionPart("($attribute IS NOT NULL)", emptyList())
+ Query.Operator.LIKE ->
+ SelectionPart("($attribute LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg()))
+
+ Query.Operator.NOT_LIKE ->
+ SelectionPart("($attribute NOT LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg()))
+ }
+}
+
+private fun buildInSelection(attribute: String, values: List): SelectionPart {
+ val nonNullValues = values.filterNotNull()
+ val hasNull = values.any { it == null }
+
+ return when {
+ nonNullValues.isEmpty() && hasNull -> SelectionPart("($attribute IS NULL)", emptyList())
+ hasNull -> SelectionPart(
+ "(($attribute IN (${nonNullValues.joinToString(",") { "?" }})) OR ($attribute IS NULL))",
+ nonNullValues.map { it.toSqlArg() },
+ )
+
+ else -> SelectionPart(
+ "($attribute IN (${nonNullValues.joinToString(",") { "?" }}))",
+ nonNullValues.map { it.toSqlArg() },
+ )
+ }
+}
+
+private fun buildNotInSelection(attribute: String, values: List): SelectionPart {
+ val nonNullValues = values.filterNotNull()
+ val hasNull = values.any { it == null }
+
+ return when {
+ nonNullValues.isEmpty() && hasNull -> SelectionPart("($attribute IS NOT NULL)", emptyList())
+ hasNull -> SelectionPart(
+ "(($attribute NOT IN (${nonNullValues.joinToString(",") { "?" }})) AND ($attribute IS NOT NULL))",
+ nonNullValues.map { it.toSqlArg() },
+ )
+
+ else -> SelectionPart(
+ "($attribute NOT IN (${nonNullValues.joinToString(",") { "?" }}))",
+ nonNullValues.map { it.toSqlArg() },
+ )
+ }
+}
+
+private fun Any?.toSqlArg(): String {
+ return when (this) {
+ null -> "null"
+ is Boolean -> if (this) "1" else "0"
+ else -> toString()
+ }
+}
+
+internal fun Query.describe(): String {
+ return when (this) {
+ is Query.Projection -> "projection"
+ is Query.Sort -> if (descending) "orderByDesc($column)" else "orderByAsc($column)"
+ is Query.Limit -> "limit($count)"
+ is Query.Offset -> "offset($count)"
+ is Query.Selection -> when (operator) {
+ Query.Operator.EQUAL -> "equal($attribute)"
+ Query.Operator.NOT_EQUAL -> "notEqual($attribute)"
+ Query.Operator.IN -> "in($attribute)"
+ Query.Operator.NOT_IN -> "notIn($attribute)"
+ Query.Operator.GREATER_THAN -> "greaterThan($attribute)"
+ Query.Operator.GREATER_THAN_OR_EQUAL -> "greaterThanOrEqual($attribute)"
+ Query.Operator.LESS_THAN -> "lessThan($attribute)"
+ Query.Operator.LESS_THAN_OR_EQUAL -> "lessThanOrEqual($attribute)"
+ Query.Operator.BETWEEN -> "between($attribute)"
+ Query.Operator.IS_NULL -> "isNull($attribute)"
+ Query.Operator.IS_NOT_NULL -> "isNotNull($attribute)"
+ Query.Operator.LIKE -> "like($attribute)"
+ Query.Operator.NOT_LIKE -> "notLike($attribute)"
+ }
+
+ is Query.RawSelection -> "rawSelection"
+ }
+}
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt
index bb20f65..7755a03 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import com.lazygeniouz.dfc.file.DocumentFileCompat
+import com.lazygeniouz.dfc.file.Query
import com.lazygeniouz.dfc.logger.ErrorLogger.logError
import java.io.File
@@ -112,6 +113,18 @@ internal class RawDocumentFileCompat(context: Context, var file: File) :
return listFiles()
}
+ /**
+ * Raw file queries are not backed by a DocumentsProvider and therefore don't support
+ * provider-level query arguments.
+ *
+ * @throws UnsupportedOperationException
+ */
+ override fun listFiles(vararg queries: Query): List {
+ throw UnsupportedOperationException(
+ "Queries are only supported for DocumentsProvider-backed tree URIs."
+ )
+ }
+
/**
* This will return the children count in the directory.
*
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt
index 69fa5d5..541e4e4 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import com.lazygeniouz.dfc.file.DocumentFileCompat
+import com.lazygeniouz.dfc.file.Query
import com.lazygeniouz.dfc.resolver.ResolverCompat
/**
@@ -59,6 +60,15 @@ internal class SingleDocumentFileCompat(
return listFiles()
}
+ /**
+ * Single document Uris don't have children, so queries are not applicable.
+ *
+ * @throws UnsupportedOperationException
+ */
+ override fun listFiles(vararg queries: Query): List {
+ throw UnsupportedOperationException()
+ }
+
/**
* No [listFiles], no children, no count.
*
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt
index b635b62..3d39e4d 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt
@@ -5,6 +5,7 @@ import android.net.Uri
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document.MIME_TYPE_DIR
import com.lazygeniouz.dfc.file.DocumentFileCompat
+import com.lazygeniouz.dfc.file.Query
import com.lazygeniouz.dfc.resolver.ResolverCompat
/**
@@ -61,6 +62,10 @@ internal class TreeDocumentFileCompat(
return fileController.listFiles()
}
+ override fun listFiles(vararg queries: Query): List {
+ return fileController.listFiles(*queries)
+ }
+
/**
* This will return the children count in the directory.
*
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt b/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt
index dfda655..ee46f31 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt
@@ -13,4 +13,11 @@ object ErrorLogger {
internal fun logError(message: String, throwable: Throwable?) {
Log.e("DocumentFileCompat", "$message: ${throwable?.message}")
}
-}
\ No newline at end of file
+
+ /**
+ * Log warning to logcat for non-fatal behavior differences.
+ */
+ internal fun logWarning(message: String) {
+ Log.w("DocumentFileCompat", message)
+ }
+}
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt
index 74c1afe..66a3282 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt
@@ -4,9 +4,15 @@ import android.content.ContentResolver
import android.content.Context
import android.database.Cursor
import android.net.Uri
+import android.os.Build
+import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import com.lazygeniouz.dfc.file.DocumentFileCompat
+import com.lazygeniouz.dfc.file.Query
+import com.lazygeniouz.dfc.file.QueryDefaults
+import com.lazygeniouz.dfc.file.describe
+import com.lazygeniouz.dfc.file.toSelectionPart
import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat
import com.lazygeniouz.dfc.logger.ErrorLogger
@@ -120,17 +126,196 @@ internal object ResolverCompat {
context: Context,
file: DocumentFileCompat,
projection: Array = fullProjection,
+ ): List {
+ return queryFiles(context, file, Query.select(*projection))
+ }
+
+ /**
+ * Queries the ContentResolver using provider-level query arguments and builds
+ * a list of [DocumentFileCompat].
+ */
+ internal fun queryFiles(
+ context: Context,
+ file: DocumentFileCompat,
+ vararg queries: Query,
): List {
val uri = file.uri
val childrenUri = createChildrenUri(uri)
- val listOfDocuments = arrayListOf()
+ val projectionQueries = queries.filterIsInstance()
+ val projection = LinkedHashSet().apply {
+ if (projectionQueries.isEmpty()) {
+ addAll(QueryDefaults.DEFAULT_PROJECTION)
+ } else {
+ projectionQueries.forEach { addAll(it.columns) }
+ }
+
+ // Ensure document id is always available so we can build child document Uris.
+ add(Document.COLUMN_DOCUMENT_ID)
+ }.toTypedArray()
- // ensure `Document.COLUMN_DOCUMENT_ID` is always included
- val finalProjection = if (Document.COLUMN_DOCUMENT_ID !in projection) {
- arrayOf(Document.COLUMN_DOCUMENT_ID, *projection)
- } else projection
+ val ignoredQueries = mutableListOf()
+ val selectionParts = mutableListOf()
+ val selectionArgs = mutableListOf()
+ val sortClauses = mutableListOf()
+
+ var limit: Int? = null
+ var offset: Int? = null
+
+ queries.forEach { query ->
+ when (query) {
+ is Query.Projection -> Unit
+ is Query.Sort -> {
+ sortClauses += "${query.column} ${if (query.descending) "DESC" else "ASC"}"
+ }
- val cursor = getCursor(context, childrenUri, finalProjection) ?: return emptyList()
+ is Query.Limit -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) limit = query.count
+ else ignoredQueries += query
+ }
+
+ is Query.Offset -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) offset = query.count
+ else ignoredQueries += query
+ }
+
+ is Query.Selection -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val compiledSelection = query.toSelectionPart()
+ selectionParts += compiledSelection.selection
+ selectionArgs += compiledSelection.args
+ } else {
+ ignoredQueries += query
+ }
+ }
+
+ is Query.RawSelection -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ selectionParts += "(${query.selection})"
+ selectionArgs += query.args
+ } else {
+ ignoredQueries += query
+ }
+ }
+ }
+ }
+
+ logIgnoredQueriesIfNeeded(ignoredQueries)
+
+ val sortOrder = sortClauses.takeIf { it.isNotEmpty() }?.joinToString(", ")
+ val selection = selectionParts.takeIf { it.isNotEmpty() }?.joinToString(" AND ")
+ val queryArgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
+ (limit != null || offset != null || selection != null || sortOrder != null)
+ ) {
+ Bundle().apply {
+ if (selection != null) {
+ putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection)
+ putStringArray(
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
+ selectionArgs.toTypedArray(),
+ )
+ }
+
+ if (sortOrder != null) {
+ putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, sortOrder)
+ }
+
+ if (limit != null) putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
+ if (offset != null) putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
+ }
+ } else {
+ null
+ }
+
+ val cursor = getCursor(
+ context,
+ childrenUri,
+ projection,
+ queryArgs,
+ selection,
+ selectionArgs.takeIf { it.isNotEmpty() }?.toTypedArray(),
+ sortOrder,
+ ) ?: return emptyList()
+
+ return buildDocumentList(context, file, uri, cursor)
+ }
+
+ /**
+ * Get [Cursor] from [ContentResolver.query] with given [projection] on a given [uri].
+ */
+ internal fun getCursor(context: Context, uri: Uri, projection: Array): Cursor? {
+ return try {
+ context.contentResolver.query(
+ uri, projection, null, null, null
+ )
+ } catch (exception: Exception) {
+ /**
+ * This exception can occur in scenarios such as -
+ *
+ * - The Uri became invalid due to external changes (e.g., permissions revoked, storage unmounted, etc.).
+ * - The file or directory represented by this Uri was probably deleted or became `inaccessible` after the Uri was obtained but before this operation was performed.
+ */
+ ErrorLogger.logError("Exception while building the Cursor", exception)
+ null
+ }
+ }
+
+ /**
+ * Get [Cursor] from [ContentResolver.query] using compiled provider query arguments.
+ */
+ internal fun getCursor(
+ context: Context,
+ uri: Uri,
+ projection: Array,
+ queryArgs: Bundle?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor? {
+ return try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (queryArgs != null) {
+ context.contentResolver.query(uri, projection, queryArgs, null)
+ } else {
+ context.contentResolver.query(uri, projection, null, null, null)
+ }
+ } else {
+ // Pre-O child document queries only have the legacy selection/sortOrder path.
+ // Our compiler ignores unsupported filters there, but still preserves sorting.
+ context.contentResolver.query(
+ uri,
+ projection,
+ selection,
+ selectionArgs,
+ sortOrder,
+ )
+ }
+ } catch (exception: Exception) {
+ ErrorLogger.logError("Exception while building the Cursor", exception)
+ null
+ }
+ }
+
+ private fun logIgnoredQueriesIfNeeded(ignoredQueries: List) {
+ if (ignoredQueries.isEmpty()) return
+
+ ErrorLogger.logWarning(
+ buildString {
+ append("Ignored unsupported queries on API ")
+ append(Build.VERSION.SDK_INT)
+ append(": ")
+ append(ignoredQueries.joinToString { it.describe() })
+ append(". SAF child-document filtering, limit, and offset require API 26+.")
+ }
+ )
+ }
+
+ private fun buildDocumentList(
+ context: Context,
+ file: DocumentFileCompat,
+ treeUri: Uri,
+ cursor: Cursor,
+ ): List {
+ val listOfDocuments = arrayListOf()
cursor.use {
val itemCount = cursor.count
@@ -144,9 +329,7 @@ internal object ResolverCompat {
*/
if (itemCount > 10) listOfDocuments.ensureCapacity(itemCount)
- // Resolve column indices dynamically
val idIndex = cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)
-
val nameIndex = cursor.getColumnIndex(Document.COLUMN_DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(Document.COLUMN_SIZE)
val modifiedIndex = cursor.getColumnIndex(Document.COLUMN_LAST_MODIFIED)
@@ -155,7 +338,7 @@ internal object ResolverCompat {
while (cursor.moveToNext()) {
val documentId = cursor.getString(idIndex) ?: continue
- val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
+ val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
val documentName = getStringOrDefault(cursor, nameIndex)
val documentSize = getLongOrDefault(cursor, sizeIndex)
@@ -182,30 +365,10 @@ internal object ResolverCompat {
return listOfDocuments
}
- /**
- * Get [Cursor] from [ContentResolver.query] with given [projection] on a given [uri].
- */
- fun getCursor(context: Context, uri: Uri, projection: Array): Cursor? {
- return try {
- context.contentResolver.query(
- uri, projection, null, null, null
- )
- } catch (exception: Exception) {
- /**
- * This exception can occur in scenarios such as -
- *
- * - The Uri became invalid due to external changes (e.g., permissions revoked, storage unmounted, etc.).
- * - The file or directory represented by this Uri was probably deleted or became `inaccessible` after the Uri was obtained but before this operation was performed.
- */
- ErrorLogger.logError("Exception while building the Cursor", exception)
- null
- }
- }
-
// Make children uri for query.
private fun createChildrenUri(uri: Uri): Uri {
return DocumentsContract.buildChildDocumentsUriUsingTree(
uri, DocumentsContract.getDocumentId(uri)
)
}
-}
\ No newline at end of file
+}
diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt
new file mode 100644
index 0000000..38b3aa9
--- /dev/null
+++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt
@@ -0,0 +1,286 @@
+package com.lazygeniouz.dfc.file
+
+import android.provider.DocumentsContract.Document
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class QueryTest {
+
+ @Test
+ fun `nameContains escapes sql like wildcards`() {
+ val query = Query.nameContains("100%_done\\ready") as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals(
+ "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')",
+ selectionPart.selection,
+ )
+ assertEquals(listOf("%100\\%\\_done\\\\ready%"), selectionPart.args)
+ }
+
+ @Test
+ fun `in with null adds is null clause`() {
+ val query = Query.`in`(Document.COLUMN_MIME_TYPE, null, "image/png") as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals(
+ "((${Document.COLUMN_MIME_TYPE} IN (?)) OR (${Document.COLUMN_MIME_TYPE} IS NULL))",
+ selectionPart.selection,
+ )
+ assertEquals(listOf("image/png"), selectionPart.args)
+ }
+
+ @Test
+ fun `notIn with null adds is not null clause`() {
+ val query = Query.notIn(Document.COLUMN_MIME_TYPE, null, "image/png") as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals(
+ "((${Document.COLUMN_MIME_TYPE} NOT IN (?)) AND (${Document.COLUMN_MIME_TYPE} IS NOT NULL))",
+ selectionPart.selection,
+ )
+ assertEquals(listOf("image/png"), selectionPart.args)
+ }
+
+ @Test
+ fun `equal with null becomes isNull selection`() {
+ val query = Query.equal(Document.COLUMN_MIME_TYPE, null) as Query.Selection
+
+ assertEquals(Query.Operator.IS_NULL, query.operator)
+ assertTrue(query.values.isEmpty())
+ }
+
+ @Test
+ fun `notEqual with null becomes isNotNull selection`() {
+ val query = Query.notEqual(Document.COLUMN_MIME_TYPE, null) as Query.Selection
+
+ assertEquals(Query.Operator.IS_NOT_NULL, query.operator)
+ assertTrue(query.values.isEmpty())
+ }
+
+ @Test
+ fun `equal compiles to equality selection`() {
+ val query = Query.equal(Document.COLUMN_DISPLAY_NAME, "report.pdf") as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals("(${Document.COLUMN_DISPLAY_NAME} = ?)", selectionPart.selection)
+ assertEquals(listOf("report.pdf"), selectionPart.args)
+ }
+
+ @Test
+ fun `notEqual compiles to inequality selection`() {
+ val query = Query.notEqual(Document.COLUMN_DISPLAY_NAME, "report.pdf") as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals("(${Document.COLUMN_DISPLAY_NAME} != ?)", selectionPart.selection)
+ assertEquals(listOf("report.pdf"), selectionPart.args)
+ }
+
+ @Test
+ fun `greaterThan compiles correctly`() {
+ val query = Query.greaterThan(Document.COLUMN_SIZE, 1024L) as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals("(${Document.COLUMN_SIZE} > ?)", selectionPart.selection)
+ assertEquals(listOf("1024"), selectionPart.args)
+ }
+
+ @Test
+ fun `greaterThanOrEqual compiles correctly`() {
+ val query = Query.greaterThanOrEqual(Document.COLUMN_SIZE, 1024L) as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals("(${Document.COLUMN_SIZE} >= ?)", selectionPart.selection)
+ assertEquals(listOf("1024"), selectionPart.args)
+ }
+
+ @Test
+ fun `lessThan compiles correctly`() {
+ val query = Query.lessThan(Document.COLUMN_SIZE, 1024L) as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals("(${Document.COLUMN_SIZE} < ?)", selectionPart.selection)
+ assertEquals(listOf("1024"), selectionPart.args)
+ }
+
+ @Test
+ fun `lessThanOrEqual compiles correctly`() {
+ val query = Query.lessThanOrEqual(Document.COLUMN_SIZE, 1024L) as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals("(${Document.COLUMN_SIZE} <= ?)", selectionPart.selection)
+ assertEquals(listOf("1024"), selectionPart.args)
+ }
+
+ @Test
+ fun `between compiles correctly`() {
+ val query = Query.between(Document.COLUMN_SIZE, 10L, 20L) as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals("(${Document.COLUMN_SIZE} BETWEEN ? AND ?)", selectionPart.selection)
+ assertEquals(listOf("10", "20"), selectionPart.args)
+ }
+
+ @Test
+ fun `isNull compiles correctly`() {
+ val query = Query.isNull(Document.COLUMN_MIME_TYPE) as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals("(${Document.COLUMN_MIME_TYPE} IS NULL)", selectionPart.selection)
+ assertTrue(selectionPart.args.isEmpty())
+ }
+
+ @Test
+ fun `isNotNull compiles correctly`() {
+ val query = Query.isNotNull(Document.COLUMN_MIME_TYPE) as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.selection)
+ assertTrue(selectionPart.args.isEmpty())
+ }
+
+ @Test
+ fun `like compiles correctly`() {
+ val query = Query.like(Document.COLUMN_DISPLAY_NAME, "report%") as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals(
+ "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')",
+ selectionPart.selection,
+ )
+ assertEquals(listOf("report%"), selectionPart.args)
+ }
+
+ @Test
+ fun `notLike compiles correctly`() {
+ val query = Query.notLike(Document.COLUMN_DISPLAY_NAME, "report%") as Query.Selection
+
+ val selectionPart = query.toSelectionPart()
+
+ assertEquals(
+ "(${Document.COLUMN_DISPLAY_NAME} NOT LIKE ? ESCAPE '\\')",
+ selectionPart.selection,
+ )
+ assertEquals(listOf("report%"), selectionPart.args)
+ }
+
+ @Test
+ fun `filesOnly maps to mime type not equal directory`() {
+ val query = Query.filesOnly() as Query.Selection
+
+ assertEquals(Document.COLUMN_MIME_TYPE, query.attribute)
+ assertEquals(Query.Operator.NOT_EQUAL, query.operator)
+ assertEquals(listOf(Document.MIME_TYPE_DIR), query.values)
+ }
+
+ @Test
+ fun `directoriesOnly maps to mime type equal directory`() {
+ val query = Query.directoriesOnly() as Query.Selection
+
+ assertEquals(Document.COLUMN_MIME_TYPE, query.attribute)
+ assertEquals(Query.Operator.EQUAL, query.operator)
+ assertEquals(listOf(Document.MIME_TYPE_DIR), query.values)
+ }
+
+ @Test
+ fun `mimeTypeIn maps to in selection`() {
+ val query = Query.mimeTypeIn("image/png", "image/jpeg") as Query.Selection
+
+ assertEquals(Document.COLUMN_MIME_TYPE, query.attribute)
+ assertEquals(Query.Operator.IN, query.operator)
+ assertEquals(listOf("image/png", "image/jpeg"), query.values)
+ }
+
+ @Test
+ fun `select returns projection query`() {
+ val query = Query.select(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE)
+ as Query.Projection
+
+ assertEquals(
+ listOf(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE),
+ query.columns,
+ )
+ }
+
+ @Test
+ fun `projection delegates to select`() {
+ @Suppress("DEPRECATION")
+ val query = Query.projection(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE)
+ as Query.Projection
+
+ assertEquals(
+ listOf(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE),
+ query.columns,
+ )
+ }
+
+ @Test
+ fun `orderByAsc returns ascending sort query`() {
+ val query = Query.orderByAsc(Document.COLUMN_DISPLAY_NAME) as Query.Sort
+
+ assertEquals(Document.COLUMN_DISPLAY_NAME, query.column)
+ assertFalse(query.descending)
+ }
+
+ @Test
+ fun `orderByDesc returns descending sort query`() {
+ val query = Query.orderByDesc(Document.COLUMN_DISPLAY_NAME) as Query.Sort
+
+ assertEquals(Document.COLUMN_DISPLAY_NAME, query.column)
+ assertTrue(query.descending)
+ }
+
+ @Test
+ fun `limit returns limit query`() {
+ val query = Query.limit(25) as Query.Limit
+
+ assertEquals(25, query.count)
+ }
+
+ @Test
+ fun `offset returns offset query`() {
+ val query = Query.offset(10) as Query.Offset
+
+ assertEquals(10, query.count)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `limit rejects negative values`() {
+ Query.limit(-1)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `offset rejects negative values`() {
+ Query.offset(-1)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `in rejects empty values`() {
+ Query.`in`(Document.COLUMN_DISPLAY_NAME)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `notIn rejects empty values`() {
+ Query.notIn(Document.COLUMN_DISPLAY_NAME)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `rawSelection rejects blank selection`() {
+ Query.rawSelection(" ")
+ }
+}