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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Below is a brief list of the available commands and their function:
| **firestore:export** | Export all collections from Firestore to a single compact importable JSON file (`firestore_export.json`). Supports subcollection handling and collection exclusions. |
| **firestore:import** | Import data to Firestore from JSON file. Supports batch operations and merge functionality. |
| **firestore:list** | List all collections and their basic information from the current project's Firestore database. |
| **firestore:query** | Query a collection or fetch a specific document. Supports advanced filtering, ordering, and field-specific queries. |
| **firestore:query** | Query a collection or fetch a specific document. Supports subcollection paths (e.g., `users user1 orders`), collection group queries (`--collection-group`), advanced filtering, ordering, and field-specific queries. |

### Realtime Database Commands

Expand All @@ -78,7 +78,7 @@ Below is a brief list of the available commands and their function:
| **rtdb:export** | Export all data from Realtime Database to a single compact importable JSON file (`rtdb_export.json`). Supports exclusion options and top-level-only export. |
| **rtdb:import** | Import data to Realtime Database from JSON file. Supports batch operations and merge functionality. |
| **rtdb:list** | List all top-level nodes and their basic information from the current project's Realtime Database. |
| **rtdb:query** | Query a specific path in Realtime Database. Supports filtering, ordering, and JSON output with file saving. |
| **rtdb:query** | Query a specific path in Realtime Database. Supports deep nested paths (e.g., `/root/a/b/c`), nested field filters (`field/subfield,==,value`), ordering, and JSON output with file saving. |

### Remote Config Commands

Expand Down Expand Up @@ -127,12 +127,29 @@ firebase-tools-cli rtdb:import ./rtdb-backup/rtdb_export.json --database-url htt
firebase-tools-cli firestore:query users --where "age,>=,18" --limit 10
firebase-tools-cli firestore:query users --order-by "name,asc"

# Query Firestore subcollections (nested paths)
firebase-tools-cli firestore:query users user1 orders
firebase-tools-cli firestore:query users user1 orders --where "status,==,shipped" --limit 5
firebase-tools-cli firestore:query users user1 orders order1

# Query Firestore collection groups (all subcollections with the same name)
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Firestore collectionGroup() queries include top-level collections with the given collection ID, not only subcollections. This example heading says "all subcollections with the same name"; consider rewording to match Firestore semantics and avoid surprising results.

Suggested change
# Query Firestore collection groups (all subcollections with the same name)
# Query Firestore collection groups (all collections and subcollections with the same collection ID)

Copilot uses AI. Check for mistakes.
firebase-tools-cli firestore:query orders --collection-group
firebase-tools-cli firestore:query orders --collection-group --where "status,==,shipped" --limit 20

# Query specific document fields
firebase-tools-cli firestore:query users user1 --field profile.settings

# Query Realtime Database with filtering
firebase-tools-cli rtdb:query users --where "age,>=,18" --limit 10 --database-url https://my-project-rtdb.firebaseio.com/
firebase-tools-cli rtdb:query posts --order-by "timestamp,desc" --json --output results.json

# Query Realtime Database at a nested path
firebase-tools-cli rtdb:query users/user4/active --database-url https://my-project-rtdb.firebaseio.com/
firebase-tools-cli rtdb:query users user4 active --database-url https://my-project-rtdb.firebaseio.com/

