-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathDatabaseReporter.ps1
More file actions
2599 lines (2189 loc) · 116 KB
/
DatabaseReporter.ps1
File metadata and controls
2599 lines (2189 loc) · 116 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
if ($PSVersionTable.PSVersion.Major -lt 3) {
throw "Unsupported PowerShell version! PowerShell v3.0 or greater is required to use the Database Reporter Framework!"
}
# Call this to ensure nothing is exported from the module by default. Each call
# to DbReaderCommand will manually export that command as the module is being
# imported.
Export-ModuleMember
$DBRModule = New-Module {
<#
This dynamic module holds DBRF session information as a way to prevent
polluting the calling/parent module's scope so that that module doesn't
have to worry about any member conflicts with the DBRF.
MOVE THIS TO REAL DOCUMENTATION SECTION:
Variables exported:
$DBRInfo
A hash table that contains information about the framework
#>
$DBRInfo = @{
Version = [version] '0.1.20170731'
# When a returned column is null, this string is returned instead:
OutputNullReplacementString = '$null'
ParentModule = $args[0]
}
Export-ModuleMember -Variable DBRInfo
# As long as PS v3/4 are supported, we can't depend on Register-ArgumentCompleter
# to always be available.
$DBRInfo.TabExpansionAvailable = if (Get-Command Register-ArgumentCompleter -ErrorAction SilentlyContinue) {
$true
}
else {
$false
}
# Valid command attributes. If you don't like the default names, here's where you can
# change them
$CommandAttributes = @{
DbCommandInfoAttributeName = 'MagicDbInfo'
HelpAttributeName = 'MagicPsHelp'
}
$DbCommandInfoAttributeProperties = @{
WildcardReplacement = 'WildcardReplacementScriptblock'
WhereConditionBuilder = 'WhereConditionBuilderScriptblock'
SqlMode = 'SqlMode'
}
$ParameterAttributes = @{
DbComparisonSuffixAttributeName = 'MagicDbComparisonSuffix'
DbColumnProperty = 'MagicDbProp'
DbFormatTableInfo = 'MagicDbFormatTableColumn'
}
# This variable will be created in a DbReaderCommand instances' scope. This
# is where the raw (potentially hash table) input for parameters is stored
# before the PS parameter binder kicks in. Name it something that won't
# conflict with real parameter names
$__DbReaderInfoTableName = '__PsBoundDbInfos'
# DbReaderCommand instance information will be tucked away here. Underscore
# prefix can go away...
$__CommandDeclarations = @{}
# DbReaderCommand instances get this scriptblock injected into their definition
# after a new instance is bound to this module. It will look into the command
# declaration hashtable to get specific information
$ReferenceCommandScriptBlock = [scriptblock]::Create({
<#
.SYNOPSIS
A dynamic command generated by the DatabaseReporter framework.
.DESCRIPTION
This is a command that was generated by the DatabaseReporter framework.
While no command-specific help was specified, some more generic help would be
very helpful here :)
.EXAMPLE
PS> Verb-Noun -GroupBy ParameterName
Explain generic example #1 here
.EXAMPLE
PS> Verb-Noun -OrderBy ParameterName
Explain generic example #2 here
#>
[CmdletBinding()]
param(
# Negates specified properties that have been provided to the command.
# Any filtering parameter that is specified in the same call can be
# provided as input.
[string[]] $Negate,
# Changes the returned object to have properties specified in this
# parameter, along with a count property that shows the number of
# records grouped together by those properties. More than one parameter
# name can be specified.
[string[]] $GroupBy,
# Changes the order the objects are returned in, depending on what
# parameter names are provided. This behaves the way that Sort-Object
# would, except it causes the database to do the sorting.
#
# Adding a '!' or ' DESC' to the end of a parameter name causes the
# sorting to be descening, instead of the default ascending order.
[string[]] $OrderBy
)
begin {
if (-not $__MyCommandInfo) {
throw "Unable to get command information for '$($PsCmdlet.MyInvocation.MyCommand.Name)'"
}
# This is the dictionary that gets set up during parameter binding that has all the information for handling
# the WHERE clause. If no parameters are specified (at least DB column parameters), then it won't have been
# defined, and that will be bad later. So, if it's not defined, create an empty one:
#
#
# FUTURE UPDATE:
# This call is unnecessary. Put some kind of a marker here that DbReaderCommand function will recognize
# and replace the literal string with the parameter name and a null check (at a minimum, the first line
# here should be able to go away, but if $__DbReaderInfoTableName were changed above, having $__PSBoundDBInfos
# hardcoded would break. If DbReaderCommand replaces all the markers where $__PSBoundDbInfos are present
# here, then $__DbReaderInfoTableName could be updated and everything would just work)
#
#
$__PSBoundDbInfos = Get-Variable -ErrorAction SilentlyContinue -Scope 0 -Name $__DbReaderInfoTableName -ValueOnly
if ($null -eq $__PSBoundDbInfos) { $__PSBoundDbInfos = @{} }
# Default parameter values need to work. The way this is set up right now, PSBoundParameters is all that's going
# to be checked. So, let's take any default values (they'll stand out b/c they'll be variables with a value that
# exist in this scope), and add them to the PSBoundParameters dictionary (only if they weren't already bound).
#
# That's great for parameters that weren't passed via pipeline. What if about pipeline bound parameters (they won't
# show up in PSBoundParameters in the begin{} block). Well, they'll be updated in the process{} block, and since
# all the work will happen there, it should be fine
#
foreach ($__ParameterName in $PsCmdlet.MyInvocation.MyCommand.Parameters.Keys) {
Write-Verbose "Checking to see if $__ParameterName wasn't specified and has a default value..."
if (-not ($PSBoundParameters.ContainsKey($__ParameterName)) -and ($__ParamDefaultValue = Get-Variable -Name $__ParameterName -Scope 0 -ValueOnly -ErrorAction SilentlyContinue)) {
Write-Verbose " ...one of those conditions was met! Setting PSBoundParameter to show value of $__ParamDefaultValue"
# If this is a DB property, we need to make sure the DbReaderInfo is attached
if ($__MyCommandInfo.PropertyParameters.Contains($__ParameterName)) {
Write-Verbose " (Attaching DBReaderInfo property first)"
$__ParamDefaultValue = AddDbReaderInfo -InputObject $__ParamDefaultValue -OutputType $PSCmdlet.MyInvocation.MyCommand.Parameters[$__ParameterName].ParameterType -CommandName $PsCmdlet.MyInvocation.MyCommand.Name -ParameterName $__ParameterName
}
$PSBoundParameters[$__ParameterName] = $__ParamDefaultValue
}
else {
Write-Verbose " ...either already specified, or no default value"
}
}
Write-Verbose "begin PSBoundParameters {"
foreach ($__Param in $PSBoundParameters.GetEnumerator()) {
Write-Verbose (" {0} = {1}{2}" -f $__Param.Key, ($__Param.Value -join ', '), $(if ($__PSBoundDbInfos.Contains($__Param.Key)) {' (DBReaderInfo bound)'}))
}
Write-Verbose "}"
}
process {
Write-Verbose "process PSBoundParameters {"
foreach ($__Param in $PSBoundParameters.GetEnumerator()) {
Write-Verbose (" {0} = {1}{2}" -f $__Param.Key, ($__Param.Value -join ', '), $(if ($__PSBoundDbInfos.Contains($__Param.Key)) {' (DBReaderInfo bound)'}))
}
Write-Verbose "}"
# For now, process block will execute multiple times when pipeline input comes in (that's nothing new). Should the process block attempt to
# collect all the pipeline data, though, and do one query in the end block instead? Just something to think about...
$__JoinSpacingString = "`n "
$__SqlQuerySb = New-Object System.Text.StringBuilder
# Get SELECT clause info (get GROUP BY info too, just in case it's needed):
$__StringList = New-Object System.Collections.Generic.List[string]
$__GroupByList = New-Object System.Collections.Generic.List[string]
foreach ($__Property in $__MyCommandInfo.PropertyParameters.GetEnumerator()) {
if (-not $PSBoundParameters.ContainsKey('GroupBy') -or (ContainsMatch -Collection $GroupBy -ValueToMatch $__Property.Value.PropertyName) -or (ContainsMatch -Collection $GroupBy -ValueToMatch $__Property.Name)) {
$__CurrentSelect = '{0} AS {1}' -f $__Property.Value.ColumnName, $__Property.Value.PropertyName
if (-not $__StringList.Contains($__CurrentSelect)) {
$__StringList.Add($__CurrentSelect)
}
if (-not $__GroupByList.Contains($__Property.Value.ColumnName)) {
$__GroupByList.Add($__Property.Value.ColumnName)
}
}
}
if ($PSBoundParameters.ContainsKey('GroupBy')) {
if ($__StringList.Count -eq 0) {
Write-Warning 'No -GroupBy parameters matched valid DB parameters, so Count will be the only property returned'
# Should this error the command out??
}
$__StringList.Add('COUNT(*) AS Count')
}
$null = $__SqlQuerySb.AppendFormat('SELECT{0}', $__JoinSpacingString)
if ($__StringList) {
$null = $__SqlQuerySb.AppendLine(($__StringList -join ",${__JoinSpacingString}"))
}
else {
$null = $__SqlQuerySb.AppendLine('*')
}
$__ValidOrderByParameterNames = $__StringList | select-string "(?<=\sas\s)(.*)$" | ForEach-Object Matches | ForEach-Object Value
$__StringList.Clear()
# Add the FROM clause
$null = $__SqlQuerySb.AppendFormat('FROM{0}', $__JoinSpacingString)
$FromClause = if ($__MyCommandInfo.FromClause -is [scriptblock]) {
& $__MyCommandInfo.FromClause
}
else {
$__MyCommandInfo.FromClause.ToString()
}
$FormattedFromClause = $FromClause | FormatFromClause
$null = $__SqlQuerySb.AppendLine($FormattedFromClause -join $__JoinSpacingString)
# Get WHERE clause info:
$__CombinedDbReaderInfo = CombineDbReaderInfo -ParamInfoTable $__PSBoundDbInfos -Negate $Negate
if ($__CombinedDbReaderInfo.WhereString) {
$__SqlQuerySb.AppendLine($__CombinedDbReaderInfo.WhereString) | Out-Null
}
# GROUP BY
if ($PSBoundParameters.ContainsKey('GroupBy') -and $__GroupByList.Count -gt 0) {
$null = $__SqlQuerySb.AppendFormat('GROUP BY{0}', $__JoinSpacingString)
$null = $__SqlQuerySb.AppendLine(($__GroupByList -join ",${__JoinSpacingString}"))
}
# ORDER BY
$__OrderByStrings = if ($PSBoundParameters.ContainsKey('OrderBy')) {
$PSBoundParameters['OrderBy'] | NewOrderByString -ValidNames $__ValidOrderByParameterNames
}
if ($__OrderByStrings.Count -gt 0) {
$null = $__SqlQuerySb.AppendFormat('ORDER BY{0}', $__JoinSpacingString)
$null = $__SqlQuerySb.AppendLine(($__OrderByStrings -join ",${__JoinSpacingString}"))
}
$__SqlQuery = $__SqlQuerySb.ToString()
if ($PSBoundParameters['ReturnSqlQueryNew']) {
# This parameter is only available in debug mode, and it changes the
# behavior of the command to just return this string instead of executing
# the command
[PSCustomObject] @{
Query = $__SqlQuery
Parameters = $__CombinedDbReaderInfo.QueryParameters
}
}
elseif ($PSBoundParameters['ReturnSqlQuery']) {
# This parameter is only available in debug mode, and it changes the
# behavior of the command to just return this string instead of executing
# the command
$__SqlQuery
if ($__CombinedDbReaderInfo.QueryParameters.Keys) {
$__MaxWidth = $__CombinedDbReaderInfo.QueryParameters.Keys.Length | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum
$__ParamStrings = foreach ($__Entry in $__CombinedDbReaderInfo.QueryParameters.GetEnumerator()) {
" {0,${__MaxWidth}}: {1}" -f $__Entry.Name, $__Entry.Value
}
@'
/*
Parameters:
{0}
*/
'@ -f ($__ParamStrings -join "`n")
}
}
else {
# Make a copy since we might add a pstype name
$__ConnectionParams = @{} + $__MyCommandInfo.DbConnectionParams
if ($__MyCommandInfo.Contains('PSTypeName') -and -not $PSBoundParameters.ContainsKey('GroupBy')) {
# Only add a typename if one's defined, and if command is not in GroupBy mode (that will change
# the look of the object)
$__ConnectionParams['PSTypeName'] = $__MyCommandInfo['PSTypeName']
}
Write-Debug "About to execute:`n${__SqlQuery}"
InvokeReaderCommand -Query $__SqlQuery @__ConnectionParams -QueryParameters $__CombinedDbReaderInfo.QueryParameters
}
}
end {
Write-Verbose "end PSBoundParameters {"
foreach ($__Param in $PSBoundParameters.GetEnumerator()) {
Write-Verbose (" {0} = {1}{2}" -f $__Param.Key, ($__Param.Value -join ', '), $(if ($__PSBoundDbInfos.Contains($__Param.Key)) {' (DBReaderInfo bound)'}))
}
Write-Verbose "}"
}
})
# Completers:
# OrderBy, GroupBy, and Negate all use the same completer. It has logic to change behavior based
# on what parameter is being used
$StandardArgumentCompleter = {
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
$PropertyParameters = $__CommandDeclarations[$commandName].PropertyParameters
$ValidResults = $PotentialResults = if ($parameterName -eq 'Negate') {
# Negate works against the actual parameter names
$PropertyParameters.Keys
}
else {
# And OrderBy and GroupBy work off of the column names
$PropertyParameters.Values.PropertyName | Sort-Object -Unique
}
if ($parameterName -eq 'OrderBy' -and $fakeBoundParameter.ContainsKey('GroupBy')) {
# When working on -OrderBy and -GroupBy has been specified, you can only use
# parameters from -GroupBy and the 'Count' alias (which needs to be configurable,
# but that's for a different day)
$PotentialResults = @($fakeBoundParameter['GroupBy']) + 'Count'
$ValidResults = @($ValidResults) + 'Count'
}
elseif ($parameterName -eq 'Negate') {
$PotentialResults = $fakeBoundParameter.Keys | Sort-Object -Unique
}
$PotentialResults | Where-Object { $_ -in $ValidResults -and $_ -like "*${wordToComplete}*" } | ForEach-Object {
New-Object System.Management.Automation.CompletionResult (
$_,
$_,
'ParameterValue',
$_
)
}
}
$BoundStandardCompleter = {}.Module.NewBoundScriptBlock($StandardArgumentCompleter)
$BoundDateTimeCompleter = {}.Module.NewBoundScriptBlock({
DateTimeConverter -wordToComplete $args[2]
})
# Helper functions:
function CombineDbReaderInfo {
<#
Helper function that takes DBReaderInfo objects and returns a PSObject with
the following information:
* WhereString
A string representation of the parameterized WHERE clause.
* Parameters
A hashtable that has any parameters referenced in the WhereString
In the future, this function will probably take on more work...
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Collections.IDictionary] $ParamInfoTable,
# Should come directly from the -Negate parameter to the command. This
# is needed to know when an entire parameter should be negated
[System.Collections.IList] $NegateCollection
)
end {
$QueryParameters = [ordered] @{}
$AllWhereConditions = foreach ($DbReaderInfoCollection in $ParamInfoTable.GetEnumerator()) {
$ParamIndex = 0
$ParamNamePrefix = $DbReaderInfoCollection.Name
$CurrInstanceConditions = foreach ($DbReaderInfoInstance in $DbReaderInfoCollection.Value) {
$Values = if ($DbreaderInfoInstance.IsNull) {
# NULLs are special, so no need to do any extra param magic
'{0} {1} NULL' -f $DbReaderInfoInstance.ColumnName, $DbReaderInfoInstance.ComparisonOperator
}
else {
# Possible to have multiple values, so go through each one,
# add it to the param table, and give it a placeholder string
# for the query. But first, make any necessary transformations
# on the input:
foreach ($CurrentValue in $DbReaderInfoInstance.Value) {
if ($DbReaderInfoInstance.AllowWildcards) {
$CurrentValue = $CurrentValue | WildcardStringToQuery
}
if ($DbReaderInfoInstance.TransformArgument -is [scriptblock]) {
$CurrentValue = $CurrentValue | ForEach-Object $DbReaderInfoInstance.TransformArgument
}
$ParamName = "@${ParamNamePrefix}${ParamIndex}"
$ParamIndex++
# Store the parameter in the hash table and emit the string w/ the
# contional operator and parameter name so we can combine it later
NewWhereCondition -DbReaderInfo $DbReaderInfoInstance -ParamName $ParamName
$QueryParameters[$ParamName] = $CurrentValue
}
}
'{0}({1})' -f $(if ($DbReaderInfoInstance.Negate) { 'NOT ' }), ($Values -join " $($DbReaderInfoInstance.ConditionalOperator) ")
}
'{0}({1})' -f $(if ($ParamNamePrefix -in $NegateCollection) { 'NOT ' }), ($CurrInstanceConditions -join ' OR ') # OR should be configurable
}
$WhereString = if ($AllWhereConditions) {
"WHERE${__JoinSpacingString}{0}" -f ($AllWhereConditions -join " AND${__JoinSpacingString}") # NEED TO MAKE AND CONFIGURABLE; $__JoinSpacingString is in parent scope, which is BAD. FIX IT!!
}
[PSCustomObject] @{
WhereString = $WhereString
QueryParameters = $QueryParameters
}
}
}
function WildcardStringToQuery {
<#
Handles converting globbing wildcards * and ? into SQL wildards (by default % and _, but
possibly others if there are any RDBM systems that don't use the standard ones)
Doing normal conversion can be handled by [WildcardPattern] ToWql() method, but that
escapes literal _ and % characters according to SQL Server escaping rules, which isn't
always what we want.
This helper function is smart enough to look into command definition info to see how to
handle escaping them.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string] $InputText
)
process {
$Scriptblock = $__MyCommandInfo[$DbCommandInfoAttributeProperties.WildcardReplacement]
if ($Scriptblock -as [scriptblock]) {
$InputText | ForEach-Object $Scriptblock
}
else {
Write-Warning "Unable to perform wildcard replacement for '$($DbReaderInfoCollection.Name)' property because WildcardReplacementScriptblock property was either missing or invalid."
$InputText
}
}
}
function NewWhereCondition {
<#
Allows complex where condition building. Originally, this was as simple as combining
the column name, the comparison operator, and the parameter name.
With support for features like SQLite's ESCAPE clause when using LIKE, the complexity
grew and required a helper function to take care of it.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$DbReaderInfoInstance,
[Parameter(Mandatory)]
[string] $ParamName
)
process {
$Scriptblock = $__MyCommandInfo[$DbCommandInfoAttributeProperties.WhereConditionBuilder]
if ($Scriptblock -as [scriptblock]) {
& $Scriptblock $DbReaderInfoInstance.ColumnName $DbReaderInfoInstance.ComparisonOperator $ParamName
}
else {
throw "Unable to build WHERE condition for '$($DbReaderInfoCollection.Name)' property because WhereConditionBuilderScriptblock property was either missing or invalid."
}
}
}
function GetDefaultAttributeProperty {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $PropertyName,
# Optional designator to help specify the DB system (used when default behavior should
# be different depending on the RDBMS)
[string] $DbSystem
)
end {
$DefaultKey = '__default__'
# The $DefaultKey definition should have a default for each attribute property. The other definitions
# don't require it, and if the $PropertyName being searched for doesn't exist in the dict for the other
# DB system, the function will fall back to the $DefaultKey definition's version
$Dict = @{
$DefaultKey = @{
$DbCommandInfoAttributeProperties.WildcardReplacement = {
(New-Object System.Management.Automation.WildcardPattern $_).ToWql()
}
$DbCommandInfoAttributeProperties.WhereConditionBuilder = {
'{0} {1} {2}' -f $args[0], $args[1], $args[2]
}
}
SQLite = @{
$DbCommandInfoAttributeProperties.WildcardReplacement = {
$WqlString = (New-Object System.Management.Automation.WildcardPattern $_).ToWql()
# We're going to use '\' as an escape character, so go ahead and replace any literal
# '\' characters with their escaped equivalent (\\)
$WqlString = $WqlString -replace '\\', '\\'
# Wql should have already replaced literal underscores and percent signs with their
# WQL escaped versions ([_] and [%]). Look for those and replace them with backslash
# escaped versions
$WqlString = $WqlString -replace '\[(_|\%)\]', '\$1'
$WqlString
}
}
}
# Add this to SQLite dictionary after the whole thing has been defined to get access
# to the default version:
$Dict.SQLite[$DbCommandInfoAttributeProperties.WhereConditionBuilder] = [scriptblock]::Create(@"
`$Default = & { $($Dict[$DefaultKey][$DbCommandInfoAttributeProperties.WhereConditionBuilder]) } @args
if (`$args[1] -eq 'LIKE') {
"`${Default} ESCAPE '\'"
}
else {
`$Default
}
"@)
$Key = if ($PSBoundParameters.ContainsKey('DbSystem')) {
# Put logic here to figure out the key to use. For now, just write out
# the -DbSystem parameter value
$DbSystem
}
else {
$DefaultKey
}
# NOTE: No type checking is done. If the dictionary contains the wrong default value, too bad :(
$ReturnValue = if ($Dict.ContainsKey($Key) -and $Dict[$Key] -is [System.Collections.IDictionary] -and $Dict[$Key].Contains($PropertyName)) {
$Dict[$Key][$PropertyName]
}
elseif ($Dict.ContainsKey($DefaultKey) -and $Dict[$DefaultKey] -is [System.Collections.IDictionary] -and $Dict[$DefaultKey].Contains($PropertyName)) {
$Dict[$DefaultKey][$PropertyName]
}
else {
Write-Error "Unable to find ${PropertyName} in default attribute property definition dictionary (checked the following sub dictionaries: $($Key, $DefaultKey | sort -Unique))"
}
$ReturnValue
}
}
function InvokeReaderCommand {
[CmdletBinding()]
param(
[Parameter(Mandatory, Position=0, ParameterSetName="ByConnectionString")]
[string] $ConnectionString,
[Parameter(Mandatory, ParameterSetName="ByConnectionString")]
# Might try to make just the short name work here if it's possible to find it based on being an inherited class of DbConnection...
[type] $ConnectionType,
[Parameter(Mandatory, Position=0, ParameterSetName="ByConnection")]
[System.Data.Common.DbConnection] $Connection,
[Parameter(Mandatory, Position=1)]
[string] $Query,
# Optional parameters for a parameterized query
[System.Collections.IDictionary] $QueryParameters,
# Optional PSTypeNames to add to the objects that are output
[string[]] $PSTypeName
)
process {
switch ($PSCmdlet.ParameterSetName) {
ByConnectionString {
# Create the connection:
try {
$Connection = New-Object -TypeName $ConnectionType $ConnectionString -ErrorAction Stop
}
catch {
Write-Error "Unable to open '$ConnectionType' connection with string '$ConnectionString': $_"
return
}
}
}
# Make sure the connection is open (if we created it in this function, it won't be). Also, if we open it,
# we need to close it
$NeedToClose = $false
if ($Connection.State -ne "Open") {
$Connection.Open()
$NeedToClose = $true
}
$Command = $Connection.CreateCommand()
$Command.Connection = $Connection
$Command.CommandText = $Query
if ($PSBoundParameters.ContainsKey('QueryParameters')) {
foreach ($QueryParamEntry in $QueryParameters.GetEnumerator()) {
$Param = $Command.CreateParameter()
$Param.ParameterName = $QueryParamEntry.Name
$Param.Value = $QueryParamEntry.Value
$Command.Parameters.Add($Param) | Out-Null
}
}
try {
$Reader = $Command.ExecuteReader()
$RecordObjectProperties = [ordered] @{}
while ($Reader.Read()) {
$RecordObjectProperties.Clear()
for ($i = 0; $i -lt $Reader.FieldCount; $i++) {
$Name = $Reader.GetName($i)
$Value = $Reader.GetValue($i)
if ([System.DBNull]::Value.Equals($Value)) { $Value = $script:DBRInfo.OutputNullReplacementString }
if ($RecordObjectProperties.Contains($Name) -and $RecordObjectProperties[$Name] -ne $Value) {
# If there's a duplicate, but the value is the same, don't worry about it and keep moving.
# If there's a duplicate name with a different value, though, we want to save that. First
# version is going to be inefficient until we can get enough data together to test out making
# a better way. First shot is going to simply append a suffix with a #, and keep incrementing
# the # until it finds an available property name.
$Suffix = '_dupe'
$DupeIndex = 1
while ($RecordObjectProperties.Contains(($Name = "${Name}${Suffix}${DupeIndex}"))) {
$DupeIndex++
}
}
$RecordObjectProperties[$Name] = $Value
}
$ReturnObject = [PSCustomObject] $RecordObjectProperties
if ($PSBoundParameters.ContainsKey('PSTypeName')) {
foreach ($CurrentTypeName in $PSTypeName) {
$ReturnObject.pstypenames.Insert(0, $CurrentTypeName)
}
}
$ReturnObject
}
}
catch {
Write-Warning "Error executing query '$Query' on '$($Connection.ConnectionString)':"
Write-Warning " -> $_"
return
}
finally {
if ($Reader) {
$Reader.Dispose()
}
if ($NeedToClose) {
$Connection.Close()
}
if ($PSCmdlet.ParameterSetName -eq "ByConnectionString") {
$Connection.Dispose()
}
}
}
}
function DateTimeConverter {
[CmdletBinding(DefaultParameterSetName='NormalConversion')]
param(
[Parameter(ValueFromPipeline, Mandatory, Position=0, ParameterSetName='NormalConversion')]
[AllowNull()]
$InputObject,
[Parameter(Mandatory, ParameterSetName='ArgumentCompleterMode')]
[AllowEmptyString()]
[string] $wordToComplete
)
begin {
$RegexInfo = @{
Intervals = Write-Output Minute, Hour, Day, Week, Month, Year # Regex would need to be redesigned if one of these can't be made plural with a simple 's' at the end
Separators = Write-Output \., \s, _ #, '\|' # Bad separator in practice, but maybe good for an example of how easy it is to add a separator and then get command completion and argument conversion to work
Adverbs = Write-Output Ago, FromNow
GenerateRegex = {
$Definition = $RegexInfo
$Separator = '({0})?' -f ($Definition.Separators -join '|') # ? makes separators optional
$Adverbs = '(?<adverb>{0})' -f ($Definition.Adverbs -join '|')
$Intervals = '((?<interval>{0})s?)' -f ($Definition.Intervals -join '|')
$Number = '(?<number>-?\d+)'
'^{0}{1}{2}{1}{3}$' -f $Number, $Separator, $Intervals, $Adverbs
}
}
$DateTimeStringRegex = & $RegexInfo.GenerateRegex
$DateTimeStringShortcuts = @{
Now = { Get-Date }
Today = { (Get-Date).ToShortDateString() }
ThisMonth = { $Now = Get-Date; Get-Date -Month $Now.Month -Day 1 -Year $Now.Year }
LastMonth = { $Now = Get-Date; (Get-Date -Month $Now.Month -Day 1 -Year $Now.Year).AddMonths(-1) }
NextMonth = { $Now = Get-Date; (Get-Date -Month $Now.Month -Day 1 -Year $Now.Year).AddMonths(1) }
}
}
process {
switch ($PSCmdlet.ParameterSetName) {
NormalConversion {
if ($InputObject -eq $null) {
$InputObject = [System.DBNull]::Value
}
foreach ($DateString in $InputObject) {
if ($DateString -eq $null) {
# Let the DbReaderInfo transformer handle this
$null
continue
}
elseif ($DateString -as [datetime]) {
# No need to do any voodoo if it can already be coerced to a datetime
$DateString
continue
}
if ($DateString -match $DateTimeStringRegex) {
$Multiplier = 1 # Only changed if 'week' is used
switch ($Matches.interval) {
<#
Allowed intervals: minute, hour, day, week, month, year
Of those, only 'week' doesn't have a method, so handle it special. The
others can be handled in the default{} case
#>
week {
$Multiplier = 7
$MethodName = 'AddDays'
}
default {
$MethodName = "Add${_}s"
}
}
switch ($Matches.adverb) {
fromnow {
# No change needed
}
ago {
# Multiplier needs to be negated
$Multiplier *= -1
}
}
try {
(Get-Date).$MethodName.Invoke($Multiplier * $matches.number)
continue
}
catch {
Write-Error $_
return
}
}
elseif ($DateTimeStringShortcuts.ContainsKey($DateString)) {
(& $DateTimeStringShortcuts[$DateString]) -as [datetime]
continue
}
else {
# Just return what was originally input; if this is used as an argument transformation, the binder will
# throw it's localized error message
$DateString
}
}
}
ArgumentCompleterMode {
$CompletionResults = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult]
# Check for any shortcut matches:
foreach ($Match in ($DateTimeStringShortcuts.Keys -like "*${wordToComplete}*")) {
$EvaluatedValue = & $DateTimeStringShortcuts[$Match]
$CompletionResults.Add((NewCompletionResult -CompletionText $Match -ToolTip "$Match [$EvaluatedValue]"))
}
# Check to see if they've typed anything that could resemble valid friedly text
# Trim wildcards??
if ($wordToComplete -match "^(-?\d+)(?<separator>$($RegexInfo.Separators -join '|'))?") {
$Length = $matches[1]
$Separator = " "
if ($matches.separator) {
$Separator = $matches.separator
}
$IntervalSuffix = 's'
if ($Length -eq '1') {
$IntervalSuffix = ''
}
foreach ($Interval in $RegexInfo.Intervals) {
foreach ($Adverb in $RegexInfo.Adverbs) {
# $CompletedText = $DisplayText = "${Length}${Separator}${Interval}${IntervalSuffix}${Separator}${Adverb}"
# if ($CompletedText -match '\s') {
# $CompletedText = "'$CompletedText'"
# }
$Text = "${Length}${Separator}${Interval}${IntervalSuffix}${Separator}${Adverb}"
if ($Text -like "*${wordToComplete}*") {
$CompletionResults.Add((NewCompletionResult -CompletionText $Text))
}
}
}
}
$CompletionResults
}
default {
# Shouldn't happen. Just don't return anything for now...
}
}
}
}
function NewCompletionResult {
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string] $CompletionText,
[string] $ListItemText,
[System.Management.Automation.CompletionResultType] $ResultType = 'ParameterValue',
[string] $ToolTip,
[switch] $NoQuotes
)
process {
if (-not $PSBoundParameters.ContainsKey('ListItemText')) {
$ListItemText = $CompletionText
}
if (-not $PSBoundParameters.ContainsKey('ToolTip')) {
$ToolTip = $CompletionText
}
# CHOOSING WHICH VARIABLE TO MATCH THIS TO MATTERS IN REGARDS TO BEHAVIOR OF COMMAND!!
if ($ListItemText -notlike "${wordToComplete}*") { return }
# Modified version of the check from TabExpansionPlusPlus (I added the single quote escaping)
if ($ResultType -eq [System.Management.Automation.CompletionResultType]::ParameterValue -and -not $NoQuotes) {
# Add single quotes for the caller in case they are needed.
# We use the parser to robustly determine how it will treat
# the argument. If we end up with too many tokens, or if
# the parser found something expandable in the results, we
# know quotes are needed.
$tokens = $null
$null = [System.Management.Automation.Language.Parser]::ParseInput("echo $CompletionText", [ref]$tokens, [ref]$null)
if ($tokens.Length -ne 3 -or
($tokens[1] -is [System.Management.Automation.Language.StringExpandableToken] -and
$tokens[1].Kind -eq [System.Management.Automation.Language.TokenKind]::Generic))
{
$CompletionText = "'$($CompletionText -replace "'", "''")'"
}
}
New-Object System.Management.Automation.CompletionResult $CompletionText, $ListItemText, $ResultType, $ToolTip
}
}
function NewOrderByString {
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline)]
[Alias('Expression', 'Value', 'Name', 'ColumnName')]
[object] $Property,
[switch] $Descending,
[string[]] $ValidNames
)
process {
foreach ($CurrentProperty in $Property) {
if ($CurrentProperty -is [hashtable]) {
try {
$Params = $CurrentProperty.Clone()
if ($Params.ContainsKey('ValidNames')) {
$Params.Remove('ValidNames')
}
(& $PSCmdlet.MyInvocation.MyCommand -ErrorAction Stop @Params)
}
catch {
throw $_
}
}
else {
$PropertyName = $CurrentProperty.ToString()
$DescendingEnabled = $Descending
try {
if ($ValidNames -notcontains $PropertyName) {
# This is a problem. A valid property wasn't specified. One last chance: does
# the string end with 'ASC' or 'DESC' or '!'?
if ($PropertyName -match "^(.*)((?<excmark>!)|\s+(?<orderstring>ASC|DESC))$") {
$PropertyName = $Matches[1]
if ($Matches.orderstring -eq "DESC" -or $Matches.excmark -eq "!") {
$DescendingEnabled = $true
}
else {
$DescendingEnabled = $false
}
if ($ValidNames -notcontains $PropertyName) {
throw "Unknown"
}
}
else {
throw "Unknown"
}
}
}
catch {
Write-Warning "Unknown -OrderBy property '$PropertyName' will be ignored"
continue
}
# Quick way to make this match the case from the SELECT statement. Should be able to refactor
# code above to do it without this last minute check
$PropertyName = $ValidNames -eq $PropertyName | Select-Object -first 1
"$PropertyName$(if ($DescendingEnabled) { " DESC" })"
}
}
}
}
function ContainsMatch {
<#
Used mostly to figure out if a value is contained in a collection. Can't just use -contains because
the collection can have wildcards in it
#>
param(
[string[]] $Collection,
[string] $ValueToMatch
)
foreach ($Current in $Collection) {
if ($ValueToMatch -like $Current) {
return $true
}
}
return $false
}
# Pretty much every argument this module generates will have a ROE.TransformParameter() attribute
# that calls this function to populate a side hashtable for each parameter in $PSBoundParameters.
# The side table contains a PSObject (one day a class, though) that contains DB relevant information,
# like whether or not the value should be negated, whether it was null, etc. It works with the
# NewDbReaderInfo function...
function AddDbReaderInfo {
<#
This function's job is to hide extra information in a strongly typed parameter, e.g.,
if you have an [int] named $Number that is a parameter for another function, this
function can be used in an argument transformation attribute to allow a hash table
to be passed in instead of just an [int], which opens the possibilty of negating a
parameter or changing the conditional operator used for joining multiple values
together (not applicable for [int], but would be for [int[]])
This function let's you do something like this:
function DemoAddDbReaderInfo {
param(
[ROE.TransformArgument({
AddDbReaderInfo $_ -OutputType [int[]]
})