sqlite-export-for-ynab 2.1.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlite_export_for_ynab-2.1.0/sqlite_export_for_ynab.egg-info → sqlite_export_for_ynab-2.2.0}/PKG-INFO +149 -36
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/README.md +148 -35
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/setup.cfg +1 -1
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/_main.py +92 -38
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0/sqlite_export_for_ynab.egg-info}/PKG-INFO +149 -36
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/testing/fixtures.py +3 -4
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/tests/_main_test.py +86 -3
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/LICENSE +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/pyproject.toml +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/setup.py +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__init__.py +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__main__.py +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/__init__.py +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/create-relations.sql +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/drop-relations.sql +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/py.typed +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/SOURCES.txt +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/dependency_links.txt +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/entry_points.txt +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/requires.txt +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab.egg-info/top_level.txt +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/testing/__init__.py +0 -0
- {sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlite_export_for_ynab
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: SQLite Export for YNAB - Export YNAB Data to SQLite
|
|
5
5
|
Home-page: https://github.com/mxr/sqlite-export-for-ynab
|
|
6
6
|
Author: Max R
|
|
@@ -51,6 +51,7 @@ $ sqlite-export-for-ynab
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
Running it again will pull only data that changed since the last pull (this is done with [Delta Requests](https://api.ynab.com/#deltas)). If you want to wipe the DB and pull all data again use the `--full-refresh` flag.
|
|
54
|
+
Pass `--quiet` to suppress all CLI output, including progress bars.
|
|
54
55
|
|
|
55
56
|
<a id="db-path"></a>You can specify the DB path with the following options
|
|
56
57
|
1. The `--db` flag.
|
|
@@ -345,44 +346,80 @@ To compare assigned category values to a given account's balance:
|
|
|
345
346
|
-- -cmd ".parameter set @account_name_like %Savings%" \
|
|
346
347
|
-- -cmd ".parameter set @include_category_groups 'Home,Food'" \
|
|
347
348
|
-- < query.sql
|
|
349
|
+
CREATE TEMP TABLE excess_query_results AS
|
|
348
350
|
WITH params AS (
|
|
349
351
|
SELECT
|
|
350
352
|
TRIM(COALESCE(@account_name_like, '')) AS account_name_like
|
|
353
|
+
, TRIM(COALESCE(@plan_id, '')) AS plan_id
|
|
351
354
|
, TRIM(COALESCE(@include_category_groups, ''))
|
|
352
355
|
AS include_category_groups
|
|
353
356
|
, TRIM(COALESCE(@exclude_category_groups, ''))
|
|
354
357
|
AS exclude_category_groups
|
|
355
358
|
)
|
|
356
359
|
|
|
357
|
-
,
|
|
358
|
-
SELECT
|
|
359
|
-
|
|
360
|
-
|
|
360
|
+
, scoped_plans AS (
|
|
361
|
+
SELECT
|
|
362
|
+
p.id
|
|
363
|
+
, p.name
|
|
364
|
+
FROM plans AS p
|
|
365
|
+
CROSS JOIN params AS prm
|
|
366
|
+
WHERE prm.plan_id = '' OR p.id = prm.plan_id
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
, split_include_category_groups (value, rest) AS (
|
|
370
|
+
SELECT
|
|
371
|
+
''
|
|
372
|
+
, prm.include_category_groups || ','
|
|
373
|
+
FROM params AS prm
|
|
361
374
|
UNION ALL
|
|
362
375
|
SELECT
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
FROM
|
|
366
|
-
WHERE
|
|
376
|
+
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
|
|
377
|
+
, SUBSTR(rest, INSTR(rest, ',') + 1)
|
|
378
|
+
FROM split_include_category_groups
|
|
379
|
+
WHERE rest != ''
|
|
367
380
|
)
|
|
368
381
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
382
|
+
, include_category_groups AS (
|
|
383
|
+
SELECT value AS name
|
|
384
|
+
FROM split_include_category_groups
|
|
385
|
+
WHERE value != ''
|
|
386
|
+
)
|
|
373
387
|
|
|
374
|
-
|
|
388
|
+
, split_exclude_category_groups (value, rest) AS (
|
|
375
389
|
SELECT
|
|
376
|
-
|
|
377
|
-
,
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
390
|
+
''
|
|
391
|
+
, prm.exclude_category_groups || ','
|
|
392
|
+
FROM params AS prm
|
|
393
|
+
UNION ALL
|
|
394
|
+
SELECT
|
|
395
|
+
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
|
|
396
|
+
, SUBSTR(rest, INSTR(rest, ',') + 1)
|
|
397
|
+
FROM split_exclude_category_groups
|
|
398
|
+
WHERE rest != ''
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
, exclude_category_groups AS (
|
|
402
|
+
SELECT value AS name
|
|
403
|
+
FROM split_exclude_category_groups
|
|
404
|
+
WHERE value != ''
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
, matching_accounts AS (
|
|
408
|
+
SELECT
|
|
409
|
+
sp.id AS plan_id
|
|
410
|
+
, sp.name AS plan_name
|
|
411
|
+
, COUNT(*) AS matches
|
|
412
|
+
FROM scoped_plans AS sp
|
|
413
|
+
INNER JOIN accounts AS a ON sp.id = a.plan_id
|
|
414
|
+
CROSS JOIN params AS prm
|
|
415
|
+
WHERE NOT a.deleted AND a.name LIKE prm.account_name_like
|
|
416
|
+
GROUP BY sp.id, sp.name
|
|
381
417
|
)
|
|
382
418
|
|
|
383
419
|
, validation AS (
|
|
384
420
|
SELECT
|
|
385
421
|
p.account_name_like
|
|
422
|
+
, p.plan_id
|
|
386
423
|
, p.include_category_groups
|
|
387
424
|
, p.exclude_category_groups
|
|
388
425
|
FROM params AS p
|
|
@@ -398,11 +435,62 @@ WITH params AS (
|
|
|
398
435
|
|| ' or @exclude_category_groups' AS error
|
|
399
436
|
FROM validation AS v
|
|
400
437
|
WHERE v.include_category_groups != '' AND v.exclude_category_groups != ''
|
|
438
|
+
UNION ALL
|
|
439
|
+
SELECT 'No plan matched @plan_id' AS error
|
|
440
|
+
FROM validation AS v
|
|
441
|
+
WHERE
|
|
442
|
+
v.plan_id != '' AND NOT EXISTS (
|
|
443
|
+
SELECT 1
|
|
444
|
+
FROM scoped_plans
|
|
445
|
+
)
|
|
446
|
+
UNION ALL
|
|
447
|
+
SELECT 'No account names matched @account_name_like' AS error
|
|
448
|
+
FROM validation AS v
|
|
449
|
+
WHERE
|
|
450
|
+
v.account_name_like != '' AND NOT EXISTS (
|
|
451
|
+
SELECT 1
|
|
452
|
+
FROM matching_accounts
|
|
453
|
+
)
|
|
454
|
+
UNION ALL
|
|
455
|
+
SELECT
|
|
456
|
+
'Matched more than 1 account in plan: '
|
|
457
|
+
|| ma.plan_name AS error
|
|
458
|
+
FROM matching_accounts AS ma
|
|
459
|
+
WHERE ma.matches > 1
|
|
460
|
+
UNION ALL
|
|
461
|
+
SELECT
|
|
462
|
+
'Unknown include category group in plan '
|
|
463
|
+
|| sp.name
|
|
464
|
+
|| ': '
|
|
465
|
+
|| icg.name AS error
|
|
466
|
+
FROM scoped_plans AS sp
|
|
467
|
+
CROSS JOIN include_category_groups AS icg
|
|
468
|
+
LEFT JOIN category_groups AS cg
|
|
469
|
+
ON
|
|
470
|
+
sp.id = cg.plan_id
|
|
471
|
+
AND NOT COALESCE(cg.deleted, 0)
|
|
472
|
+
AND LOWER(cg.name) = LOWER(icg.name)
|
|
473
|
+
WHERE cg.id IS NULL
|
|
474
|
+
UNION ALL
|
|
475
|
+
SELECT
|
|
476
|
+
'Unknown exclude category group in plan '
|
|
477
|
+
|| sp.name
|
|
478
|
+
|| ': '
|
|
479
|
+
|| ecg.name AS error
|
|
480
|
+
FROM scoped_plans AS sp
|
|
481
|
+
CROSS JOIN exclude_category_groups AS ecg
|
|
482
|
+
LEFT JOIN category_groups AS cg
|
|
483
|
+
ON
|
|
484
|
+
sp.id = cg.plan_id
|
|
485
|
+
AND NOT COALESCE(cg.deleted, 0)
|
|
486
|
+
AND LOWER(cg.name) = LOWER(ecg.name)
|
|
487
|
+
WHERE cg.id IS NULL
|
|
401
488
|
)
|
|
402
489
|
|
|
403
490
|
, valid_params AS (
|
|
404
491
|
SELECT
|
|
405
492
|
v.account_name_like
|
|
493
|
+
, v.plan_id
|
|
406
494
|
, v.include_category_groups
|
|
407
495
|
, v.exclude_category_groups
|
|
408
496
|
FROM validation AS v
|
|
@@ -425,7 +513,7 @@ WITH params AS (
|
|
|
425
513
|
TRUE
|
|
426
514
|
AND NOT a.deleted
|
|
427
515
|
AND a.name LIKE v.account_name_like
|
|
428
|
-
AND (
|
|
516
|
+
AND (v.plan_id = '' OR p.id = v.plan_id)
|
|
429
517
|
)
|
|
430
518
|
|
|
431
519
|
, category_totals AS (
|
|
@@ -440,36 +528,61 @@ WITH params AS (
|
|
|
440
528
|
AND c.category_group_name != 'Internal Master Category'
|
|
441
529
|
AND (
|
|
442
530
|
v.include_category_groups = ''
|
|
443
|
-
OR
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
, ',' || LOWER(c.category_group_name) || ','
|
|
531
|
+
OR EXISTS (
|
|
532
|
+
SELECT 1
|
|
533
|
+
FROM include_category_groups AS icg
|
|
534
|
+
WHERE LOWER(icg.name) = LOWER(c.category_group_name)
|
|
448
535
|
)
|
|
449
|
-
> 0
|
|
450
536
|
)
|
|
451
537
|
AND (
|
|
452
538
|
v.exclude_category_groups = ''
|
|
453
|
-
OR
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
, ',' || LOWER(c.category_group_name) || ','
|
|
539
|
+
OR NOT EXISTS (
|
|
540
|
+
SELECT 1
|
|
541
|
+
FROM exclude_category_groups AS ecg
|
|
542
|
+
WHERE LOWER(ecg.name) = LOWER(c.category_group_name)
|
|
458
543
|
)
|
|
459
|
-
= 0
|
|
460
544
|
)
|
|
461
|
-
AND (
|
|
545
|
+
AND (v.plan_id = '' OR c.plan_id = v.plan_id)
|
|
462
546
|
GROUP BY c.plan_id
|
|
463
547
|
)
|
|
464
548
|
|
|
465
549
|
SELECT
|
|
466
|
-
|
|
550
|
+
ve.error AS error_message
|
|
551
|
+
, NULL AS "plan"
|
|
552
|
+
, NULL AS account
|
|
553
|
+
, NULL AS total
|
|
554
|
+
, NULL AS excess
|
|
555
|
+
FROM validation_errors AS ve
|
|
556
|
+
|
|
557
|
+
UNION ALL
|
|
558
|
+
|
|
559
|
+
SELECT
|
|
560
|
+
NULL AS error_message
|
|
561
|
+
, ma.plan_name AS "plan"
|
|
467
562
|
, ma.account_name AS account
|
|
468
563
|
, PRINTF('%.2f', COALESCE(ct.total, 0)) AS total
|
|
469
|
-
, PRINTF('%.2f', COALESCE(ct.total, 0)
|
|
564
|
+
, PRINTF('%.2f', ma.account_amount - COALESCE(ct.total, 0)) AS excess
|
|
470
565
|
FROM matched_accounts AS ma
|
|
471
566
|
LEFT JOIN category_totals AS ct ON ma.plan_id = ct.plan_id
|
|
472
|
-
|
|
567
|
+
;
|
|
568
|
+
|
|
569
|
+
SELECT error_message
|
|
570
|
+
FROM excess_query_results
|
|
571
|
+
WHERE error_message IS NOT NULL
|
|
572
|
+
;
|
|
573
|
+
|
|
574
|
+
SELECT
|
|
575
|
+
eqr."plan"
|
|
576
|
+
, eqr.account
|
|
577
|
+
, eqr.total
|
|
578
|
+
, eqr.excess
|
|
579
|
+
FROM excess_query_results AS eqr
|
|
580
|
+
WHERE
|
|
581
|
+
NOT EXISTS (
|
|
582
|
+
SELECT 1
|
|
583
|
+
FROM excess_query_results AS eqr_errors
|
|
584
|
+
WHERE eqr_errors.error_message IS NOT NULL
|
|
585
|
+
)
|
|
473
586
|
;
|
|
474
587
|
```
|
|
475
588
|
|
|
@@ -31,6 +31,7 @@ $ sqlite-export-for-ynab
|
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
Running it again will pull only data that changed since the last pull (this is done with [Delta Requests](https://api.ynab.com/#deltas)). If you want to wipe the DB and pull all data again use the `--full-refresh` flag.
|
|
34
|
+
Pass `--quiet` to suppress all CLI output, including progress bars.
|
|
34
35
|
|
|
35
36
|
<a id="db-path"></a>You can specify the DB path with the following options
|
|
36
37
|
1. The `--db` flag.
|
|
@@ -325,44 +326,80 @@ To compare assigned category values to a given account's balance:
|
|
|
325
326
|
-- -cmd ".parameter set @account_name_like %Savings%" \
|
|
326
327
|
-- -cmd ".parameter set @include_category_groups 'Home,Food'" \
|
|
327
328
|
-- < query.sql
|
|
329
|
+
CREATE TEMP TABLE excess_query_results AS
|
|
328
330
|
WITH params AS (
|
|
329
331
|
SELECT
|
|
330
332
|
TRIM(COALESCE(@account_name_like, '')) AS account_name_like
|
|
333
|
+
, TRIM(COALESCE(@plan_id, '')) AS plan_id
|
|
331
334
|
, TRIM(COALESCE(@include_category_groups, ''))
|
|
332
335
|
AS include_category_groups
|
|
333
336
|
, TRIM(COALESCE(@exclude_category_groups, ''))
|
|
334
337
|
AS exclude_category_groups
|
|
335
338
|
)
|
|
336
339
|
|
|
337
|
-
,
|
|
338
|
-
SELECT
|
|
339
|
-
|
|
340
|
-
|
|
340
|
+
, scoped_plans AS (
|
|
341
|
+
SELECT
|
|
342
|
+
p.id
|
|
343
|
+
, p.name
|
|
344
|
+
FROM plans AS p
|
|
345
|
+
CROSS JOIN params AS prm
|
|
346
|
+
WHERE prm.plan_id = '' OR p.id = prm.plan_id
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
, split_include_category_groups (value, rest) AS (
|
|
350
|
+
SELECT
|
|
351
|
+
''
|
|
352
|
+
, prm.include_category_groups || ','
|
|
353
|
+
FROM params AS prm
|
|
341
354
|
UNION ALL
|
|
342
355
|
SELECT
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
FROM
|
|
346
|
-
WHERE
|
|
356
|
+
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
|
|
357
|
+
, SUBSTR(rest, INSTR(rest, ',') + 1)
|
|
358
|
+
FROM split_include_category_groups
|
|
359
|
+
WHERE rest != ''
|
|
347
360
|
)
|
|
348
361
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
362
|
+
, include_category_groups AS (
|
|
363
|
+
SELECT value AS name
|
|
364
|
+
FROM split_include_category_groups
|
|
365
|
+
WHERE value != ''
|
|
366
|
+
)
|
|
353
367
|
|
|
354
|
-
|
|
368
|
+
, split_exclude_category_groups (value, rest) AS (
|
|
355
369
|
SELECT
|
|
356
|
-
|
|
357
|
-
,
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
370
|
+
''
|
|
371
|
+
, prm.exclude_category_groups || ','
|
|
372
|
+
FROM params AS prm
|
|
373
|
+
UNION ALL
|
|
374
|
+
SELECT
|
|
375
|
+
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
|
|
376
|
+
, SUBSTR(rest, INSTR(rest, ',') + 1)
|
|
377
|
+
FROM split_exclude_category_groups
|
|
378
|
+
WHERE rest != ''
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
, exclude_category_groups AS (
|
|
382
|
+
SELECT value AS name
|
|
383
|
+
FROM split_exclude_category_groups
|
|
384
|
+
WHERE value != ''
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
, matching_accounts AS (
|
|
388
|
+
SELECT
|
|
389
|
+
sp.id AS plan_id
|
|
390
|
+
, sp.name AS plan_name
|
|
391
|
+
, COUNT(*) AS matches
|
|
392
|
+
FROM scoped_plans AS sp
|
|
393
|
+
INNER JOIN accounts AS a ON sp.id = a.plan_id
|
|
394
|
+
CROSS JOIN params AS prm
|
|
395
|
+
WHERE NOT a.deleted AND a.name LIKE prm.account_name_like
|
|
396
|
+
GROUP BY sp.id, sp.name
|
|
361
397
|
)
|
|
362
398
|
|
|
363
399
|
, validation AS (
|
|
364
400
|
SELECT
|
|
365
401
|
p.account_name_like
|
|
402
|
+
, p.plan_id
|
|
366
403
|
, p.include_category_groups
|
|
367
404
|
, p.exclude_category_groups
|
|
368
405
|
FROM params AS p
|
|
@@ -378,11 +415,62 @@ WITH params AS (
|
|
|
378
415
|
|| ' or @exclude_category_groups' AS error
|
|
379
416
|
FROM validation AS v
|
|
380
417
|
WHERE v.include_category_groups != '' AND v.exclude_category_groups != ''
|
|
418
|
+
UNION ALL
|
|
419
|
+
SELECT 'No plan matched @plan_id' AS error
|
|
420
|
+
FROM validation AS v
|
|
421
|
+
WHERE
|
|
422
|
+
v.plan_id != '' AND NOT EXISTS (
|
|
423
|
+
SELECT 1
|
|
424
|
+
FROM scoped_plans
|
|
425
|
+
)
|
|
426
|
+
UNION ALL
|
|
427
|
+
SELECT 'No account names matched @account_name_like' AS error
|
|
428
|
+
FROM validation AS v
|
|
429
|
+
WHERE
|
|
430
|
+
v.account_name_like != '' AND NOT EXISTS (
|
|
431
|
+
SELECT 1
|
|
432
|
+
FROM matching_accounts
|
|
433
|
+
)
|
|
434
|
+
UNION ALL
|
|
435
|
+
SELECT
|
|
436
|
+
'Matched more than 1 account in plan: '
|
|
437
|
+
|| ma.plan_name AS error
|
|
438
|
+
FROM matching_accounts AS ma
|
|
439
|
+
WHERE ma.matches > 1
|
|
440
|
+
UNION ALL
|
|
441
|
+
SELECT
|
|
442
|
+
'Unknown include category group in plan '
|
|
443
|
+
|| sp.name
|
|
444
|
+
|| ': '
|
|
445
|
+
|| icg.name AS error
|
|
446
|
+
FROM scoped_plans AS sp
|
|
447
|
+
CROSS JOIN include_category_groups AS icg
|
|
448
|
+
LEFT JOIN category_groups AS cg
|
|
449
|
+
ON
|
|
450
|
+
sp.id = cg.plan_id
|
|
451
|
+
AND NOT COALESCE(cg.deleted, 0)
|
|
452
|
+
AND LOWER(cg.name) = LOWER(icg.name)
|
|
453
|
+
WHERE cg.id IS NULL
|
|
454
|
+
UNION ALL
|
|
455
|
+
SELECT
|
|
456
|
+
'Unknown exclude category group in plan '
|
|
457
|
+
|| sp.name
|
|
458
|
+
|| ': '
|
|
459
|
+
|| ecg.name AS error
|
|
460
|
+
FROM scoped_plans AS sp
|
|
461
|
+
CROSS JOIN exclude_category_groups AS ecg
|
|
462
|
+
LEFT JOIN category_groups AS cg
|
|
463
|
+
ON
|
|
464
|
+
sp.id = cg.plan_id
|
|
465
|
+
AND NOT COALESCE(cg.deleted, 0)
|
|
466
|
+
AND LOWER(cg.name) = LOWER(ecg.name)
|
|
467
|
+
WHERE cg.id IS NULL
|
|
381
468
|
)
|
|
382
469
|
|
|
383
470
|
, valid_params AS (
|
|
384
471
|
SELECT
|
|
385
472
|
v.account_name_like
|
|
473
|
+
, v.plan_id
|
|
386
474
|
, v.include_category_groups
|
|
387
475
|
, v.exclude_category_groups
|
|
388
476
|
FROM validation AS v
|
|
@@ -405,7 +493,7 @@ WITH params AS (
|
|
|
405
493
|
TRUE
|
|
406
494
|
AND NOT a.deleted
|
|
407
495
|
AND a.name LIKE v.account_name_like
|
|
408
|
-
AND (
|
|
496
|
+
AND (v.plan_id = '' OR p.id = v.plan_id)
|
|
409
497
|
)
|
|
410
498
|
|
|
411
499
|
, category_totals AS (
|
|
@@ -420,36 +508,61 @@ WITH params AS (
|
|
|
420
508
|
AND c.category_group_name != 'Internal Master Category'
|
|
421
509
|
AND (
|
|
422
510
|
v.include_category_groups = ''
|
|
423
|
-
OR
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
, ',' || LOWER(c.category_group_name) || ','
|
|
511
|
+
OR EXISTS (
|
|
512
|
+
SELECT 1
|
|
513
|
+
FROM include_category_groups AS icg
|
|
514
|
+
WHERE LOWER(icg.name) = LOWER(c.category_group_name)
|
|
428
515
|
)
|
|
429
|
-
> 0
|
|
430
516
|
)
|
|
431
517
|
AND (
|
|
432
518
|
v.exclude_category_groups = ''
|
|
433
|
-
OR
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
, ',' || LOWER(c.category_group_name) || ','
|
|
519
|
+
OR NOT EXISTS (
|
|
520
|
+
SELECT 1
|
|
521
|
+
FROM exclude_category_groups AS ecg
|
|
522
|
+
WHERE LOWER(ecg.name) = LOWER(c.category_group_name)
|
|
438
523
|
)
|
|
439
|
-
= 0
|
|
440
524
|
)
|
|
441
|
-
AND (
|
|
525
|
+
AND (v.plan_id = '' OR c.plan_id = v.plan_id)
|
|
442
526
|
GROUP BY c.plan_id
|
|
443
527
|
)
|
|
444
528
|
|
|
445
529
|
SELECT
|
|
446
|
-
|
|
530
|
+
ve.error AS error_message
|
|
531
|
+
, NULL AS "plan"
|
|
532
|
+
, NULL AS account
|
|
533
|
+
, NULL AS total
|
|
534
|
+
, NULL AS excess
|
|
535
|
+
FROM validation_errors AS ve
|
|
536
|
+
|
|
537
|
+
UNION ALL
|
|
538
|
+
|
|
539
|
+
SELECT
|
|
540
|
+
NULL AS error_message
|
|
541
|
+
, ma.plan_name AS "plan"
|
|
447
542
|
, ma.account_name AS account
|
|
448
543
|
, PRINTF('%.2f', COALESCE(ct.total, 0)) AS total
|
|
449
|
-
, PRINTF('%.2f', COALESCE(ct.total, 0)
|
|
544
|
+
, PRINTF('%.2f', ma.account_amount - COALESCE(ct.total, 0)) AS excess
|
|
450
545
|
FROM matched_accounts AS ma
|
|
451
546
|
LEFT JOIN category_totals AS ct ON ma.plan_id = ct.plan_id
|
|
452
|
-
|
|
547
|
+
;
|
|
548
|
+
|
|
549
|
+
SELECT error_message
|
|
550
|
+
FROM excess_query_results
|
|
551
|
+
WHERE error_message IS NOT NULL
|
|
552
|
+
;
|
|
553
|
+
|
|
554
|
+
SELECT
|
|
555
|
+
eqr."plan"
|
|
556
|
+
, eqr.account
|
|
557
|
+
, eqr.total
|
|
558
|
+
, eqr.excess
|
|
559
|
+
FROM excess_query_results AS eqr
|
|
560
|
+
WHERE
|
|
561
|
+
NOT EXISTS (
|
|
562
|
+
SELECT 1
|
|
563
|
+
FROM excess_query_results AS eqr_errors
|
|
564
|
+
WHERE eqr_errors.error_message IS NOT NULL
|
|
565
|
+
)
|
|
453
566
|
;
|
|
454
567
|
```
|
|
455
568
|
|
{sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/_main.py
RENAMED
|
@@ -50,7 +50,19 @@ _ENV_TOKEN = "YNAB_PERSONAL_ACCESS_TOKEN"
|
|
|
50
50
|
_PACKAGE = "sqlite-export-for-ynab"
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
def resolve_token(token_override: str | None = None) -> str:
|
|
54
|
+
token = token_override or os.environ.get(_ENV_TOKEN)
|
|
55
|
+
if token:
|
|
56
|
+
return token
|
|
57
|
+
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Must set YNAB access token as {_ENV_TOKEN!r} environment variable or pass token_override directly. See https://api.ynab.com/#personal-access-tokens"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def async_main(
|
|
64
|
+
argv: Sequence[str] | None = None, *, token_override: str | None = None
|
|
65
|
+
) -> int:
|
|
54
66
|
parser = argparse.ArgumentParser(prog=_PACKAGE)
|
|
55
67
|
parser.add_argument(
|
|
56
68
|
"--db",
|
|
@@ -66,20 +78,20 @@ async def async_main(argv: Sequence[str] | None = None) -> int:
|
|
|
66
78
|
parser.add_argument(
|
|
67
79
|
"--version", action="version", version=f"%(prog)s {version(_PACKAGE)}"
|
|
68
80
|
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--quiet",
|
|
83
|
+
action="store_true",
|
|
84
|
+
help="Suppress all CLI output, including progress bars.",
|
|
85
|
+
)
|
|
69
86
|
|
|
70
87
|
args = parser.parse_args(argv)
|
|
71
88
|
db: Path = args.db
|
|
72
89
|
full_refresh: bool = args.full_refresh
|
|
90
|
+
quiet: bool = args.quiet
|
|
73
91
|
|
|
74
|
-
token =
|
|
75
|
-
if not token:
|
|
76
|
-
raise ValueError(
|
|
77
|
-
f"Must set YNAB access token as {_ENV_TOKEN!r} "
|
|
78
|
-
"environment variable. See "
|
|
79
|
-
"https://api.ynab.com/#personal-access-tokens"
|
|
80
|
-
)
|
|
92
|
+
token = resolve_token(token_override)
|
|
81
93
|
|
|
82
|
-
await sync(token, db, full_refresh)
|
|
94
|
+
await sync(token, db, full_refresh, quiet=quiet)
|
|
83
95
|
|
|
84
96
|
return 0
|
|
85
97
|
|
|
@@ -96,7 +108,14 @@ def default_db_path() -> Path:
|
|
|
96
108
|
)
|
|
97
109
|
|
|
98
110
|
|
|
99
|
-
|
|
111
|
+
def _print(message: str, *, quiet: bool) -> None:
|
|
112
|
+
if not quiet:
|
|
113
|
+
print(message)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def sync(
|
|
117
|
+
token: str, db: Path, full_refresh: bool, *, quiet: bool = False
|
|
118
|
+
) -> None:
|
|
100
119
|
async with aiohttp.ClientSession() as session:
|
|
101
120
|
plans = (await YnabClient(token, session)("plans"))["plans"]
|
|
102
121
|
|
|
@@ -106,26 +125,26 @@ async def sync(token: str, db: Path, full_refresh: bool) -> None:
|
|
|
106
125
|
db.parent.mkdir(parents=True, exist_ok=True)
|
|
107
126
|
|
|
108
127
|
with sqlite3.connect(db) as con:
|
|
109
|
-
con.row_factory =
|
|
128
|
+
con.row_factory = sqlite3.Row
|
|
110
129
|
cur = con.cursor()
|
|
111
130
|
|
|
112
131
|
if full_refresh:
|
|
113
|
-
|
|
132
|
+
_print("Dropping relations...", quiet=quiet)
|
|
114
133
|
cur.executescript(contents("drop-relations.sql"))
|
|
115
134
|
con.commit()
|
|
116
|
-
|
|
135
|
+
_print("Done", quiet=quiet)
|
|
117
136
|
|
|
118
137
|
relations = get_relations(cur)
|
|
119
138
|
if relations != _ALL_RELATIONS:
|
|
120
|
-
|
|
139
|
+
_print("Recreating relations...", quiet=quiet)
|
|
121
140
|
cur.executescript(contents("create-relations.sql"))
|
|
122
141
|
con.commit()
|
|
123
|
-
|
|
142
|
+
_print("Done", quiet=quiet)
|
|
124
143
|
|
|
125
|
-
|
|
144
|
+
_print("Fetching plan data...", quiet=quiet)
|
|
126
145
|
lkos = get_last_knowledge_of_server(cur)
|
|
127
146
|
async with aiohttp.ClientSession() as session:
|
|
128
|
-
with tldm(desc="Plan Data", total=len(plans) * 5) as pbar:
|
|
147
|
+
with tldm(desc="Plan Data", total=len(plans) * 5, disable=quiet) as pbar:
|
|
129
148
|
yc = ProgressYnabClient(YnabClient(token, session), pbar)
|
|
130
149
|
|
|
131
150
|
account_jobs = jobs(yc, "accounts", plan_ids, lkos)
|
|
@@ -155,7 +174,7 @@ async def sync(token: str, db: Path, full_refresh: bool) -> None:
|
|
|
155
174
|
plan_ids, all_txn_data, strict=True
|
|
156
175
|
)
|
|
157
176
|
}
|
|
158
|
-
|
|
177
|
+
_print("Done", quiet=quiet)
|
|
159
178
|
|
|
160
179
|
if (
|
|
161
180
|
not any(t["accounts"] for t in all_account_data)
|
|
@@ -164,29 +183,27 @@ async def sync(token: str, db: Path, full_refresh: bool) -> None:
|
|
|
164
183
|
and not any(t["transactions"] for t in all_txn_data)
|
|
165
184
|
and not any(s["scheduled_transactions"] for s in all_sched_txn_data)
|
|
166
185
|
):
|
|
167
|
-
|
|
186
|
+
_print("No new data fetched", quiet=quiet)
|
|
168
187
|
else:
|
|
169
|
-
|
|
188
|
+
_print("Inserting plan data...", quiet=quiet)
|
|
170
189
|
insert_plans(cur, plans, new_lkos)
|
|
171
190
|
for plan_id, account_data in zip(plan_ids, all_account_data, strict=True):
|
|
172
|
-
insert_accounts(cur, plan_id, account_data["accounts"])
|
|
191
|
+
insert_accounts(cur, plan_id, account_data["accounts"], quiet=quiet)
|
|
173
192
|
for plan_id, cat_data in zip(plan_ids, all_cat_data, strict=True):
|
|
174
|
-
insert_category_groups(
|
|
193
|
+
insert_category_groups(
|
|
194
|
+
cur, plan_id, cat_data["category_groups"], quiet=quiet
|
|
195
|
+
)
|
|
175
196
|
for plan_id, payee_data in zip(plan_ids, all_payee_data, strict=True):
|
|
176
|
-
insert_payees(cur, plan_id, payee_data["payees"])
|
|
197
|
+
insert_payees(cur, plan_id, payee_data["payees"], quiet=quiet)
|
|
177
198
|
for plan_id, txn_data in zip(plan_ids, all_txn_data, strict=True):
|
|
178
|
-
insert_transactions(cur, plan_id, txn_data["transactions"])
|
|
199
|
+
insert_transactions(cur, plan_id, txn_data["transactions"], quiet=quiet)
|
|
179
200
|
for plan_id, sched_txn_data in zip(
|
|
180
201
|
plan_ids, all_sched_txn_data, strict=True
|
|
181
202
|
):
|
|
182
203
|
insert_scheduled_transactions(
|
|
183
|
-
cur, plan_id, sched_txn_data["scheduled_transactions"]
|
|
204
|
+
cur, plan_id, sched_txn_data["scheduled_transactions"], quiet=quiet
|
|
184
205
|
)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def _row_factory(c: sqlite3.Cursor, row: tuple[Any, ...]) -> dict[str, Any]:
|
|
189
|
-
return {d[0]: r for d, r in zip(c.description, row, strict=True)}
|
|
206
|
+
_print("Done", quiet=quiet)
|
|
190
207
|
|
|
191
208
|
|
|
192
209
|
def contents(filename: str) -> str:
|
|
@@ -253,7 +270,11 @@ _LOAN_ACCOUNT_PERIODIC_VALUES = frozenset(
|
|
|
253
270
|
|
|
254
271
|
|
|
255
272
|
def insert_accounts(
|
|
256
|
-
cur: sqlite3.Cursor,
|
|
273
|
+
cur: sqlite3.Cursor,
|
|
274
|
+
plan_id: str,
|
|
275
|
+
accounts: list[dict[str, Any]],
|
|
276
|
+
*,
|
|
277
|
+
quiet: bool = False,
|
|
257
278
|
) -> None:
|
|
258
279
|
# YNAB's LoanAccountPeriodValues are untyped dicts so we need to turn them into a more standard sub-entry view
|
|
259
280
|
updated_accounts = [
|
|
@@ -281,11 +302,16 @@ def insert_accounts(
|
|
|
281
302
|
"accounts",
|
|
282
303
|
"account_periodic_values",
|
|
283
304
|
"account_periodic_values",
|
|
305
|
+
quiet=quiet,
|
|
284
306
|
)
|
|
285
307
|
|
|
286
308
|
|
|
287
309
|
def insert_category_groups(
|
|
288
|
-
cur: sqlite3.Cursor,
|
|
310
|
+
cur: sqlite3.Cursor,
|
|
311
|
+
plan_id: str,
|
|
312
|
+
category_groups: list[dict[str, Any]],
|
|
313
|
+
*,
|
|
314
|
+
quiet: bool = False,
|
|
289
315
|
) -> None:
|
|
290
316
|
return insert_nested_entries(
|
|
291
317
|
cur,
|
|
@@ -295,21 +321,30 @@ def insert_category_groups(
|
|
|
295
321
|
"category_groups",
|
|
296
322
|
"categories",
|
|
297
323
|
"categories",
|
|
324
|
+
quiet=quiet,
|
|
298
325
|
)
|
|
299
326
|
|
|
300
327
|
|
|
301
328
|
def insert_payees(
|
|
302
|
-
cur: sqlite3.Cursor,
|
|
329
|
+
cur: sqlite3.Cursor,
|
|
330
|
+
plan_id: str,
|
|
331
|
+
payees: list[dict[str, Any]],
|
|
332
|
+
*,
|
|
333
|
+
quiet: bool = False,
|
|
303
334
|
) -> None:
|
|
304
335
|
if not payees:
|
|
305
336
|
return
|
|
306
337
|
|
|
307
|
-
for payee in tldm(payees, desc="Payees"):
|
|
338
|
+
for payee in tldm(payees, desc="Payees", disable=quiet):
|
|
308
339
|
insert_entry(cur, "payees", plan_id, payee)
|
|
309
340
|
|
|
310
341
|
|
|
311
342
|
def insert_transactions(
|
|
312
|
-
cur: sqlite3.Cursor,
|
|
343
|
+
cur: sqlite3.Cursor,
|
|
344
|
+
plan_id: str,
|
|
345
|
+
transactions: list[dict[str, Any]],
|
|
346
|
+
*,
|
|
347
|
+
quiet: bool = False,
|
|
313
348
|
) -> None:
|
|
314
349
|
return insert_nested_entries(
|
|
315
350
|
cur,
|
|
@@ -319,11 +354,16 @@ def insert_transactions(
|
|
|
319
354
|
"transactions",
|
|
320
355
|
"subtransactions",
|
|
321
356
|
"subtransactions",
|
|
357
|
+
quiet=quiet,
|
|
322
358
|
)
|
|
323
359
|
|
|
324
360
|
|
|
325
361
|
def insert_scheduled_transactions(
|
|
326
|
-
cur: sqlite3.Cursor,
|
|
362
|
+
cur: sqlite3.Cursor,
|
|
363
|
+
plan_id: str,
|
|
364
|
+
scheduled_transactions: list[dict[str, Any]],
|
|
365
|
+
*,
|
|
366
|
+
quiet: bool = False,
|
|
327
367
|
) -> None:
|
|
328
368
|
return insert_nested_entries(
|
|
329
369
|
cur,
|
|
@@ -333,6 +373,7 @@ def insert_scheduled_transactions(
|
|
|
333
373
|
"scheduled_transactions",
|
|
334
374
|
"subtransactions",
|
|
335
375
|
"scheduled_subtransactions",
|
|
376
|
+
quiet=quiet,
|
|
336
377
|
)
|
|
337
378
|
|
|
338
379
|
|
|
@@ -345,6 +386,8 @@ def insert_nested_entries(
|
|
|
345
386
|
entries_name: Literal["accounts"],
|
|
346
387
|
subentries_name: Literal["account_periodic_values"],
|
|
347
388
|
subentries_table_name: Literal["account_periodic_values"],
|
|
389
|
+
*,
|
|
390
|
+
quiet: bool = False,
|
|
348
391
|
) -> None: ...
|
|
349
392
|
|
|
350
393
|
|
|
@@ -357,6 +400,8 @@ def insert_nested_entries(
|
|
|
357
400
|
entries_name: Literal["category_groups"],
|
|
358
401
|
subentries_name: Literal["categories"],
|
|
359
402
|
subentries_table_name: Literal["categories"],
|
|
403
|
+
*,
|
|
404
|
+
quiet: bool = False,
|
|
360
405
|
) -> None: ...
|
|
361
406
|
|
|
362
407
|
|
|
@@ -369,6 +414,8 @@ def insert_nested_entries(
|
|
|
369
414
|
entries_name: Literal["transactions"],
|
|
370
415
|
subentries_name: Literal["subtransactions"],
|
|
371
416
|
subentries_table_name: Literal["subtransactions"],
|
|
417
|
+
*,
|
|
418
|
+
quiet: bool = False,
|
|
372
419
|
) -> None: ...
|
|
373
420
|
|
|
374
421
|
|
|
@@ -381,6 +428,8 @@ def insert_nested_entries(
|
|
|
381
428
|
entries_name: Literal["scheduled_transactions"],
|
|
382
429
|
subentries_name: Literal["subtransactions"],
|
|
383
430
|
subentries_table_name: Literal["scheduled_subtransactions"],
|
|
431
|
+
*,
|
|
432
|
+
quiet: bool = False,
|
|
384
433
|
) -> None: ...
|
|
385
434
|
|
|
386
435
|
|
|
@@ -411,6 +460,8 @@ def insert_nested_entries(
|
|
|
411
460
|
| Literal["subtransactions"]
|
|
412
461
|
| Literal["scheduled_subtransactions"]
|
|
413
462
|
),
|
|
463
|
+
*,
|
|
464
|
+
quiet: bool = False,
|
|
414
465
|
) -> None:
|
|
415
466
|
if not entries:
|
|
416
467
|
return
|
|
@@ -418,6 +469,7 @@ def insert_nested_entries(
|
|
|
418
469
|
with tldm(
|
|
419
470
|
total=sum(1 + len(e[subentries_name]) for e in entries),
|
|
420
471
|
desc=desc,
|
|
472
|
+
disable=quiet,
|
|
421
473
|
) as pbar:
|
|
422
474
|
for entry in entries:
|
|
423
475
|
insert_entry(
|
|
@@ -530,5 +582,7 @@ class YnabClient:
|
|
|
530
582
|
raise AssertionError("unreachable")
|
|
531
583
|
|
|
532
584
|
|
|
533
|
-
def main(
|
|
534
|
-
|
|
585
|
+
def main(
|
|
586
|
+
argv: Sequence[str] | None = None, *, token_override: str | None = None
|
|
587
|
+
) -> int:
|
|
588
|
+
return asyncio.run(async_main(argv, token_override=token_override))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlite_export_for_ynab
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: SQLite Export for YNAB - Export YNAB Data to SQLite
|
|
5
5
|
Home-page: https://github.com/mxr/sqlite-export-for-ynab
|
|
6
6
|
Author: Max R
|
|
@@ -51,6 +51,7 @@ $ sqlite-export-for-ynab
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
Running it again will pull only data that changed since the last pull (this is done with [Delta Requests](https://api.ynab.com/#deltas)). If you want to wipe the DB and pull all data again use the `--full-refresh` flag.
|
|
54
|
+
Pass `--quiet` to suppress all CLI output, including progress bars.
|
|
54
55
|
|
|
55
56
|
<a id="db-path"></a>You can specify the DB path with the following options
|
|
56
57
|
1. The `--db` flag.
|
|
@@ -345,44 +346,80 @@ To compare assigned category values to a given account's balance:
|
|
|
345
346
|
-- -cmd ".parameter set @account_name_like %Savings%" \
|
|
346
347
|
-- -cmd ".parameter set @include_category_groups 'Home,Food'" \
|
|
347
348
|
-- < query.sql
|
|
349
|
+
CREATE TEMP TABLE excess_query_results AS
|
|
348
350
|
WITH params AS (
|
|
349
351
|
SELECT
|
|
350
352
|
TRIM(COALESCE(@account_name_like, '')) AS account_name_like
|
|
353
|
+
, TRIM(COALESCE(@plan_id, '')) AS plan_id
|
|
351
354
|
, TRIM(COALESCE(@include_category_groups, ''))
|
|
352
355
|
AS include_category_groups
|
|
353
356
|
, TRIM(COALESCE(@exclude_category_groups, ''))
|
|
354
357
|
AS exclude_category_groups
|
|
355
358
|
)
|
|
356
359
|
|
|
357
|
-
,
|
|
358
|
-
SELECT
|
|
359
|
-
|
|
360
|
-
|
|
360
|
+
, scoped_plans AS (
|
|
361
|
+
SELECT
|
|
362
|
+
p.id
|
|
363
|
+
, p.name
|
|
364
|
+
FROM plans AS p
|
|
365
|
+
CROSS JOIN params AS prm
|
|
366
|
+
WHERE prm.plan_id = '' OR p.id = prm.plan_id
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
, split_include_category_groups (value, rest) AS (
|
|
370
|
+
SELECT
|
|
371
|
+
''
|
|
372
|
+
, prm.include_category_groups || ','
|
|
373
|
+
FROM params AS prm
|
|
361
374
|
UNION ALL
|
|
362
375
|
SELECT
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
FROM
|
|
366
|
-
WHERE
|
|
376
|
+
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
|
|
377
|
+
, SUBSTR(rest, INSTR(rest, ',') + 1)
|
|
378
|
+
FROM split_include_category_groups
|
|
379
|
+
WHERE rest != ''
|
|
367
380
|
)
|
|
368
381
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
382
|
+
, include_category_groups AS (
|
|
383
|
+
SELECT value AS name
|
|
384
|
+
FROM split_include_category_groups
|
|
385
|
+
WHERE value != ''
|
|
386
|
+
)
|
|
373
387
|
|
|
374
|
-
|
|
388
|
+
, split_exclude_category_groups (value, rest) AS (
|
|
375
389
|
SELECT
|
|
376
|
-
|
|
377
|
-
,
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
390
|
+
''
|
|
391
|
+
, prm.exclude_category_groups || ','
|
|
392
|
+
FROM params AS prm
|
|
393
|
+
UNION ALL
|
|
394
|
+
SELECT
|
|
395
|
+
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1))
|
|
396
|
+
, SUBSTR(rest, INSTR(rest, ',') + 1)
|
|
397
|
+
FROM split_exclude_category_groups
|
|
398
|
+
WHERE rest != ''
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
, exclude_category_groups AS (
|
|
402
|
+
SELECT value AS name
|
|
403
|
+
FROM split_exclude_category_groups
|
|
404
|
+
WHERE value != ''
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
, matching_accounts AS (
|
|
408
|
+
SELECT
|
|
409
|
+
sp.id AS plan_id
|
|
410
|
+
, sp.name AS plan_name
|
|
411
|
+
, COUNT(*) AS matches
|
|
412
|
+
FROM scoped_plans AS sp
|
|
413
|
+
INNER JOIN accounts AS a ON sp.id = a.plan_id
|
|
414
|
+
CROSS JOIN params AS prm
|
|
415
|
+
WHERE NOT a.deleted AND a.name LIKE prm.account_name_like
|
|
416
|
+
GROUP BY sp.id, sp.name
|
|
381
417
|
)
|
|
382
418
|
|
|
383
419
|
, validation AS (
|
|
384
420
|
SELECT
|
|
385
421
|
p.account_name_like
|
|
422
|
+
, p.plan_id
|
|
386
423
|
, p.include_category_groups
|
|
387
424
|
, p.exclude_category_groups
|
|
388
425
|
FROM params AS p
|
|
@@ -398,11 +435,62 @@ WITH params AS (
|
|
|
398
435
|
|| ' or @exclude_category_groups' AS error
|
|
399
436
|
FROM validation AS v
|
|
400
437
|
WHERE v.include_category_groups != '' AND v.exclude_category_groups != ''
|
|
438
|
+
UNION ALL
|
|
439
|
+
SELECT 'No plan matched @plan_id' AS error
|
|
440
|
+
FROM validation AS v
|
|
441
|
+
WHERE
|
|
442
|
+
v.plan_id != '' AND NOT EXISTS (
|
|
443
|
+
SELECT 1
|
|
444
|
+
FROM scoped_plans
|
|
445
|
+
)
|
|
446
|
+
UNION ALL
|
|
447
|
+
SELECT 'No account names matched @account_name_like' AS error
|
|
448
|
+
FROM validation AS v
|
|
449
|
+
WHERE
|
|
450
|
+
v.account_name_like != '' AND NOT EXISTS (
|
|
451
|
+
SELECT 1
|
|
452
|
+
FROM matching_accounts
|
|
453
|
+
)
|
|
454
|
+
UNION ALL
|
|
455
|
+
SELECT
|
|
456
|
+
'Matched more than 1 account in plan: '
|
|
457
|
+
|| ma.plan_name AS error
|
|
458
|
+
FROM matching_accounts AS ma
|
|
459
|
+
WHERE ma.matches > 1
|
|
460
|
+
UNION ALL
|
|
461
|
+
SELECT
|
|
462
|
+
'Unknown include category group in plan '
|
|
463
|
+
|| sp.name
|
|
464
|
+
|| ': '
|
|
465
|
+
|| icg.name AS error
|
|
466
|
+
FROM scoped_plans AS sp
|
|
467
|
+
CROSS JOIN include_category_groups AS icg
|
|
468
|
+
LEFT JOIN category_groups AS cg
|
|
469
|
+
ON
|
|
470
|
+
sp.id = cg.plan_id
|
|
471
|
+
AND NOT COALESCE(cg.deleted, 0)
|
|
472
|
+
AND LOWER(cg.name) = LOWER(icg.name)
|
|
473
|
+
WHERE cg.id IS NULL
|
|
474
|
+
UNION ALL
|
|
475
|
+
SELECT
|
|
476
|
+
'Unknown exclude category group in plan '
|
|
477
|
+
|| sp.name
|
|
478
|
+
|| ': '
|
|
479
|
+
|| ecg.name AS error
|
|
480
|
+
FROM scoped_plans AS sp
|
|
481
|
+
CROSS JOIN exclude_category_groups AS ecg
|
|
482
|
+
LEFT JOIN category_groups AS cg
|
|
483
|
+
ON
|
|
484
|
+
sp.id = cg.plan_id
|
|
485
|
+
AND NOT COALESCE(cg.deleted, 0)
|
|
486
|
+
AND LOWER(cg.name) = LOWER(ecg.name)
|
|
487
|
+
WHERE cg.id IS NULL
|
|
401
488
|
)
|
|
402
489
|
|
|
403
490
|
, valid_params AS (
|
|
404
491
|
SELECT
|
|
405
492
|
v.account_name_like
|
|
493
|
+
, v.plan_id
|
|
406
494
|
, v.include_category_groups
|
|
407
495
|
, v.exclude_category_groups
|
|
408
496
|
FROM validation AS v
|
|
@@ -425,7 +513,7 @@ WITH params AS (
|
|
|
425
513
|
TRUE
|
|
426
514
|
AND NOT a.deleted
|
|
427
515
|
AND a.name LIKE v.account_name_like
|
|
428
|
-
AND (
|
|
516
|
+
AND (v.plan_id = '' OR p.id = v.plan_id)
|
|
429
517
|
)
|
|
430
518
|
|
|
431
519
|
, category_totals AS (
|
|
@@ -440,36 +528,61 @@ WITH params AS (
|
|
|
440
528
|
AND c.category_group_name != 'Internal Master Category'
|
|
441
529
|
AND (
|
|
442
530
|
v.include_category_groups = ''
|
|
443
|
-
OR
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
, ',' || LOWER(c.category_group_name) || ','
|
|
531
|
+
OR EXISTS (
|
|
532
|
+
SELECT 1
|
|
533
|
+
FROM include_category_groups AS icg
|
|
534
|
+
WHERE LOWER(icg.name) = LOWER(c.category_group_name)
|
|
448
535
|
)
|
|
449
|
-
> 0
|
|
450
536
|
)
|
|
451
537
|
AND (
|
|
452
538
|
v.exclude_category_groups = ''
|
|
453
|
-
OR
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
, ',' || LOWER(c.category_group_name) || ','
|
|
539
|
+
OR NOT EXISTS (
|
|
540
|
+
SELECT 1
|
|
541
|
+
FROM exclude_category_groups AS ecg
|
|
542
|
+
WHERE LOWER(ecg.name) = LOWER(c.category_group_name)
|
|
458
543
|
)
|
|
459
|
-
= 0
|
|
460
544
|
)
|
|
461
|
-
AND (
|
|
545
|
+
AND (v.plan_id = '' OR c.plan_id = v.plan_id)
|
|
462
546
|
GROUP BY c.plan_id
|
|
463
547
|
)
|
|
464
548
|
|
|
465
549
|
SELECT
|
|
466
|
-
|
|
550
|
+
ve.error AS error_message
|
|
551
|
+
, NULL AS "plan"
|
|
552
|
+
, NULL AS account
|
|
553
|
+
, NULL AS total
|
|
554
|
+
, NULL AS excess
|
|
555
|
+
FROM validation_errors AS ve
|
|
556
|
+
|
|
557
|
+
UNION ALL
|
|
558
|
+
|
|
559
|
+
SELECT
|
|
560
|
+
NULL AS error_message
|
|
561
|
+
, ma.plan_name AS "plan"
|
|
467
562
|
, ma.account_name AS account
|
|
468
563
|
, PRINTF('%.2f', COALESCE(ct.total, 0)) AS total
|
|
469
|
-
, PRINTF('%.2f', COALESCE(ct.total, 0)
|
|
564
|
+
, PRINTF('%.2f', ma.account_amount - COALESCE(ct.total, 0)) AS excess
|
|
470
565
|
FROM matched_accounts AS ma
|
|
471
566
|
LEFT JOIN category_totals AS ct ON ma.plan_id = ct.plan_id
|
|
472
|
-
|
|
567
|
+
;
|
|
568
|
+
|
|
569
|
+
SELECT error_message
|
|
570
|
+
FROM excess_query_results
|
|
571
|
+
WHERE error_message IS NOT NULL
|
|
572
|
+
;
|
|
573
|
+
|
|
574
|
+
SELECT
|
|
575
|
+
eqr."plan"
|
|
576
|
+
, eqr.account
|
|
577
|
+
, eqr.total
|
|
578
|
+
, eqr.excess
|
|
579
|
+
FROM excess_query_results AS eqr
|
|
580
|
+
WHERE
|
|
581
|
+
NOT EXISTS (
|
|
582
|
+
SELECT 1
|
|
583
|
+
FROM excess_query_results AS eqr_errors
|
|
584
|
+
WHERE eqr_errors.error_message IS NOT NULL
|
|
585
|
+
)
|
|
473
586
|
;
|
|
474
587
|
```
|
|
475
588
|
|
|
@@ -8,7 +8,6 @@ from uuid import uuid4
|
|
|
8
8
|
import pytest
|
|
9
9
|
from aioresponses import aioresponses
|
|
10
10
|
|
|
11
|
-
from sqlite_export_for_ynab._main import _row_factory
|
|
12
11
|
from sqlite_export_for_ynab._main import contents
|
|
13
12
|
|
|
14
13
|
PLAN_ID_1 = str(uuid4())
|
|
@@ -326,7 +325,7 @@ SCHEDULED_TRANSACTIONS: list[dict[str, Any]] = [
|
|
|
326
325
|
@pytest.fixture
|
|
327
326
|
def cur():
|
|
328
327
|
with sqlite3.connect(":memory:") as con:
|
|
329
|
-
con.row_factory =
|
|
328
|
+
con.row_factory = sqlite3.Row
|
|
330
329
|
cursor = con.cursor()
|
|
331
330
|
cursor.executescript(contents("create-relations.sql"))
|
|
332
331
|
yield cursor
|
|
@@ -338,8 +337,8 @@ def mock_aioresponses():
|
|
|
338
337
|
yield m
|
|
339
338
|
|
|
340
339
|
|
|
341
|
-
def strip_nones(d:
|
|
342
|
-
return {k: v for k, v in d.items() if v is not None}
|
|
340
|
+
def strip_nones(d: sqlite3.Row) -> dict[str, Any]:
|
|
341
|
+
return {k: v for k, v in dict(d).items() if v is not None}
|
|
343
342
|
|
|
344
343
|
|
|
345
344
|
TOKEN = f"token-{uuid4()}"
|
|
@@ -27,6 +27,7 @@ from sqlite_export_for_ynab._main import insert_scheduled_transactions
|
|
|
27
27
|
from sqlite_export_for_ynab._main import insert_transactions
|
|
28
28
|
from sqlite_export_for_ynab._main import main
|
|
29
29
|
from sqlite_export_for_ynab._main import ProgressYnabClient
|
|
30
|
+
from sqlite_export_for_ynab._main import resolve_token
|
|
30
31
|
from sqlite_export_for_ynab._main import sync
|
|
31
32
|
from sqlite_export_for_ynab._main import YnabClient
|
|
32
33
|
from testing.fixtures import ACCOUNT_ID_1
|
|
@@ -106,7 +107,7 @@ def test_get_last_knowledge_of_server(cur):
|
|
|
106
107
|
def test_insert_plans(cur):
|
|
107
108
|
insert_plans(cur, PLANS, LKOS)
|
|
108
109
|
cur.execute("SELECT * FROM plans ORDER BY name")
|
|
109
|
-
assert cur.fetchall() == [
|
|
110
|
+
assert [strip_nones(d) for d in cur.fetchall()] == [
|
|
110
111
|
{
|
|
111
112
|
"id": PLAN_ID_1,
|
|
112
113
|
"name": PLANS[0]["name"],
|
|
@@ -170,7 +171,7 @@ def test_insert_accounts(cur):
|
|
|
170
171
|
]
|
|
171
172
|
|
|
172
173
|
cur.execute("SELECT * FROM account_periodic_values ORDER BY name")
|
|
173
|
-
assert cur.fetchall() == [
|
|
174
|
+
assert [strip_nones(d) for d in cur.fetchall()] == [
|
|
174
175
|
{
|
|
175
176
|
"account_id": ACCOUNT_ID_1,
|
|
176
177
|
"plan_id": PLAN_ID_1,
|
|
@@ -575,7 +576,7 @@ def test_main_ok(sync, tmp_path, monkeypatch):
|
|
|
575
576
|
monkeypatch.setenv(_ENV_TOKEN, TOKEN)
|
|
576
577
|
|
|
577
578
|
ret = main(("--db", str(tmp_path / "db.sqlite")))
|
|
578
|
-
sync.
|
|
579
|
+
sync.assert_called_once_with(TOKEN, tmp_path / "db.sqlite", False, quiet=False)
|
|
579
580
|
assert ret == 0
|
|
580
581
|
|
|
581
582
|
|
|
@@ -586,6 +587,40 @@ def test_main_no_token(tmp_path, monkeypatch):
|
|
|
586
587
|
main(("--db", str(tmp_path / "db.sqlite")))
|
|
587
588
|
|
|
588
589
|
|
|
590
|
+
@patch("sqlite_export_for_ynab._main.sync")
|
|
591
|
+
def test_main_uses_token_override(sync, tmp_path, monkeypatch):
|
|
592
|
+
monkeypatch.delenv(_ENV_TOKEN, raising=False)
|
|
593
|
+
|
|
594
|
+
ret = main(("--db", str(tmp_path / "db.sqlite")), token_override="override-token")
|
|
595
|
+
|
|
596
|
+
sync.assert_called_once_with(
|
|
597
|
+
"override-token", tmp_path / "db.sqlite", False, quiet=False
|
|
598
|
+
)
|
|
599
|
+
assert ret == 0
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@patch("sqlite_export_for_ynab._main.sync")
|
|
603
|
+
def test_main_quiet(sync, tmp_path, monkeypatch):
|
|
604
|
+
monkeypatch.setenv(_ENV_TOKEN, TOKEN)
|
|
605
|
+
|
|
606
|
+
ret = main(("--db", str(tmp_path / "db.sqlite"), "--quiet"))
|
|
607
|
+
|
|
608
|
+
sync.assert_called_once_with(TOKEN, tmp_path / "db.sqlite", False, quiet=True)
|
|
609
|
+
assert ret == 0
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def test_resolve_token_override(monkeypatch):
|
|
613
|
+
monkeypatch.delenv(_ENV_TOKEN, raising=False)
|
|
614
|
+
|
|
615
|
+
assert resolve_token("override-token") == "override-token"
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def test_resolve_token_env(monkeypatch):
|
|
619
|
+
monkeypatch.setenv(_ENV_TOKEN, TOKEN)
|
|
620
|
+
|
|
621
|
+
assert resolve_token() == TOKEN
|
|
622
|
+
|
|
623
|
+
|
|
589
624
|
@pytest.mark.asyncio
|
|
590
625
|
@pytest.mark.usefixtures(mock_aioresponses.__name__)
|
|
591
626
|
async def test_sync_no_data(tmp_path, mock_aioresponses):
|
|
@@ -631,6 +666,54 @@ async def test_sync_no_data(tmp_path, mock_aioresponses):
|
|
|
631
666
|
await sync(TOKEN, db, False)
|
|
632
667
|
|
|
633
668
|
|
|
669
|
+
@pytest.mark.asyncio
|
|
670
|
+
@pytest.mark.usefixtures(mock_aioresponses.__name__)
|
|
671
|
+
async def test_sync_no_data_quiet(tmp_path, mock_aioresponses, capsys):
|
|
672
|
+
mock_aioresponses.get(
|
|
673
|
+
PLANS_ENDPOINT_RE, body=json.dumps({"data": {"plans": PLANS}})
|
|
674
|
+
)
|
|
675
|
+
mock_aioresponses.get(
|
|
676
|
+
ACCOUNTS_ENDPOINT_RE,
|
|
677
|
+
body=json.dumps({"data": {"accounts": []}}),
|
|
678
|
+
repeat=True,
|
|
679
|
+
)
|
|
680
|
+
mock_aioresponses.get(
|
|
681
|
+
CATEGORIES_ENDPOINT_RE,
|
|
682
|
+
body=json.dumps({"data": {"category_groups": []}}),
|
|
683
|
+
repeat=True,
|
|
684
|
+
)
|
|
685
|
+
mock_aioresponses.get(
|
|
686
|
+
PAYEES_ENDPOINT_RE, body=json.dumps({"data": {"payees": []}}), repeat=True
|
|
687
|
+
)
|
|
688
|
+
mock_aioresponses.get(
|
|
689
|
+
TRANSACTIONS_ENDPOINT_RE,
|
|
690
|
+
body=json.dumps(
|
|
691
|
+
{
|
|
692
|
+
"data": {
|
|
693
|
+
"transactions": [],
|
|
694
|
+
"server_knowledge": SERVER_KNOWLEDGE_1,
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
),
|
|
698
|
+
repeat=True,
|
|
699
|
+
)
|
|
700
|
+
mock_aioresponses.get(
|
|
701
|
+
SCHEDULED_TRANSACTIONS_ENDPOINT_RE,
|
|
702
|
+
body=json.dumps({"data": {"scheduled_transactions": []}}),
|
|
703
|
+
repeat=True,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
db = tmp_path / "db.sqlite"
|
|
707
|
+
with sqlite3.connect(db) as con:
|
|
708
|
+
con.executescript(contents("create-relations.sql"))
|
|
709
|
+
|
|
710
|
+
await sync(TOKEN, db, False, quiet=True)
|
|
711
|
+
|
|
712
|
+
out, err = capsys.readouterr()
|
|
713
|
+
assert out == ""
|
|
714
|
+
assert err == ""
|
|
715
|
+
|
|
716
|
+
|
|
634
717
|
@pytest.mark.asyncio
|
|
635
718
|
@pytest.mark.usefixtures(mock_aioresponses.__name__)
|
|
636
719
|
async def test_sync(tmp_path, mock_aioresponses):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__init__.py
RENAMED
|
File without changes
|
{sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/__main__.py
RENAMED
|
File without changes
|
{sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/ddl/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlite_export_for_ynab-2.1.0 → sqlite_export_for_ynab-2.2.0}/sqlite_export_for_ynab/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|