# Query Realtime Database with nested field filters
firebase-tools-cli rtdb:query users --where "workouts/appVersion,==,2.3.1" --database-url https://my-project-rtdb.firebaseio.com/
firebase-tools-cli rtdb:query workouts --where "settings/difficulty,>=,3" --order-by "settings/duration,desc" --database-url https://my-project-rtdb.firebaseio.com/
```

### Remote Config Management
Expand Down
31 changes: 30 additions & 1 deletion llm.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ A comprehensive command-line interface for Firebase Firestore database operation
- `-m, --merge` - Merge documents instead of overwriting
- `-e, --exclude <collections...>` - Exclude specific collections

- `firebase-tools-cli query <collection>` - Query a specific collection
- `firebase-tools-cli query <collection>` - Query a specific collection or document
- `-w, --where <field,operator,value>` - Where clause (e.g., "age,>=,18")
- `-l, --limit <number>` - Limit number of results
- `-o, --order-by <field,direction>` - Order by field (e.g., "name,asc")
- `-f, --field <fieldPath>` - Show specific field from document (document queries only)
- `-g, --collection-group` - Query as a collection group across all matching subcollections
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collection group queries in Firestore include top-level collections with the given collection ID, not only subcollections. This doc line says "across all matching subcollections"; consider rewording to "across all collections/subcollections with this ID" (or similar) to match Firestore semantics.

Suggested change
- `-g, --collection-group` - Query as a collection group across all matching subcollections
- `-g, --collection-group` - Query as a collection group across all collections/subcollections with this ID

Copilot uses AI. Check for mistakes.
- `--json` - Output results as JSON
- `--output <file>` - Save JSON output to file

Expand Down Expand Up @@ -146,6 +148,33 @@ Supported Firestore query operators:
- `in` - Value is in array
- `not-in` - Value is not in array

## Nested Path Queries

### Firestore Subcollections
Use space-separated path segments to target subcollections. Odd segments = collection query, even segments = document query:
- `firestore:query users` - Query top-level `users` collection
- `firestore:query users user1 orders` - Query `orders` subcollection under `users/user1`
- `firestore:query users user1 orders order42` - Fetch specific document `users/user1/orders/order42`
- `firestore:query users user1 orders --where "status,==,shipped"` - Filter subcollection

### Firestore Collection Group Queries
Use `--collection-group` (`-g`) to query all subcollections with a given name across the entire database:
- `firestore:query orders --collection-group` - Query all `orders` subcollections
Comment on lines +161 to +162
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Firestore collection group queries (collectionGroup) include top-level collections with the same collection ID, not just subcollections. This section repeatedly says "all subcollections"; reword to reflect the actual scope to avoid confusing users.

Suggested change
Use `--collection-group` (`-g`) to query all subcollections with a given name across the entire database:
- `firestore:query orders --collection-group` - Query all `orders` subcollections
Use `--collection-group` (`-g`) to query all collections (top-level collections and subcollections) with a given collection ID across the entire database:
- `firestore:query orders --collection-group` - Query all `orders` collections and subcollections

Copilot uses AI. Check for mistakes.
- `firestore:query orders --collection-group --where "status,==,shipped" --limit 20`
- **Filtered/ordered queries may require a Firestore composite index** - follow the error link to create it in Firebase Console if needed.

### RTDB Nested Paths
Specify deep paths using either slash-separated or space-separated segments:
- `rtdb:query users/user4/active` - Query at path `/users/user4/active`
- `rtdb:query users user4 active` - Same as above (space-separated)
- `rtdb:query root/a/b/c` - Query at arbitrary depth

### RTDB Nested Field Filters
Use `/`-separated field paths in `--where` and `--order-by` to filter on nested fields:
- `rtdb:query users --where "workouts/appVersion,==,2.3.1"` - Filter on nested field
- `rtdb:query workouts --order-by "settings/duration,desc"` - Sort by nested field
- `rtdb:query workouts --where "settings/difficulty,>=,3" --order-by "settings/duration,asc"` - Combine both

## Configuration Files
- `~/.firebase-tools-cli/config.json` - Default project and settings
- `~/.firebase-tools-cli/credentials.json` - OAuth credentials (auto-managed)
Expand Down
251 changes: 218 additions & 33 deletions src/actions/firestore/firestore-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type QueryCommandOptionsType = {
field?: string;
json?: boolean;
output?: string;
collectionGroup?: boolean;
};

type QueryDocumentSnapshotType = admin.firestore.QueryDocumentSnapshot<
Expand Down Expand Up @@ -125,6 +126,24 @@ export async function queryCollection(

const db = admin.firestore();

// Collection group query: queries all subcollections with this name across the entire database
if (options.collectionGroup) {
if (collectionPath.length !== 1) {
throw new Error(
'--collection-group requires exactly one collection ID (e.g., firestore:query orders --collection-group)'
);
}
if (options.field) {
console.log(
chalk.yellow(
'⚠️ --field option is only available for document queries, not collection group queries'
)
);
}
await queryCollectionGroupData(db, collectionPath[0], options);
return;
}

// Check if we're querying a document or a collection
const isDocumentQuery = collectionPath.length % 2 === 0;

Expand Down Expand Up @@ -211,7 +230,7 @@ async function queryDocument(
const [filterField, operator, value] = options.where.split(',');
if (!filterField || !operator || value === undefined) {
throw new Error(
'Where clause must be in format "field,operator,value" (e.g., "page_type,==,question-page_v1")'
'Where clause must be in format "field,operator,value" (e.g., "page_type,==,question_1")'
);
}

Expand Down Expand Up @@ -561,6 +580,51 @@ async function queryDocument(
}
}

// Helper function to parse a where-clause value string to the appropriate JS type
function parseWhereValue(raw: string): string | number | boolean | null {
if (raw === 'true') return true;
if (raw === 'false') return false;
if (raw === 'null') return null;
if (!isNaN(Number(raw))) return Number(raw);
Comment on lines +585 to +588
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseWhereValue will treat an empty string as the number 0 because Number('') is 0, and it will also coerce long numeric strings into JS numbers (risking precision loss). Consider guarding against empty strings and using a safer numeric parse rule (e.g., only convert when the raw value is a non-empty numeric literal and within a safe/expected length), consistent with the array-field parsing logic earlier in this file.

Suggested change
if (raw === 'true') return true;
if (raw === 'false') return false;
if (raw === 'null') return null;
if (!isNaN(Number(raw))) return Number(raw);
const trimmed = raw.trim();
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
if (trimmed === 'null') return null;
// Only treat as a number if it's a non-empty, simple numeric literal and not excessively long
if (trimmed !== '') {
const numericLiteralPattern = /^-?\d+(\.\d+)?$/;
if (numericLiteralPattern.test(trimmed)) {
// Limit the total count of digits to reduce precision-loss risk for very large numbers
const digitCount = trimmed.replace('-', '').replace('.', '').length;
const MAX_SAFE_DIGITS = 15;
if (digitCount <= MAX_SAFE_DIGITS) {
const num = Number(trimmed);
if (!Number.isNaN(num)) {
return num;
}
}
}
}

Copilot uses AI. Check for mistakes.
return raw;
}

// Helper function to apply where/limit/orderBy constraints to a Firestore query
function applyCollectionQueryConstraints(
query: admin.firestore.Query<
admin.firestore.DocumentData,
admin.firestore.DocumentData
>,
options: QueryCommandOptionsType
): admin.firestore.Query<
admin.firestore.DocumentData,
admin.firestore.DocumentData
> {
if (options.where) {
const [field, operator, value] = options.where.split(',');
const operatorType = operator.trim() as admin.firestore.WhereFilterOp;
const parsedValue = parseWhereValue(value.trim());
query = query.where(field.trim(), operatorType, parsedValue);
console.log(
chalk.gray(` └── Filter: ${field} ${operator} ${parsedValue}`)
);
}
Comment on lines +603 to +611
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyCollectionQueryConstraints assumes options.where always contains exactly three comma-separated parts. If the user passes a malformed value (e.g. missing operator/value or trailing comma), operator.trim() / value.trim() will throw a TypeError with an unhelpful message. Add explicit validation (similar to the earlier array-field --where validation) and raise a clear Error describing the expected format.

Copilot uses AI. Check for mistakes.

if (options.limit) {
query = query.limit(parseInt(options.limit));
console.log(chalk.gray(` └── Limit: ${options.limit}`));
}

if (options.orderBy) {
const [field, direction] = options.orderBy.split(',');
const directionType = direction?.trim() as admin.firestore.OrderByDirection;
query = query.orderBy(field.trim(), directionType || 'asc');
console.log(chalk.gray(` └── Order: ${field} ${direction || 'asc'}`));
}

return query;
}

async function queryCollectionData(
db: admin.firestore.Firestore,
collectionPath: string[],
Expand Down Expand Up @@ -594,38 +658,7 @@ async function queryCollectionData(
>;
}

// Apply where clause
if (options.where) {
const [field, operator, value] = options.where.split(',');
const operatorType = operator.trim() as admin.firestore.WhereFilterOp;

// Parse value to appropriate type
let parsedValue: any = value.trim();
if (parsedValue === 'true') parsedValue = true;
else if (parsedValue === 'false') parsedValue = false;
else if (parsedValue === 'null') parsedValue = null;
else if (!isNaN(Number(parsedValue))) parsedValue = Number(parsedValue);

query = query.where(field.trim(), operatorType, parsedValue);
console.log(
chalk.gray(` └── Filter: ${field} ${operator} ${parsedValue}`)
);
}

// Apply limit
if (options.limit) {
query = query.limit(parseInt(options.limit));
console.log(chalk.gray(` └── Limit: ${options.limit}`));
}

// Apply ordering
if (options.orderBy) {
const [field, direction] = options.orderBy.split(',');
const directionType = direction?.trim() as admin.firestore.OrderByDirection;

query = query.orderBy(field.trim(), directionType || 'asc');
console.log(chalk.gray(` └── Order: ${field} ${direction || 'asc'}`));
}
query = applyCollectionQueryConstraints(query, options);

const snapshot = await query.get();

Expand Down Expand Up @@ -744,3 +777,155 @@ async function queryCollectionData(
console.log(chalk.gray(` └── Project: ${admin.app().options.projectId}`));
}
}

async function queryCollectionGroupData(
db: admin.firestore.Firestore,
collectionId: string,
options: QueryCommandOptionsType
) {
console.log(
chalk.cyan(
`🌐 Collection group query: searching all "${collectionId}" subcollections across the database\n`
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Firestore collectionGroup() queries match all collections with the given collection ID, including top-level collections (not only subcollections). The console message should avoid saying "subcollections" to prevent misleading users about what will be queried.

Suggested change
`🌐 Collection group query: searching all "${collectionId}" subcollections across the database\n`
`🌐 Collection group query: searching all "${collectionId}" collections across the database\n`

Copilot uses AI. Check for mistakes.
)
);

// Only filtered/ordered collection group queries require a Firestore index
if (options.where || options.orderBy) {
console.log(
chalk.gray(
' ⚠️ Note: Filtered/ordered collection group queries may require a Firestore composite index.'
)
);
console.log(
chalk.gray(
' If this query fails with an index error, follow the link in the error'
)
);
console.log(
chalk.gray(
' message to create the required index in Firebase Console.\n'
)
);
}
let query: admin.firestore.Query<
admin.firestore.DocumentData,
admin.firestore.DocumentData
> = db.collectionGroup(collectionId);

query = applyCollectionQueryConstraints(query, options);

const snapshot = await query.get();

if (snapshot.size === 0) {
console.log(chalk.yellow('⚠️ No documents found matching the query'));
return;
}

console.log(chalk.green(`✅ Found ${snapshot.size} document(s)\n`));

// Collect results for JSON output
const results: any[] = [];

snapshot.forEach((doc: QueryDocumentSnapshotType) => {
const docData = {
id: doc.id,
path: doc.ref.path,
data: doc.data(),
createTime: doc.createTime,
updateTime: doc.updateTime,
};

results.push(docData);

// Only show console output if not JSON mode
if (!options.json) {
console.log(chalk.white(`📁 ${doc.id}`));
console.log(chalk.gray(` └── Path: ${doc.ref.path}`));

// Show field values in detail
const data = doc.data();
const entries = Object.entries(data);

// Show up to 10 fields to prevent overwhelming output
const maxDisplay = 10;
const displayEntries = entries.slice(0, maxDisplay);

for (const [key, value] of displayEntries) {
let displayValue: string;

if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
displayValue = `[Array with ${value.length} items]`;
} else {
displayValue = `[Object with ${Object.keys(value).length} keys]`;
}
} else if (typeof value === 'string' && value.length > 50) {
displayValue = `${value.substring(0, 50)}...`;
} else {
displayValue = String(value);
}

console.log(chalk.gray(` └── ${key}: ${displayValue}`));
}

if (entries.length > maxDisplay) {
console.log(
chalk.gray(
` └── ... and ${entries.length - maxDisplay} more fields`
)
);
}

console.log();
}
});

// Prepare output data
const outputData = {
type: 'collection_group',
collectionId: collectionId,
query: {
...(options.where && { where: options.where }),
...(options.limit && { limit: parseInt(options.limit) }),
...(options.orderBy && { orderBy: options.orderBy }),
},
summary: {
totalDocuments: snapshot.size,
},
results: results,
timestamp: new Date().toISOString(),
};

// Handle file output
if (options.output) {
const outputFile = options.output.endsWith('.json')
? options.output
: `${options.output}.json`;

fs.writeFileSync(outputFile, JSON.stringify(outputData, null, 2));
console.log(chalk.green(`📄 Query results saved to: ${outputFile}`));

const fileSize = (fs.statSync(outputFile).size / 1024).toFixed(2);
console.log(chalk.gray(` └── File size: ${fileSize} KB`));
}

// Handle JSON console output
if (options.json) {
console.log(JSON.stringify(outputData, null, 2));
} else if (!options.output) {
// Show detailed summary only if not in JSON mode and no file output
console.log(chalk.blue('📊 Collection Group Query Summary:'));
console.log(chalk.gray(` └── Collection ID: ${collectionId}`));
console.log(chalk.gray(` └── Total documents: ${snapshot.size}`));
if (options.where) {
console.log(chalk.gray(` └── Where: ${options.where}`));
}
if (options.orderBy) {
console.log(chalk.gray(` └── Order by: ${options.orderBy}`));
}
if (options.limit) {
console.log(chalk.gray(` └── Limit: ${options.limit}`));
}
console.log(chalk.gray(` └── Project: ${admin.app().options.projectId}`));
}
}
Loading